Skip to main content

forge_script/verify/
external.rs

1//! Acquisition, compilation, and matching of external Solidity sources.
2
3use 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
107/// Per-script-run state. Both caches deliberately retain failures, preventing repeated network or
108/// compiler work for the same provenance.
109pub(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        // Canonical Sourcify cannot observe ephemeral local chains. Avoid a guaranteed external
193        // request so local external-verification workflows remain hermetic.
194        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    // Older solc versions do not expose an import-callback switch. Literal source validation and
550    // an empty base/current directory prevent their filesystem importer from resolving a file.
551    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        // Linux solc builds identify GCC as `g++`, whose plus signs are invalid in SemVer build
619        // metadata. Use the same normalization as foundry-compilers' solc version parser.
620        .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    // Reserve part of the caller's timeout for cancelling I/O, terminating, and reaping. This
635    // keeps the complete subprocess lifecycle within one absolute deadline.
636    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    // If the OS does not complete cleanup in its reserved window, dropping the child retains
719    // `kill_on_drop` as a final best-effort safeguard while preserving the caller's deadline.
720    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        // A cache hit returns the retained value directly and never invokes accounting again.
888        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        // Output closes before the process exits, so the output-task branch wins first. Waiting
1174        // for the process must remain subject to the original deadline.
1175        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        // The shell exits immediately, but its child retains the output pipe. The same absolute
1194        // deadline must still cover draining output after `child.wait()` completes.
1195        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        // The direct child exits while a descendant retains stdin without reading it. Awaiting a
1205        // blocked writer must also remain subject to the same deadline.
1206        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}