1use alloy_dyn_abi::{DynSolValue, JsonAbiExt};
4use alloy_json_abi::Constructor;
5use alloy_primitives::{Address, Bytes, keccak256};
6use eyre::{Result, bail, eyre};
7use forge_verify::sourcify::SOURCIFY_URL;
8use foundry_compilers::{artifacts::CompilerOutput, solc::Solc};
9use foundry_config::{Chain, NamedChain};
10use futures::StreamExt;
11use semver::Version;
12use serde::Deserialize;
13use serde_json::{Value, json};
14use std::{
15 collections::{HashMap, HashSet},
16 process::Stdio,
17 sync::Arc,
18 time::Duration,
19};
20use tokio::{
21 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
22 process::Command,
23};
24
25const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
26const MAX_SOURCES: usize = 256;
27const MAX_FETCHES: usize = 64;
28const MAX_COMPILER_VERSIONS: usize = 8;
29const MAX_COMPILATIONS: usize = 32;
30const MAX_CANDIDATES: usize = 512;
31const MAX_CREATION_BYTECODE: usize = 64 * 1024 * 1024;
32const MAX_RETAINED_METADATA: usize = 8 * 1024 * 1024;
33const MAX_RETAINED_SOURCE_INPUT: usize = 64 * 1024 * 1024;
34const MAX_SOURCE_PATH: usize = 4096;
35const MAX_COMPILER_VERSION: usize = 256;
36const MAX_CACHED_ERROR_CHARS: usize = 512;
37const MAX_STDOUT: usize = 32 * 1024 * 1024;
38const MAX_STDERR: usize = 1024 * 1024;
39const COMPILE_TIMEOUT: Duration = Duration::from_secs(60);
40pub(super) const MAX_PROVENANCE_ADDRESSES: usize = 16;
41
42#[derive(Clone, Debug, PartialEq, Eq, Hash)]
43pub(super) enum SourceProvider {
44 Etherscan { endpoint: String },
45 Sourcify { endpoint: String },
46}
47
48impl SourceProvider {
49 const fn metadata_len(&self) -> usize {
50 match self {
51 Self::Etherscan { endpoint } | Self::Sourcify { endpoint } => endpoint.len(),
52 }
53 }
54}
55
56impl std::fmt::Display for SourceProvider {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::Etherscan { .. } => f.write_str("Etherscan"),
60 Self::Sourcify { .. } => f.write_str("Sourcify"),
61 }
62 }
63}
64
65#[derive(Clone, Debug)]
66pub(super) struct ExternalSource {
67 pub input: Arc<Value>,
68 pub version: Version,
69 pub provider: SourceProvider,
70}
71
72#[derive(Clone, Debug)]
73pub(super) struct Candidate {
74 pub input: Arc<Value>,
75 pub fingerprint: String,
76 pub version: Version,
77 pub fqn: String,
78 pub constructor: Option<Constructor>,
79 pub creation_bytecode: Bytes,
80}
81
82#[derive(Clone, Debug)]
83pub(super) struct ExternalMatch {
84 pub input: Arc<Value>,
85 pub version: Version,
86 pub fqn: String,
87 pub creation_bytecode: Bytes,
88 pub constructor_args: Bytes,
89}
90
91#[derive(Clone, Debug)]
92pub(super) enum MatchResult {
93 None,
94 Unique(ExternalMatch),
95 Ambiguous(Vec<ExternalMatch>),
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Hash)]
99struct FetchKey {
100 chain: u64,
101 provider: SourceProvider,
102 address: Address,
103}
104
105type CompiledCandidates = Result<(Arc<[Candidate]>, bool), String>;
106
107pub(super) struct ExternalResolver {
110 http: reqwest::Client,
111 fetch_cache: HashMap<FetchKey, Result<Option<ExternalSource>, String>>,
112 compile_cache: HashMap<(Version, String), CompiledCandidates>,
113 compiler_versions: HashSet<Version>,
114 compilations: usize,
115 retained_source_input: usize,
116 retained_candidates: usize,
117 retained_metadata: usize,
118 retained_creation_bytecode: usize,
119}
120
121impl ExternalResolver {
122 pub(super) fn new() -> Result<Self> {
123 let http = reqwest::Client::builder()
124 .timeout(Duration::from_secs(15))
125 .connect_timeout(Duration::from_secs(15))
126 .redirect(reqwest::redirect::Policy::none())
127 .build()?;
128 Ok(Self {
129 http,
130 fetch_cache: HashMap::new(),
131 compile_cache: HashMap::new(),
132 compiler_versions: HashSet::new(),
133 compilations: 0,
134 retained_source_input: 0,
135 retained_candidates: 0,
136 retained_metadata: 0,
137 retained_creation_bytecode: 0,
138 })
139 }
140
141 pub(super) async fn resolve_etherscan(
142 &mut self,
143 chain: Chain,
144 address: Address,
145 endpoint: Option<&str>,
146 api_key: Option<&str>,
147 ) -> Result<Option<ExternalSource>, String> {
148 let endpoint = endpoint.ok_or_else(|| "Etherscan is not configured".to_string())?;
149 let provider = SourceProvider::Etherscan { endpoint: endpoint.to_string() };
150 let key = FetchKey { chain: chain.id(), provider: provider.clone(), address };
151 if let Some(cached) = self.fetch_cache.get(&key) {
152 return cached.clone();
153 }
154 if self.fetch_cache.len() >= MAX_FETCHES {
155 return Err("external source fetch limit exceeded".to_string());
156 }
157 let result = async {
158 let address = address.to_string();
159 let response = self
160 .http
161 .get(endpoint)
162 .query(&[
163 ("module", "contract"),
164 ("action", "getsourcecode"),
165 ("address", address.as_str()),
166 ("apikey", api_key.unwrap_or_default()),
167 ])
168 .send()
169 .await
170 .map_err(|_| "Etherscan request failed".to_string())?;
171 if !response.status().is_success() {
172 return Err(format!("Etherscan returned HTTP {}", response.status()));
173 }
174 if response.content_length().is_some_and(|length| length > MAX_INPUT_SIZE as u64) {
175 return Err("Etherscan response exceeds 10 MiB".to_string());
176 }
177 let body = read_capped_body(response, MAX_INPUT_SIZE, "Etherscan").await?;
178 let (input, version) = parse_etherscan_response(&body)?;
179 Ok(Some(ExternalSource { input: Arc::new(input), version, provider }))
180 }
181 .await;
182 self.cache_source_result(key, result)
183 }
184
185 pub(super) async fn resolve_sourcify(
186 &mut self,
187 chain: Chain,
188 address: Address,
189 endpoint: Option<&str>,
190 ) -> Result<Option<ExternalSource>, String> {
191 let Some(endpoint) = endpoint else { return Ok(None) };
192 if endpoint == SOURCIFY_URL
195 && matches!(
196 chain.named(),
197 Some(NamedChain::Dev | NamedChain::AnvilHardhat | NamedChain::Cannon)
198 )
199 {
200 return Ok(None);
201 }
202 let provider = SourceProvider::Sourcify { endpoint: endpoint.to_string() };
203 let key = FetchKey { chain: chain.id(), provider: provider.clone(), address };
204 if let Some(cached) = self.fetch_cache.get(&key) {
205 return cached.clone();
206 }
207 if self.fetch_cache.len() >= MAX_FETCHES {
208 return Err("external source fetch limit exceeded".to_string());
209 }
210 let result = async {
211 let endpoint = endpoint.trim_end_matches('/');
212 let url = format!(
213 "{endpoint}/v2/contract/{}/{address}?fields=stdJsonInput,compilation.compilerVersion",
214 chain.id()
215 );
216 let response = self.http.get(url).send().await.map_err(|_| "Sourcify request failed".to_string())?;
217 if response.status() == reqwest::StatusCode::NOT_FOUND {
218 return Ok(None)
219 }
220 if !response.status().is_success() {
221 return Err(format!("Sourcify returned HTTP {}", response.status()))
222 }
223 if response.content_length().is_some_and(|length| length > MAX_INPUT_SIZE as u64) {
224 return Err("Sourcify response exceeds 10 MiB".to_string())
225 }
226 let body = read_capped_body(response, MAX_INPUT_SIZE, "Sourcify").await?;
227 let response: SourcifyResponse =
228 serde_json::from_slice(&body).map_err(|e| format!("invalid Sourcify response: {e}"))?;
229 validate_input(&response.std_json_input).map_err(|e| e.to_string())?;
230 let version = parse_compiler_version(&response.compilation.compiler_version)
231 .map_err(|e| e.to_string())?;
232 Ok(Some(ExternalSource {
233 input: Arc::new(response.std_json_input),
234 version,
235 provider,
236 }))
237 }
238 .await;
239 self.cache_source_result(key, result)
240 }
241
242 pub(super) async fn compile(
243 &mut self,
244 source: &ExternalSource,
245 ) -> Result<(Arc<[Candidate]>, bool), String> {
246 let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?;
247 let key = (source.version.clone(), fingerprint);
248 if let Some(cached) = self.compile_cache.get(&key) {
249 return cached.clone();
250 }
251 if self.compilations >= MAX_COMPILATIONS {
252 return Err("external compilation limit exceeded".to_string());
253 }
254 let new_version = !self.compiler_versions.contains(&source.version);
255 if new_version && self.compiler_versions.len() >= MAX_COMPILER_VERSIONS {
256 return Err("external compiler version limit exceeded".to_string());
257 }
258 let version_len = source.version.to_string().len();
259 let cache_metadata = key
260 .1
261 .len()
262 .saturating_add(version_len)
263 .saturating_add(if new_version { version_len } else { 0 });
264 self.compilations += 1;
265 let result = match compile_source(source).await {
266 Ok((candidates, has_unresolved_links)) => self
267 .charge_candidates(candidates, cache_metadata)
268 .map(|candidates| (candidates, has_unresolved_links)),
269 Err(err) => Err(err),
270 };
271 let result = match result {
272 Ok(candidates) => Ok(candidates),
273 Err(err) => {
274 let err = bound_cached_error(err);
275 self.charge_metadata(cache_metadata.saturating_add(err.len()))?;
276 Err(err)
277 }
278 };
279 self.compiler_versions.insert(source.version.clone());
280 self.compile_cache.insert(key, result.clone());
281 result
282 }
283
284 fn charge_candidates(
285 &mut self,
286 candidates: Vec<Candidate>,
287 cache_metadata: usize,
288 ) -> Result<Arc<[Candidate]>, String> {
289 let bytes = candidates.iter().fold(0usize, |total, candidate| {
290 total.saturating_add(candidate.creation_bytecode.len())
291 });
292 let metadata = candidates.iter().try_fold(cache_metadata, |total, candidate| {
293 let constructor = candidate
294 .constructor
295 .as_ref()
296 .map(serde_json::to_vec)
297 .transpose()
298 .map_err(|_| "failed to measure candidate constructor".to_string())?
299 .map_or(0, |constructor| constructor.len());
300 Ok::<_, String>(
301 total
302 .saturating_add(candidate.fqn.len())
303 .saturating_add(candidate.fingerprint.len())
304 .saturating_add(candidate.version.to_string().len())
305 .saturating_add(constructor),
306 )
307 })?;
308 if self.retained_candidates.saturating_add(candidates.len()) > MAX_CANDIDATES {
309 return Err("external cumulative candidate limit exceeded".to_string());
310 }
311 if self.retained_metadata.saturating_add(metadata) > MAX_RETAINED_METADATA {
312 return Err("external cumulative metadata limit exceeded".to_string());
313 }
314 if self.retained_creation_bytecode.saturating_add(bytes) > MAX_CREATION_BYTECODE {
315 return Err("external cumulative creation bytecode limit exceeded".to_string());
316 }
317 self.retained_candidates += candidates.len();
318 self.retained_metadata += metadata;
319 self.retained_creation_bytecode += bytes;
320 Ok(Arc::from(candidates))
321 }
322
323 fn cache_source_result(
324 &mut self,
325 key: FetchKey,
326 result: Result<Option<ExternalSource>, String>,
327 ) -> Result<Option<ExternalSource>, String> {
328 let result = result.map_err(bound_cached_error);
329 let source_bytes = match &result {
330 Ok(Some(source)) => serde_json::to_vec(&source.input)
331 .map_err(|_| "failed to measure Standard JSON input".to_string())?
332 .len(),
333 Ok(None) | Err(_) => 0,
334 };
335 let retained = key.provider.metadata_len().saturating_add(match &result {
336 Ok(Some(source)) => {
337 source.provider.metadata_len().saturating_add(source.version.to_string().len())
338 }
339 Ok(None) => 0,
340 Err(err) => err.len(),
341 });
342 if self.retained_source_input.saturating_add(source_bytes) > MAX_RETAINED_SOURCE_INPUT {
343 return Err("external cumulative source input limit exceeded".to_string());
344 }
345 if self.retained_metadata.saturating_add(retained) > MAX_RETAINED_METADATA {
346 return Err("external cumulative metadata limit exceeded".to_string());
347 }
348 self.retained_source_input += source_bytes;
349 self.retained_metadata += retained;
350 self.fetch_cache.insert(key, result.clone());
351 result
352 }
353
354 fn charge_metadata(&mut self, bytes: usize) -> Result<(), String> {
355 if self.retained_metadata.saturating_add(bytes) > MAX_RETAINED_METADATA {
356 return Err("external cumulative metadata limit exceeded".to_string());
357 }
358 self.retained_metadata += bytes;
359 Ok(())
360 }
361}
362
363fn bound_cached_error(error: String) -> String {
364 let mut chars = error.chars();
365 let error = chars.by_ref().take(MAX_CACHED_ERROR_CHARS).collect::<String>();
366 if chars.next().is_some() { format!("{error}…") } else { error }
367}
368
369async fn read_capped_body(
370 response: reqwest::Response,
371 limit: usize,
372 provider: &str,
373) -> Result<Vec<u8>, String> {
374 let mut body = Vec::new();
375 let mut stream = response.bytes_stream();
376 while let Some(chunk) = stream.next().await {
377 let chunk = chunk.map_err(|_| format!("invalid {provider} response"))?;
378 if !append_capped(&mut body, &chunk, limit) {
379 return Err(format!("{provider} response exceeds 10 MiB"));
380 }
381 }
382 Ok(body)
383}
384
385fn append_capped(output: &mut Vec<u8>, chunk: &[u8], limit: usize) -> bool {
386 if output.len().saturating_add(chunk.len()) > limit {
387 return false;
388 }
389 output.extend_from_slice(chunk);
390 true
391}
392
393fn parse_etherscan_response(body: &[u8]) -> Result<(Value, Version), String> {
394 let response: Value =
395 serde_json::from_slice(body).map_err(|e| format!("invalid Etherscan response: {e}"))?;
396 if response.get("status").and_then(Value::as_str) != Some("1") {
397 let message = response.get("result").and_then(Value::as_str).unwrap_or("request failed");
398 return Err(format!("Etherscan request failed: {message}"));
399 }
400 let item = response
401 .get("result")
402 .and_then(Value::as_array)
403 .and_then(|items| items.first())
404 .and_then(Value::as_object)
405 .ok_or_else(|| "empty Etherscan response".to_string())?;
406 let compiler_version = item
407 .get("CompilerVersion")
408 .and_then(Value::as_str)
409 .ok_or_else(|| "Etherscan compiler version is missing".to_string())?;
410 let compiler_version =
411 parse_compiler_version(compiler_version).map_err(|err| err.to_string())?;
412 let source =
413 item.get("SourceCode").ok_or_else(|| "Etherscan source code is missing".to_string())?;
414 let input = match source {
415 Value::Object(_) => source.clone(),
416 Value::String(source) => {
417 let wrapped = source.starts_with("{{") && source.ends_with("}}");
418 let source = if wrapped { &source[1..source.len() - 1] } else { source };
419 serde_json::from_str(source)
420 .map_err(|_| "Etherscan did not return Standard JSON sources".to_string())?
421 }
422 _ => return Err("Etherscan did not return Standard JSON sources".to_string()),
423 };
424 validate_input(&input).map_err(|e| e.to_string())?;
425 Ok((input, compiler_version))
426}
427
428#[derive(Deserialize)]
429#[serde(rename_all = "camelCase")]
430struct SourcifyResponse {
431 std_json_input: Value,
432 compilation: SourcifyCompilation,
433}
434
435#[derive(Deserialize)]
436#[serde(rename_all = "camelCase")]
437struct SourcifyCompilation {
438 compiler_version: String,
439}
440
441fn parse_compiler_version(version: &str) -> Result<Version> {
442 if version.len() > MAX_COMPILER_VERSION {
443 bail!("compiler version exceeds {MAX_COMPILER_VERSION} bytes");
444 }
445 Version::parse(version.trim_start_matches('v')).map_err(Into::into)
446}
447
448fn validate_input(input: &Value) -> Result<()> {
449 if serde_json::to_vec(input)?.len() > MAX_INPUT_SIZE {
450 bail!("Standard JSON input exceeds 10 MiB");
451 }
452 let object = input.as_object().ok_or_else(|| eyre!("Standard JSON input is not an object"))?;
453 if object.get("language").and_then(Value::as_str) != Some("Solidity") {
454 bail!("source language is not Solidity");
455 }
456 let sources = object
457 .get("sources")
458 .and_then(Value::as_object)
459 .ok_or_else(|| eyre!("Standard JSON sources are missing"))?;
460 if sources.is_empty() || sources.len() > MAX_SOURCES {
461 bail!("Standard JSON must contain 1 to 256 sources");
462 }
463 for (path, source) in sources {
464 validate_identifier(path, "source path")?;
465 let source = source.as_object().ok_or_else(|| eyre!("invalid source entry"))?;
466 if source.get("content").and_then(Value::as_str).is_none_or(str::is_empty) {
467 bail!("every source must contain nonempty literal content");
468 }
469 }
470 if !object.get("settings").is_some_and(Value::is_object) {
471 bail!("Standard JSON settings must be an object");
472 }
473 Ok(())
474}
475
476fn validate_identifier(value: &str, label: &str) -> Result<()> {
477 if value.is_empty() || value.len() > MAX_SOURCE_PATH {
478 bail!("{label} must contain 1 to 4096 bytes");
479 }
480 if !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
481 bail!("{label} must contain printable ASCII characters");
482 }
483 Ok(())
484}
485
486fn compilation_input(input: &Value) -> Result<Value> {
487 validate_input(input)?;
488 let mut input = input.clone();
489 input["settings"]["outputSelection"] = json!({
490 "*": { "*": ["abi", "evm.bytecode.object", "evm.bytecode.linkReferences"] }
491 });
492 Ok(input)
493}
494
495fn fingerprint(input: &Value) -> Result<String> {
496 validate_input(input)?;
497 let mut input = input.clone();
498 input.sort_all_objects();
499 Ok(keccak256(serde_json::to_vec(&input)?).to_string())
500}
501
502fn compiler_matches(requested: &Version, actual: &Version) -> bool {
503 requested.major == actual.major
504 && requested.minor == actual.minor
505 && requested.patch == actual.patch
506 && requested.pre == actual.pre
507 && requested.build.as_str().split('.').take(2).eq(actual.build.as_str().split('.').take(2))
508}
509
510fn compiler_identity(version: &Version) -> String {
511 let build = version.build.as_str().split('.').take(2).collect::<Vec<_>>().join(".");
512 format!("{}.{}.{}-{}+{build}", version.major, version.minor, version.patch, version.pre)
513}
514
515async fn compile_source(source: &ExternalSource) -> Result<(Vec<Candidate>, bool), String> {
516 let input = compilation_input(&source.input).map_err(|e| e.to_string())?;
517 let svm_version =
518 Version::new(source.version.major, source.version.minor, source.version.patch);
519 let solc = match Solc::find_svm_installed_version(&svm_version).map_err(|e| e.to_string())? {
520 Some(solc) => solc,
521 None => tokio::time::timeout(Duration::from_secs(60), Solc::install(&svm_version))
522 .await
523 .map_err(|_| "solc installation timed out".to_string())?
524 .map_err(|e| e.to_string())?,
525 };
526 let mut version_command = Command::new(&solc.solc);
527 version_command.arg("--version");
528 let version_output =
529 run_bounded_command(version_command, None, 64 * 1024, 64 * 1024, Duration::from_secs(10))
530 .await?;
531 let actual = parse_solc_version_output(&version_output.0)?;
532 if !compiler_matches(&source.version, &actual) {
533 return Err(format!("installed solc {actual} does not match requested {}", source.version));
534 }
535 let workdir =
536 tempfile::tempdir().map_err(|_| "failed to create compiler sandbox".to_string())?;
537 let raw_input =
538 serde_json::to_vec(&input).map_err(|_| "failed to encode compiler input".to_string())?;
539 let mut command = Command::new(&solc.solc);
540 command
541 .arg("--standard-json")
542 .arg("--base-path")
543 .arg(workdir.path())
544 .current_dir(workdir.path())
545 .stdin(Stdio::piped())
546 .stdout(Stdio::piped())
547 .stderr(Stdio::piped())
548 .kill_on_drop(true);
549 if source.version >= Version::new(0, 8, 22) {
552 command.arg("--no-import-callback");
553 }
554 let (stdout, stderr, status) =
555 run_bounded_command(command, Some(raw_input), MAX_STDOUT, MAX_STDERR, COMPILE_TIMEOUT)
556 .await?;
557 if !status.success() {
558 return Err(format!("solc compilation failed: {}", sanitize_remote(&stderr)));
559 }
560 let output: CompilerOutput =
561 serde_json::from_slice(&stdout).map_err(|_| "solc returned invalid JSON".to_string())?;
562 if output.has_error() {
563 return Err("solc compilation failed".to_string());
564 }
565 candidates_from_output(source, output)
566}
567
568fn candidates_from_output(
569 source: &ExternalSource,
570 output: CompilerOutput,
571) -> Result<(Vec<Candidate>, bool), String> {
572 let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?;
573 let mut candidates = Vec::new();
574 let mut creation_bytecode_bytes = 0usize;
575 let mut has_unresolved_links = false;
576 for (path, contracts) in output.contracts {
577 for (name, contract) in contracts {
578 let Some(abi) = contract.abi else { continue };
579 let Some(bytecode) = contract.evm.and_then(|evm| evm.bytecode) else { continue };
580 if !bytecode.link_references.is_empty() || bytecode.object.is_unlinked() {
581 has_unresolved_links = true;
582 continue;
583 }
584 let Some(bytes) = bytecode.object.into_bytes() else { continue };
585 if bytes.is_empty() {
586 continue;
587 }
588 validate_identifier(&path.to_string_lossy(), "compiler source path")
589 .map_err(|e| e.to_string())?;
590 validate_identifier(&name, "contract name").map_err(|e| e.to_string())?;
591 if candidates.len() >= MAX_CANDIDATES {
592 return Err("external candidate limit exceeded".to_string());
593 }
594 if creation_bytecode_bytes.saturating_add(bytes.len()) > MAX_CREATION_BYTECODE {
595 return Err("external creation bytecode limit exceeded".to_string());
596 }
597 creation_bytecode_bytes += bytes.len();
598 candidates.push(Candidate {
599 input: source.input.clone(),
600 fingerprint: fingerprint.clone(),
601 version: source.version.clone(),
602 fqn: format!("{}:{name}", path.display()),
603 constructor: abi.constructor().cloned(),
604 creation_bytecode: bytes,
605 });
606 }
607 }
608 Ok((candidates, has_unresolved_links))
609}
610
611fn parse_solc_version_output(output: &[u8]) -> Result<Version, String> {
612 let output =
613 std::str::from_utf8(output).map_err(|_| "invalid solc version output".to_string())?;
614 output
615 .lines()
616 .find_map(|line| line.trim().strip_prefix("Version: "))
617 .ok_or_else(|| "solc version output is missing Version line".to_string())
618 .and_then(|version| {
621 Version::parse(&version.replace(".g++", ".gcc"))
622 .map_err(|_| "invalid solc version".to_string())
623 })
624}
625
626async fn run_bounded_command(
627 mut command: Command,
628 input: Option<Vec<u8>>,
629 stdout_limit: usize,
630 stderr_limit: usize,
631 timeout: Duration,
632) -> Result<(Vec<u8>, Vec<u8>, std::process::ExitStatus), String> {
633 let deadline = tokio::time::Instant::now() + timeout;
634 let cleanup_window = std::cmp::min(timeout / 10, Duration::from_secs(1));
637 let execution_deadline = deadline - cleanup_window;
638 command
639 .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
640 .stdout(Stdio::piped())
641 .stderr(Stdio::piped())
642 .kill_on_drop(true);
643 let mut child = command.spawn().map_err(|_| "failed to start subprocess".to_string())?;
644 let stdout =
645 child.stdout.take().ok_or_else(|| "failed to open subprocess stdout".to_string())?;
646 let stderr =
647 child.stderr.take().ok_or_else(|| "failed to open subprocess stderr".to_string())?;
648 let mut input_task = input.map(|input| {
649 let mut stdin = child.stdin.take().expect("piped stdin");
650 tokio::spawn(async move {
651 stdin.write_all(&input).await?;
652 stdin.shutdown().await
653 })
654 });
655 let mut output_task = tokio::spawn(async move {
656 tokio::try_join!(read_capped(stdout, stdout_limit), read_capped(stderr, stderr_limit))
657 });
658 let mut input_joined = false;
659 let mut output_joined = false;
660 let lifecycle = async {
661 let mut completed_output = None;
662 let status = tokio::select! {
663 result = child.wait() => {
664 result.map_err(|_| "failed to wait for subprocess".to_string())?
665 }
666 output = &mut output_task => {
667 output_joined = true;
668 match output {
669 Ok(Ok(output)) => {
670 completed_output = Some(output);
671 child.wait().await.map_err(|_| "failed to wait for subprocess".to_string())?
672 }
673 Ok(Err(err)) => return Err(err),
674 Err(_) => return Err("subprocess output task failed".to_string()),
675 }
676 }
677 };
678 if let Some(task) = input_task.as_mut() {
679 let input = task.await;
680 input_joined = true;
681 input
682 .map_err(|_| "subprocess input task failed".to_string())?
683 .map_err(|_| "failed to write subprocess input".to_string())?;
684 }
685 let (stdout, stderr) = match completed_output {
686 Some(output) => output,
687 None => {
688 let output = (&mut output_task).await;
689 output_joined = true;
690 output.map_err(|_| "subprocess output task failed".to_string())??
691 }
692 };
693 Ok((stdout, stderr, status))
694 };
695 let result = match tokio::time::timeout_at(execution_deadline, lifecycle).await {
696 Ok(Ok(output)) => Ok(output),
697 Ok(Err(err)) => Err(err),
698 Err(_) => Err("subprocess timed out".to_string()),
699 };
700 if result.is_ok() {
701 return result;
702 }
703
704 if let Some(task) = &input_task {
705 task.abort();
706 }
707 output_task.abort();
708 let _ = child.start_kill();
709 let cleanup = async {
710 if !input_joined && let Some(task) = input_task {
711 let _ = task.await;
712 }
713 if !output_joined {
714 let _ = output_task.await;
715 }
716 let _ = child.wait().await;
717 };
718 let _ = tokio::time::timeout_at(deadline, cleanup).await;
721 result
722}
723
724async fn read_capped(mut reader: impl AsyncRead + Unpin, limit: usize) -> Result<Vec<u8>, String> {
725 let mut output = Vec::new();
726 let mut chunk = [0; 8192];
727 loop {
728 let read =
729 reader.read(&mut chunk).await.map_err(|_| "failed to read solc output".to_string())?;
730 if read == 0 {
731 return Ok(output);
732 }
733 if output.len().saturating_add(read) > limit {
734 return Err("solc output limit exceeded".to_string());
735 }
736 output.extend_from_slice(&chunk[..read]);
737 }
738}
739
740fn sanitize_remote(message: &[u8]) -> String {
741 let message = String::from_utf8_lossy(message);
742 let clean = message
743 .chars()
744 .map(|ch| if ch.is_control() { ' ' } else { ch })
745 .take(160)
746 .collect::<String>();
747 if clean.is_empty() { "no diagnostic".to_string() } else { clean }
748}
749
750fn is_canonical_value(value: &DynSolValue) -> bool {
751 match value {
752 DynSolValue::Uint(value, bits) => value.bit_len() <= *bits,
753 DynSolValue::Int(value, bits) => {
754 if *bits == 0 || *bits > 256 || bits % 8 != 0 {
755 return false;
756 }
757 let bytes = value.to_be_bytes::<32>();
758 let value_start = 32 - bits / 8;
759 let extension = if bytes[value_start] & 0x80 == 0 { 0 } else { 0xff };
760 bytes[..value_start].iter().all(|byte| *byte == extension)
761 }
762 DynSolValue::FixedBytes(value, size) => {
763 *size <= value.len() && value[*size..].iter().all(|byte| *byte == 0)
764 }
765 DynSolValue::Array(values)
766 | DynSolValue::FixedArray(values)
767 | DynSolValue::Tuple(values) => values.iter().all(is_canonical_value),
768 DynSolValue::CustomStruct { tuple, .. } => tuple.iter().all(is_canonical_value),
769 DynSolValue::Bool(_)
770 | DynSolValue::Address(_)
771 | DynSolValue::Function(_)
772 | DynSolValue::Bytes(_)
773 | DynSolValue::String(_) => true,
774 }
775}
776
777pub(super) fn match_candidates<'a>(
778 observed: &[u8],
779 candidates: impl IntoIterator<Item = &'a Candidate>,
780) -> MatchResult {
781 let mut matches = Vec::new();
782 for candidate in candidates {
783 let bytecode = candidate.creation_bytecode.as_ref();
784 if bytecode.is_empty() {
785 continue;
786 }
787 let Some(suffix) = observed.strip_prefix(bytecode) else { continue };
788 let valid = match &candidate.constructor {
789 None => true,
790 Some(constructor) if constructor.inputs.is_empty() => true,
791 Some(constructor) => constructor
792 .abi_decode_input(suffix)
793 .ok()
794 .filter(|values| values.iter().all(is_canonical_value))
795 .and_then(|values| constructor.abi_encode_input(&values).ok())
796 .is_some_and(|encoded| suffix.starts_with(&encoded)),
797 };
798 if valid
799 && !matches.iter().any(|item: &ExternalMatch| {
800 item.fqn == candidate.fqn
801 && compiler_identity(&item.version) == compiler_identity(&candidate.version)
802 && item.creation_bytecode == candidate.creation_bytecode
803 && item.constructor_args.as_ref() == suffix
804 })
805 {
806 matches.push(ExternalMatch {
807 input: candidate.input.clone(),
808 version: candidate.version.clone(),
809 fqn: candidate.fqn.clone(),
810 creation_bytecode: candidate.creation_bytecode.clone(),
811 constructor_args: Bytes::copy_from_slice(suffix),
812 });
813 }
814 }
815 match matches.len() {
816 0 => MatchResult::None,
817 1 => MatchResult::Unique(matches.pop().unwrap()),
818 _ => MatchResult::Ambiguous(matches),
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use alloy_json_abi::JsonAbi;
826 use foundry_cli::utils::parse_json;
827 use tokio::net::TcpListener;
828
829 fn input() -> Value {
830 json!({
831 "language": "Solidity",
832 "sources": { "A.sol": { "content": "contract A {}", "custom": 1 } },
833 "settings": { "optimizer": { "enabled": true }, "outputSelection": {"old": []} },
834 "unknown": { "preserved": true }
835 })
836 }
837
838 fn candidate(fqn: &str, abi: JsonAbi) -> Candidate {
839 Candidate {
840 input: Arc::new(input()),
841 fingerprint: "fingerprint".into(),
842 version: Version::parse("0.8.30+commit.73712a01").unwrap(),
843 fqn: fqn.into(),
844 constructor: abi.constructor().cloned(),
845 creation_bytecode: Bytes::from_static(&[0x60, 0x00]),
846 }
847 }
848
849 #[test]
850 fn validation_preserves_unknown_fields_and_only_changes_output_selection() {
851 let original = input();
852 let compiled = compilation_input(&original).unwrap();
853 assert_eq!(compiled["unknown"], original["unknown"]);
854 assert_eq!(compiled["sources"], original["sources"]);
855 assert_eq!(compiled["settings"]["optimizer"], original["settings"]["optimizer"]);
856 assert_ne!(
857 compiled["settings"]["outputSelection"],
858 original["settings"]["outputSelection"]
859 );
860 assert_eq!(original["settings"]["outputSelection"], json!({"old": []}));
861
862 let mut url_only = input();
863 url_only["sources"]["A.sol"] = json!({"urls": ["ipfs://source"]});
864 assert!(validate_input(&url_only).is_err());
865 }
866
867 #[test]
868 fn source_paths_are_bounded_printable_ascii() {
869 assert!(validate_identifier("src/A.sol", "path").is_ok());
870 assert!(validate_identifier("bad\npath.sol", "path").is_err());
871 assert!(validate_identifier("café.sol", "path").is_err());
872 assert!(validate_identifier(&"x".repeat(MAX_SOURCE_PATH + 1), "path").is_err());
873 }
874
875 #[test]
876 fn cumulative_source_budget_charges_success_once() {
877 let mut resolver = ExternalResolver::new().unwrap();
878 let source = ExternalSource {
879 input: Arc::new(input()),
880 version: Version::new(0, 8, 30),
881 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
882 };
883 let key = FetchKey { chain: 1, provider: source.provider.clone(), address: Address::ZERO };
884 let charged = resolver.cache_source_result(key.clone(), Ok(Some(source))).unwrap();
885 assert!(charged.is_some());
886 let charged_bytes = resolver.retained_source_input;
887 assert!(resolver.fetch_cache.get(&key).unwrap().is_ok());
889 assert_eq!(resolver.retained_source_input, charged_bytes);
890
891 resolver.retained_source_input = MAX_RETAINED_SOURCE_INPUT;
892 let rejected_key = FetchKey { address: Address::with_last_byte(1), ..key };
893 assert!(
894 resolver
895 .cache_source_result(rejected_key.clone(), Ok(charged))
896 .unwrap_err()
897 .contains("cumulative")
898 );
899 assert!(!resolver.fetch_cache.contains_key(&rejected_key));
900 }
901
902 #[test]
903 fn cumulative_candidate_metadata_budget_is_enforced() {
904 let mut resolver = ExternalResolver::new().unwrap();
905 let retained_candidate = candidate("A.sol:A", JsonAbi::new());
906 let expected = retained_candidate.fqn.len()
907 + retained_candidate.fingerprint.len()
908 + retained_candidate.version.to_string().len();
909 resolver.charge_candidates(vec![retained_candidate], 0).unwrap();
910 assert_eq!(resolver.retained_metadata, expected);
911
912 let mut resolver = ExternalResolver::new().unwrap();
913 resolver.retained_metadata = MAX_RETAINED_METADATA;
914 let error =
915 resolver.charge_candidates(vec![candidate("A.sol:A", JsonAbi::new())], 0).unwrap_err();
916 assert!(error.contains("metadata"));
917 assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA);
918 assert_eq!(resolver.retained_candidates, 0);
919 assert_eq!(resolver.retained_creation_bytecode, 0);
920
921 let mut resolver = ExternalResolver::new().unwrap();
922 resolver.retained_creation_bytecode = MAX_CREATION_BYTECODE;
923 let error =
924 resolver.charge_candidates(vec![candidate("A.sol:A", JsonAbi::new())], 0).unwrap_err();
925 assert!(error.contains("bytecode"));
926 assert_eq!(resolver.retained_metadata, 0);
927 assert_eq!(resolver.retained_candidates, 0);
928 assert_eq!(resolver.retained_creation_bytecode, MAX_CREATION_BYTECODE);
929 }
930
931 #[test]
932 fn candidate_constructor_metadata_is_charged() {
933 let abi: JsonAbi = serde_json::from_value(json!([{
934 "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
935 }]))
936 .unwrap();
937 let candidate = candidate("A.sol:A", abi);
938 let constructor_bytes =
939 serde_json::to_vec(candidate.constructor.as_ref().unwrap()).unwrap();
940 let mut resolver = ExternalResolver::new().unwrap();
941 resolver.charge_candidates(vec![candidate], 0).unwrap();
942 assert!(resolver.retained_metadata >= constructor_bytes.len());
943 }
944
945 #[tokio::test]
946 async fn sourcify_source_discovery_uses_selected_endpoint_on_dev_chain() {
947 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
948 let endpoint = format!("http://{}/private", listener.local_addr().unwrap());
949 let response = json!({
950 "stdJsonInput": input(),
951 "compilation": { "compilerVersion": "0.8.30+commit.73712a01" }
952 })
953 .to_string();
954 let server = tokio::spawn(async move {
955 let (mut socket, _) = listener.accept().await.unwrap();
956 let mut request = [0; 4096];
957 let bytes_read = socket.read(&mut request).await.unwrap();
958 let request = std::str::from_utf8(&request[..bytes_read]).unwrap();
959 assert!(request.starts_with("GET /private/v2/contract/31337/"), "{request}");
960 socket
961 .write_all(
962 format!(
963 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",
964 response.len()
965 )
966 .as_bytes(),
967 )
968 .await
969 .unwrap();
970 });
971
972 let source = ExternalResolver::new()
973 .unwrap()
974 .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint))
975 .await
976 .unwrap()
977 .unwrap();
978 server.await.unwrap();
979 assert_eq!(source.provider, SourceProvider::Sourcify { endpoint: endpoint.clone() });
980 assert_ne!(source.provider, SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() });
981 }
982
983 #[tokio::test]
984 async fn sourcify_cache_identity_includes_selected_endpoint() {
985 let mut resolver = ExternalResolver::new().unwrap();
986 for _ in 0..2 {
987 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
988 let endpoint = format!("http://{}/private", listener.local_addr().unwrap());
989 let server = tokio::spawn(async move {
990 let (mut socket, _) = listener.accept().await.unwrap();
991 let mut request = [0; 4096];
992 assert!(socket.read(&mut request).await.unwrap() > 0);
993 socket
994 .write_all(
995 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
996 )
997 .await
998 .unwrap();
999 });
1000 assert!(
1001 resolver
1002 .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint))
1003 .await
1004 .unwrap()
1005 .is_none()
1006 );
1007 server.await.unwrap();
1008 }
1009 assert_eq!(resolver.fetch_cache.len(), 2);
1010 }
1011
1012 #[test]
1013 fn remote_versions_and_cached_errors_are_bounded_and_accounted() {
1014 let long_version = format!("0.8.30+{}", "a".repeat(MAX_COMPILER_VERSION));
1015 assert!(Version::parse(&long_version).is_ok());
1016 assert!(parse_compiler_version(&long_version).is_err());
1017
1018 let mut resolver = ExternalResolver::new().unwrap();
1019 let endpoint = "https://explorer.invalid/api";
1020 let key = FetchKey {
1021 chain: 1,
1022 provider: SourceProvider::Etherscan { endpoint: endpoint.into() },
1023 address: Address::ZERO,
1024 };
1025 let error = resolver
1026 .cache_source_result(key, Err("x".repeat(MAX_CACHED_ERROR_CHARS * 2)))
1027 .unwrap_err();
1028 assert_eq!(error.chars().count(), MAX_CACHED_ERROR_CHARS + 1);
1029 assert_eq!(resolver.retained_metadata, endpoint.len() + error.len());
1030
1031 let mut resolver = ExternalResolver::new().unwrap();
1032 resolver.retained_metadata = MAX_RETAINED_METADATA;
1033 let key = FetchKey {
1034 chain: 1,
1035 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1036 address: Address::ZERO,
1037 };
1038 let source = ExternalSource {
1039 input: Arc::new(input()),
1040 version: Version::new(0, 8, 30),
1041 provider: key.provider.clone(),
1042 };
1043 assert!(resolver.cache_source_result(key, Ok(Some(source))).is_err());
1044 assert_eq!(resolver.retained_source_input, 0);
1045 assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA);
1046 }
1047
1048 #[tokio::test]
1049 async fn etherscan_source_discovery_does_not_follow_redirects() {
1050 let target = TcpListener::bind("127.0.0.1:0").await.unwrap();
1051 let source = TcpListener::bind("127.0.0.1:0").await.unwrap();
1052 let endpoint = format!("http://{}", source.local_addr().unwrap());
1053 let location = format!("http://{}", target.local_addr().unwrap());
1054 let server = tokio::spawn(async move {
1055 let (mut socket, _) = source.accept().await.unwrap();
1056 let mut request = [0; 4096];
1057 let bytes_read = socket.read(&mut request).await.unwrap();
1058 assert!(bytes_read > 0);
1059 socket
1060 .write_all(
1061 format!(
1062 "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1063 )
1064 .as_bytes(),
1065 )
1066 .await
1067 .unwrap();
1068 });
1069
1070 let error = ExternalResolver::new()
1071 .unwrap()
1072 .resolve_etherscan(Chain::mainnet(), Address::ZERO, Some(&endpoint), None)
1073 .await
1074 .unwrap_err();
1075 server.await.unwrap();
1076 assert!(error.contains("HTTP 307"), "unexpected discovery error: {error}");
1077 assert!(
1078 tokio::time::timeout(Duration::from_millis(50), target.accept()).await.is_err(),
1079 "external source discovery followed the redirect"
1080 );
1081 }
1082
1083 #[test]
1084 fn etherscan_parser_preserves_raw_standard_json() {
1085 let expected = json!({
1086 "language": "Solidity",
1087 "sources": { "A.sol": {
1088 "content": "contract A {}",
1089 "urls": ["dweb:/ipfs/source"],
1090 "keccak256": "0x1234"
1091 } },
1092 "settings": {},
1093 "unknown": { "preserved": true }
1094 });
1095 for source in [expected.clone(), Value::String(format!("{{{expected}}}"))] {
1096 let response = json!({
1097 "status": "1",
1098 "message": "OK",
1099 "result": [{ "SourceCode": source, "CompilerVersion": "v0.8.30+commit.73712a01" }]
1100 });
1101 let (parsed, version) =
1102 parse_etherscan_response(&serde_json::to_vec(&response).unwrap()).unwrap();
1103 assert_eq!(parsed, expected);
1104 assert_eq!(version, Version::parse("0.8.30+commit.73712a01").unwrap());
1105 }
1106
1107 let flat = json!({
1108 "status": "1", "result": [{
1109 "SourceCode": "contract A {}", "CompilerVersion": "v0.8.30+commit.73712a01"
1110 }]
1111 });
1112 assert!(parse_etherscan_response(&serde_json::to_vec(&flat).unwrap()).is_err());
1113 }
1114
1115 #[test]
1116 fn fingerprint_ignores_object_key_order() {
1117 let first = input();
1118 let second: Value = parse_json(
1119 r#"{"unknown":{"preserved":true},"settings":{"outputSelection":{"old":[]},"optimizer":{"enabled":true}},"sources":{"A.sol":{"custom":1,"content":"contract A {}"}},"language":"Solidity"}"#,
1120 )
1121 .unwrap();
1122 assert_eq!(fingerprint(&first).unwrap(), fingerprint(&second).unwrap());
1123
1124 let source_a = ExternalSource {
1125 input: Arc::new(first),
1126 version: Version::new(1, 2, 3),
1127 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1128 };
1129 let source_b = ExternalSource {
1130 provider: SourceProvider::Etherscan { endpoint: "other".into() },
1131 ..source_a.clone()
1132 };
1133 assert_eq!(fingerprint(&source_a.input).unwrap(), fingerprint(&source_b.input).unwrap());
1134 }
1135
1136 #[test]
1137 fn compiler_version_requires_commit_but_allows_platform_suffix() {
1138 let requested = Version::parse("0.8.30+commit.73712a01").unwrap();
1139 assert!(compiler_matches(
1140 &requested,
1141 &Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap()
1142 ));
1143 assert!(!compiler_matches(&requested, &Version::parse("0.8.30+commit.deadbeef").unwrap()));
1144 assert!(!compiler_matches(&Version::new(0, 8, 30), &requested));
1145 }
1146
1147 #[test]
1148 fn parses_real_solc_version_output() {
1149 let output = b"solc, the solidity compiler commandline interface\nVersion: 0.8.30+commit.73712a01.Darwin.appleclang\n";
1150 assert_eq!(
1151 parse_solc_version_output(output).unwrap(),
1152 Version::parse("0.8.30+commit.73712a01.Darwin.appleclang").unwrap()
1153 );
1154 let linux = b"Version: 0.8.30+commit.73712a01.Linux.g++\n";
1155 assert_eq!(
1156 parse_solc_version_output(linux).unwrap(),
1157 Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap()
1158 );
1159 assert!(parse_solc_version_output(b"Version: forged").is_err());
1160 }
1161
1162 #[cfg(unix)]
1163 #[tokio::test]
1164 async fn bounded_process_times_out_and_caps_output() {
1165 let mut sleep = Command::new("sh");
1166 sleep.args(["-c", "sleep 2"]);
1167 let started = tokio::time::Instant::now();
1168 let error =
1169 run_bounded_command(sleep, None, 16, 16, Duration::from_millis(20)).await.unwrap_err();
1170 assert!(error.contains("timed out"));
1171 assert!(started.elapsed() < Duration::from_millis(500));
1172
1173 let mut closed_output = Command::new("sh");
1176 closed_output.args(["-c", "exec 1>&- 2>&-; sleep 2"]);
1177 assert!(
1178 run_bounded_command(closed_output, None, 16, 16, Duration::from_millis(20))
1179 .await
1180 .unwrap_err()
1181 .contains("timed out")
1182 );
1183
1184 let mut output = Command::new("sh");
1185 output.args(["-c", "printf 12345"]);
1186 assert!(
1187 run_bounded_command(output, None, 4, 16, Duration::from_secs(1))
1188 .await
1189 .unwrap_err()
1190 .contains("output limit")
1191 );
1192
1193 let mut inherited_output = Command::new("sh");
1196 inherited_output.args(["-c", "sleep 2 &"]);
1197 assert!(
1198 run_bounded_command(inherited_output, None, 16, 16, Duration::from_millis(20))
1199 .await
1200 .unwrap_err()
1201 .contains("timed out")
1202 );
1203
1204 let mut inherited_input = Command::new("sh");
1207 inherited_input.args(["-c", "exec 3<&0; sleep 2 >/dev/null 2>&1 &"]);
1208 assert!(
1209 run_bounded_command(
1210 inherited_input,
1211 Some(vec![0; 1024 * 1024]),
1212 16,
1213 16,
1214 Duration::from_millis(20)
1215 )
1216 .await
1217 .unwrap_err()
1218 .contains("timed out")
1219 );
1220 }
1221
1222 #[test]
1223 fn static_constructor_matching_accepts_trailing_data_and_preserves_full_suffix() {
1224 let constructor: JsonAbi = serde_json::from_value(json!([{
1225 "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
1226 }]))
1227 .unwrap();
1228 let candidate = candidate("A.sol:A", constructor);
1229 let mut observed = vec![0x60, 0x00];
1230 observed.extend([0; 31]);
1231 observed.push(7);
1232 observed.extend([0xaa, 0xbb]);
1233 let MatchResult::Unique(found) =
1234 match_candidates(&observed, std::slice::from_ref(&candidate))
1235 else {
1236 panic!("expected match")
1237 };
1238 assert_eq!(found.constructor_args.as_ref(), &observed[2..]);
1239 assert!(matches!(match_candidates(&[0x60, 0x00, 7], &[candidate]), MatchResult::None));
1240 }
1241
1242 #[test]
1243 fn dynamic_constructor_matching_requires_a_canonical_prefix() {
1244 let constructor: JsonAbi = serde_json::from_value(json!([{
1245 "type": "constructor",
1246 "inputs": [{"name":"n", "type":"uint256"}, {"name":"data", "type":"bytes"}]
1247 }]))
1248 .unwrap();
1249 let candidate = candidate("A.sol:A", constructor);
1250 let mut args = vec![0; 128];
1251 args[31] = 7;
1252 args[63] = 64;
1253 args[95] = 2;
1254 args[96..98].copy_from_slice(&[0xaa, 0xbb]);
1255 let mut observed = candidate.creation_bytecode.to_vec();
1256 observed.extend_from_slice(&args);
1257 observed.extend([0xcc, 0xdd, 0xee]);
1258 let MatchResult::Unique(matched) =
1259 match_candidates(&observed, std::slice::from_ref(&candidate))
1260 else {
1261 panic!("expected match")
1262 };
1263 assert_eq!(matched.constructor_args.as_ref(), &observed[2..]);
1264
1265 args[127] = 1;
1266 let mut noncanonical = candidate.creation_bytecode.to_vec();
1267 noncanonical.extend(args);
1268 assert!(matches!(match_candidates(&noncanonical, &[candidate]), MatchResult::None));
1269 }
1270
1271 #[test]
1272 fn narrow_constructor_values_require_canonical_words() {
1273 let constructor: JsonAbi = serde_json::from_value(json!([{
1274 "type": "constructor", "inputs": [{"name":"n", "type":"uint8"}]
1275 }]))
1276 .unwrap();
1277 let candidate = candidate("A.sol:A", constructor);
1278 let mut observed = candidate.creation_bytecode.to_vec();
1279 observed.extend([0; 31]);
1280 observed.push(7);
1281 assert!(matches!(
1282 match_candidates(&observed, std::slice::from_ref(&candidate)),
1283 MatchResult::Unique(_)
1284 ));
1285
1286 observed[2] = 1;
1287 assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None));
1288 }
1289
1290 #[test]
1291 fn struct_constructor_values_require_canonical_words() {
1292 let constructor: JsonAbi = serde_json::from_value(json!([{
1293 "type": "constructor",
1294 "inputs": [{
1295 "name": "s",
1296 "type": "tuple",
1297 "internalType": "struct A.S",
1298 "components": [{"name": "n", "type": "uint8", "internalType": "uint8"}]
1299 }]
1300 }]))
1301 .unwrap();
1302 let candidate = candidate("A.sol:A", constructor);
1303 let mut observed = candidate.creation_bytecode.to_vec();
1304 observed.extend([0; 31]);
1305 observed.push(7);
1306 assert!(matches!(
1307 match_candidates(&observed, std::slice::from_ref(&candidate)),
1308 MatchResult::Unique(_)
1309 ));
1310
1311 observed[2] = 1;
1312 assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None));
1313 }
1314
1315 #[test]
1316 fn no_constructor_and_zero_inputs_accept_and_preserve_suffixes() {
1317 let none = candidate("A.sol:A", JsonAbi::new());
1318 assert!(matches!(
1319 match_candidates(&[0x60, 0x00], std::slice::from_ref(&none)),
1320 MatchResult::Unique(_)
1321 ));
1322 let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00, 0xaa], &[none]) else {
1323 panic!("expected match")
1324 };
1325 assert_eq!(matched.constructor_args.as_ref(), &[0xaa]);
1326
1327 let zero: JsonAbi =
1328 serde_json::from_value(json!([{"type":"constructor","inputs":[]}])).unwrap();
1329 assert!(matches!(
1330 match_candidates(&[0x60, 0x00], &[candidate("B.sol:B", zero.clone())]),
1331 MatchResult::Unique(_)
1332 ));
1333 let MatchResult::Unique(matched) =
1334 match_candidates(&[0x60, 0x00, 0xbb], &[candidate("B.sol:B", zero)])
1335 else {
1336 panic!("expected match")
1337 };
1338 assert_eq!(matched.constructor_args.as_ref(), &[0xbb]);
1339 }
1340
1341 #[test]
1342 fn ambiguity_collapses_equivalent_deployments_from_different_inputs() {
1343 let a = candidate("A.sol:A", JsonAbi::new());
1344 let first_input = a.input.clone();
1345 let mut duplicate = a.clone();
1346 duplicate.fingerprint = "different-provider-input".into();
1347 duplicate.input = Arc::new(json!({ "providerSpecific": true }));
1348 duplicate.version = Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap();
1349 let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00], &[a.clone(), duplicate])
1350 else {
1351 panic!("equivalent deployments should be deduplicated")
1352 };
1353 assert!(Arc::ptr_eq(&matched.input, &first_input));
1354 let mut other_commit = a.clone();
1355 other_commit.version = Version::parse("0.8.30+commit.deadbeef").unwrap();
1356 assert!(matches!(
1357 match_candidates(&[0x60, 0x00], &[a.clone(), other_commit]),
1358 MatchResult::Ambiguous(_)
1359 ));
1360
1361 let constructor: JsonAbi = serde_json::from_value(json!([{
1362 "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
1363 }]))
1364 .unwrap();
1365 let prefixed = candidate("A.sol:A", constructor);
1366 let mut observed = prefixed.creation_bytecode.to_vec();
1367 observed.extend([0; 32]);
1368 let exact = Candidate {
1369 constructor: None,
1370 creation_bytecode: Bytes::copy_from_slice(&observed),
1371 ..prefixed.clone()
1372 };
1373 assert!(matches!(
1374 match_candidates(&observed, &[prefixed, exact]),
1375 MatchResult::Ambiguous(_)
1376 ));
1377
1378 let b = candidate("B.sol:B", JsonAbi::new());
1379 let MatchResult::Ambiguous(matches) = match_candidates(&[0x60, 0x00], &[a, b]) else {
1380 panic!("expected ambiguity")
1381 };
1382 assert_eq!(matches.len(), 2);
1383 }
1384
1385 #[test]
1386 fn unresolved_library_links_are_flagged_without_dropping_other_candidates() {
1387 let placeholder = format!("__${}$__", "0".repeat(34));
1388 let output = serde_json::from_value(json!({
1389 "contracts": {
1390 "A.sol": {
1391 "Linked": {
1392 "abi": [],
1393 "evm": { "bytecode": {
1394 "object": "6000",
1395 "linkReferences": {
1396 "Lib.sol": { "Lib": [{ "start": 0, "length": 20 }] }
1397 }
1398 }}
1399 },
1400 "Placeholder": {
1401 "abi": [],
1402 "evm": { "bytecode": { "object": placeholder } }
1403 },
1404 "Plain": {
1405 "abi": [],
1406 "evm": { "bytecode": { "object": "6001" } }
1407 }
1408 }
1409 }
1410 }))
1411 .unwrap();
1412 let source = ExternalSource {
1413 input: Arc::new(input()),
1414 version: Version::new(0, 8, 30),
1415 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1416 };
1417 let (candidates, has_unresolved_links) = candidates_from_output(&source, output).unwrap();
1418 assert!(has_unresolved_links);
1419 assert_eq!(candidates.len(), 1);
1420 assert_eq!(candidates[0].fqn, "A.sol:Plain");
1421 }
1422
1423 #[test]
1424 fn capped_chunk_aggregation_rejects_limit_plus_one() {
1425 let mut output = Vec::new();
1426 assert!(append_capped(&mut output, b"123", 4));
1427 assert!(!append_capped(&mut output, b"45", 4));
1428 assert_eq!(output, b"123");
1429 }
1430
1431 #[test]
1432 fn empty_creation_bytecode_never_matches() {
1433 let mut empty = candidate("A.sol:A", JsonAbi::new());
1434 empty.creation_bytecode = Bytes::new();
1435 assert!(matches!(match_candidates(&[], &[empty]), MatchResult::None));
1436 }
1437
1438 #[test]
1439 fn candidates_share_source_input() {
1440 let first = candidate("A.sol:A", JsonAbi::new());
1441 let second = Candidate { fqn: "A.sol:B".into(), ..first.clone() };
1442 assert!(Arc::ptr_eq(&first.input, &second.input));
1443 }
1444
1445 #[test]
1446 fn remote_diagnostics_are_bounded_and_sanitized() {
1447 let message = format!("secret\n{}", "x".repeat(300));
1448 let clean = sanitize_remote(message.as_bytes());
1449 assert!(!clean.contains('\n'));
1450 assert_eq!(clean.chars().count(), 160);
1451 }
1452}