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::{Map, 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 canonicalize(value: &Value) -> Value {
496    match value {
497        Value::Object(object) => {
498            let mut keys = object.keys().collect::<Vec<_>>();
499            keys.sort_unstable();
500            Value::Object(
501                keys.into_iter()
502                    .map(|key| (key.clone(), canonicalize(&object[key])))
503                    .collect::<Map<_, _>>(),
504            )
505        }
506        Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()),
507        value => value.clone(),
508    }
509}
510
511fn fingerprint(input: &Value) -> Result<String> {
512    validate_input(input)?;
513    Ok(keccak256(serde_json::to_vec(&canonicalize(input))?).to_string())
514}
515
516fn compiler_matches(requested: &Version, actual: &Version) -> bool {
517    requested.major == actual.major
518        && requested.minor == actual.minor
519        && requested.patch == actual.patch
520        && requested.pre == actual.pre
521        && requested.build.as_str().split('.').take(2).eq(actual.build.as_str().split('.').take(2))
522}
523
524fn compiler_identity(version: &Version) -> String {
525    let build = version.build.as_str().split('.').take(2).collect::<Vec<_>>().join(".");
526    format!("{}.{}.{}-{}+{build}", version.major, version.minor, version.patch, version.pre)
527}
528
529async fn compile_source(source: &ExternalSource) -> Result<(Vec<Candidate>, bool), String> {
530    let input = compilation_input(&source.input).map_err(|e| e.to_string())?;
531    let svm_version =
532        Version::new(source.version.major, source.version.minor, source.version.patch);
533    let solc = match Solc::find_svm_installed_version(&svm_version).map_err(|e| e.to_string())? {
534        Some(solc) => solc,
535        None => tokio::time::timeout(Duration::from_secs(60), Solc::install(&svm_version))
536            .await
537            .map_err(|_| "solc installation timed out".to_string())?
538            .map_err(|e| e.to_string())?,
539    };
540    let mut version_command = Command::new(&solc.solc);
541    version_command.arg("--version");
542    let version_output =
543        run_bounded_command(version_command, None, 64 * 1024, 64 * 1024, Duration::from_secs(10))
544            .await?;
545    let actual = parse_solc_version_output(&version_output.0)?;
546    if !compiler_matches(&source.version, &actual) {
547        return Err(format!("installed solc {actual} does not match requested {}", source.version));
548    }
549    let workdir =
550        tempfile::tempdir().map_err(|_| "failed to create compiler sandbox".to_string())?;
551    let raw_input =
552        serde_json::to_vec(&input).map_err(|_| "failed to encode compiler input".to_string())?;
553    let mut command = Command::new(&solc.solc);
554    command
555        .arg("--standard-json")
556        .arg("--base-path")
557        .arg(workdir.path())
558        .current_dir(workdir.path())
559        .stdin(Stdio::piped())
560        .stdout(Stdio::piped())
561        .stderr(Stdio::piped())
562        .kill_on_drop(true);
563    // Older solc versions do not expose an import-callback switch. Literal source validation and
564    // an empty base/current directory prevent their filesystem importer from resolving a file.
565    if source.version >= Version::new(0, 8, 22) {
566        command.arg("--no-import-callback");
567    }
568    let (stdout, stderr, status) =
569        run_bounded_command(command, Some(raw_input), MAX_STDOUT, MAX_STDERR, COMPILE_TIMEOUT)
570            .await?;
571    if !status.success() {
572        return Err(format!("solc compilation failed: {}", sanitize_remote(&stderr)));
573    }
574    let output: CompilerOutput =
575        serde_json::from_slice(&stdout).map_err(|_| "solc returned invalid JSON".to_string())?;
576    if output.has_error() {
577        return Err("solc compilation failed".to_string());
578    }
579    candidates_from_output(source, output)
580}
581
582fn candidates_from_output(
583    source: &ExternalSource,
584    output: CompilerOutput,
585) -> Result<(Vec<Candidate>, bool), String> {
586    let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?;
587    let mut candidates = Vec::new();
588    let mut creation_bytecode_bytes = 0usize;
589    let mut has_unresolved_links = false;
590    for (path, contracts) in output.contracts {
591        for (name, contract) in contracts {
592            let Some(abi) = contract.abi else { continue };
593            let Some(bytecode) = contract.evm.and_then(|evm| evm.bytecode) else { continue };
594            if !bytecode.link_references.is_empty() || bytecode.object.is_unlinked() {
595                has_unresolved_links = true;
596                continue;
597            }
598            let Some(bytes) = bytecode.object.into_bytes() else { continue };
599            if bytes.is_empty() {
600                continue;
601            }
602            validate_identifier(&path.to_string_lossy(), "compiler source path")
603                .map_err(|e| e.to_string())?;
604            validate_identifier(&name, "contract name").map_err(|e| e.to_string())?;
605            if candidates.len() >= MAX_CANDIDATES {
606                return Err("external candidate limit exceeded".to_string());
607            }
608            if creation_bytecode_bytes.saturating_add(bytes.len()) > MAX_CREATION_BYTECODE {
609                return Err("external creation bytecode limit exceeded".to_string());
610            }
611            creation_bytecode_bytes += bytes.len();
612            candidates.push(Candidate {
613                input: source.input.clone(),
614                fingerprint: fingerprint.clone(),
615                version: source.version.clone(),
616                fqn: format!("{}:{name}", path.display()),
617                constructor: abi.constructor().cloned(),
618                creation_bytecode: bytes,
619            });
620        }
621    }
622    Ok((candidates, has_unresolved_links))
623}
624
625fn parse_solc_version_output(output: &[u8]) -> Result<Version, String> {
626    let output =
627        std::str::from_utf8(output).map_err(|_| "invalid solc version output".to_string())?;
628    output
629        .lines()
630        .find_map(|line| line.trim().strip_prefix("Version: "))
631        .ok_or_else(|| "solc version output is missing Version line".to_string())
632        // Linux solc builds identify GCC as `g++`, whose plus signs are invalid in SemVer build
633        // metadata. Use the same normalization as foundry-compilers' solc version parser.
634        .and_then(|version| {
635            Version::parse(&version.replace(".g++", ".gcc"))
636                .map_err(|_| "invalid solc version".to_string())
637        })
638}
639
640async fn run_bounded_command(
641    mut command: Command,
642    input: Option<Vec<u8>>,
643    stdout_limit: usize,
644    stderr_limit: usize,
645    timeout: Duration,
646) -> Result<(Vec<u8>, Vec<u8>, std::process::ExitStatus), String> {
647    let deadline = tokio::time::Instant::now() + timeout;
648    // Reserve part of the caller's timeout for cancelling I/O, terminating, and reaping. This
649    // keeps the complete subprocess lifecycle within one absolute deadline.
650    let cleanup_window = std::cmp::min(timeout / 10, Duration::from_secs(1));
651    let execution_deadline = deadline - cleanup_window;
652    command
653        .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
654        .stdout(Stdio::piped())
655        .stderr(Stdio::piped())
656        .kill_on_drop(true);
657    let mut child = command.spawn().map_err(|_| "failed to start subprocess".to_string())?;
658    let stdout =
659        child.stdout.take().ok_or_else(|| "failed to open subprocess stdout".to_string())?;
660    let stderr =
661        child.stderr.take().ok_or_else(|| "failed to open subprocess stderr".to_string())?;
662    let mut input_task = input.map(|input| {
663        let mut stdin = child.stdin.take().expect("piped stdin");
664        tokio::spawn(async move {
665            stdin.write_all(&input).await?;
666            stdin.shutdown().await
667        })
668    });
669    let mut output_task = tokio::spawn(async move {
670        tokio::try_join!(read_capped(stdout, stdout_limit), read_capped(stderr, stderr_limit))
671    });
672    let mut input_joined = false;
673    let mut output_joined = false;
674    let lifecycle = async {
675        let mut completed_output = None;
676        let status = tokio::select! {
677            result = child.wait() => {
678                result.map_err(|_| "failed to wait for subprocess".to_string())?
679            }
680            output = &mut output_task => {
681                output_joined = true;
682                match output {
683                    Ok(Ok(output)) => {
684                        completed_output = Some(output);
685                        child.wait().await.map_err(|_| "failed to wait for subprocess".to_string())?
686                    }
687                    Ok(Err(err)) => return Err(err),
688                    Err(_) => return Err("subprocess output task failed".to_string()),
689                }
690            }
691        };
692        if let Some(task) = input_task.as_mut() {
693            let input = task.await;
694            input_joined = true;
695            input
696                .map_err(|_| "subprocess input task failed".to_string())?
697                .map_err(|_| "failed to write subprocess input".to_string())?;
698        }
699        let (stdout, stderr) = match completed_output {
700            Some(output) => output,
701            None => {
702                let output = (&mut output_task).await;
703                output_joined = true;
704                output.map_err(|_| "subprocess output task failed".to_string())??
705            }
706        };
707        Ok((stdout, stderr, status))
708    };
709    let result = match tokio::time::timeout_at(execution_deadline, lifecycle).await {
710        Ok(Ok(output)) => Ok(output),
711        Ok(Err(err)) => Err(err),
712        Err(_) => Err("subprocess timed out".to_string()),
713    };
714    if result.is_ok() {
715        return result;
716    }
717
718    if let Some(task) = &input_task {
719        task.abort();
720    }
721    output_task.abort();
722    let _ = child.start_kill();
723    let cleanup = async {
724        if !input_joined && let Some(task) = input_task {
725            let _ = task.await;
726        }
727        if !output_joined {
728            let _ = output_task.await;
729        }
730        let _ = child.wait().await;
731    };
732    // If the OS does not complete cleanup in its reserved window, dropping the child retains
733    // `kill_on_drop` as a final best-effort safeguard while preserving the caller's deadline.
734    let _ = tokio::time::timeout_at(deadline, cleanup).await;
735    result
736}
737
738async fn read_capped(mut reader: impl AsyncRead + Unpin, limit: usize) -> Result<Vec<u8>, String> {
739    let mut output = Vec::new();
740    let mut chunk = [0; 8192];
741    loop {
742        let read =
743            reader.read(&mut chunk).await.map_err(|_| "failed to read solc output".to_string())?;
744        if read == 0 {
745            return Ok(output);
746        }
747        if output.len().saturating_add(read) > limit {
748            return Err("solc output limit exceeded".to_string());
749        }
750        output.extend_from_slice(&chunk[..read]);
751    }
752}
753
754fn sanitize_remote(message: &[u8]) -> String {
755    let message = String::from_utf8_lossy(message);
756    let clean = message
757        .chars()
758        .map(|ch| if ch.is_control() { ' ' } else { ch })
759        .take(160)
760        .collect::<String>();
761    if clean.is_empty() { "no diagnostic".to_string() } else { clean }
762}
763
764fn is_canonical_value(value: &DynSolValue) -> bool {
765    match value {
766        DynSolValue::Uint(value, bits) => value.bit_len() <= *bits,
767        DynSolValue::Int(value, bits) => {
768            if *bits == 0 || *bits > 256 || bits % 8 != 0 {
769                return false;
770            }
771            let bytes = value.to_be_bytes::<32>();
772            let value_start = 32 - bits / 8;
773            let extension = if bytes[value_start] & 0x80 == 0 { 0 } else { 0xff };
774            bytes[..value_start].iter().all(|byte| *byte == extension)
775        }
776        DynSolValue::FixedBytes(value, size) => {
777            *size <= value.len() && value[*size..].iter().all(|byte| *byte == 0)
778        }
779        DynSolValue::Array(values)
780        | DynSolValue::FixedArray(values)
781        | DynSolValue::Tuple(values) => values.iter().all(is_canonical_value),
782        DynSolValue::CustomStruct { tuple, .. } => tuple.iter().all(is_canonical_value),
783        DynSolValue::Bool(_)
784        | DynSolValue::Address(_)
785        | DynSolValue::Function(_)
786        | DynSolValue::Bytes(_)
787        | DynSolValue::String(_) => true,
788    }
789}
790
791pub(super) fn match_candidates<'a>(
792    observed: &[u8],
793    candidates: impl IntoIterator<Item = &'a Candidate>,
794) -> MatchResult {
795    let mut matches = Vec::new();
796    for candidate in candidates {
797        let bytecode = candidate.creation_bytecode.as_ref();
798        if bytecode.is_empty() {
799            continue;
800        }
801        let Some(suffix) = observed.strip_prefix(bytecode) else { continue };
802        let valid = match &candidate.constructor {
803            None => true,
804            Some(constructor) if constructor.inputs.is_empty() => true,
805            Some(constructor) => constructor
806                .abi_decode_input(suffix)
807                .ok()
808                .filter(|values| values.iter().all(is_canonical_value))
809                .and_then(|values| constructor.abi_encode_input(&values).ok())
810                .is_some_and(|encoded| suffix.starts_with(&encoded)),
811        };
812        if valid
813            && !matches.iter().any(|item: &ExternalMatch| {
814                item.fqn == candidate.fqn
815                    && compiler_identity(&item.version) == compiler_identity(&candidate.version)
816                    && item.creation_bytecode == candidate.creation_bytecode
817                    && item.constructor_args.as_ref() == suffix
818            })
819        {
820            matches.push(ExternalMatch {
821                input: candidate.input.clone(),
822                version: candidate.version.clone(),
823                fqn: candidate.fqn.clone(),
824                creation_bytecode: candidate.creation_bytecode.clone(),
825                constructor_args: Bytes::copy_from_slice(suffix),
826            });
827        }
828    }
829    match matches.len() {
830        0 => MatchResult::None,
831        1 => MatchResult::Unique(matches.pop().unwrap()),
832        _ => MatchResult::Ambiguous(matches),
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use alloy_json_abi::JsonAbi;
840    use tokio::net::TcpListener;
841
842    fn input() -> Value {
843        json!({
844            "language": "Solidity",
845            "sources": { "A.sol": { "content": "contract A {}", "custom": 1 } },
846            "settings": { "optimizer": { "enabled": true }, "outputSelection": {"old": []} },
847            "unknown": { "preserved": true }
848        })
849    }
850
851    fn candidate(fqn: &str, abi: JsonAbi) -> Candidate {
852        Candidate {
853            input: Arc::new(input()),
854            fingerprint: "fingerprint".into(),
855            version: Version::parse("0.8.30+commit.73712a01").unwrap(),
856            fqn: fqn.into(),
857            constructor: abi.constructor().cloned(),
858            creation_bytecode: Bytes::from_static(&[0x60, 0x00]),
859        }
860    }
861
862    #[test]
863    fn validation_preserves_unknown_fields_and_only_changes_output_selection() {
864        let original = input();
865        let compiled = compilation_input(&original).unwrap();
866        assert_eq!(compiled["unknown"], original["unknown"]);
867        assert_eq!(compiled["sources"], original["sources"]);
868        assert_eq!(compiled["settings"]["optimizer"], original["settings"]["optimizer"]);
869        assert_ne!(
870            compiled["settings"]["outputSelection"],
871            original["settings"]["outputSelection"]
872        );
873        assert_eq!(original["settings"]["outputSelection"], json!({"old": []}));
874
875        let mut url_only = input();
876        url_only["sources"]["A.sol"] = json!({"urls": ["ipfs://source"]});
877        assert!(validate_input(&url_only).is_err());
878    }
879
880    #[test]
881    fn source_paths_are_bounded_printable_ascii() {
882        assert!(validate_identifier("src/A.sol", "path").is_ok());
883        assert!(validate_identifier("bad\npath.sol", "path").is_err());
884        assert!(validate_identifier("café.sol", "path").is_err());
885        assert!(validate_identifier(&"x".repeat(MAX_SOURCE_PATH + 1), "path").is_err());
886    }
887
888    #[test]
889    fn cumulative_source_budget_charges_success_once() {
890        let mut resolver = ExternalResolver::new().unwrap();
891        let source = ExternalSource {
892            input: Arc::new(input()),
893            version: Version::new(0, 8, 30),
894            provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
895        };
896        let key = FetchKey { chain: 1, provider: source.provider.clone(), address: Address::ZERO };
897        let charged = resolver.cache_source_result(key.clone(), Ok(Some(source))).unwrap();
898        assert!(charged.is_some());
899        let charged_bytes = resolver.retained_source_input;
900        // A cache hit returns the retained value directly and never invokes accounting again.
901        assert!(resolver.fetch_cache.get(&key).unwrap().is_ok());
902        assert_eq!(resolver.retained_source_input, charged_bytes);
903
904        resolver.retained_source_input = MAX_RETAINED_SOURCE_INPUT;
905        let rejected_key = FetchKey { address: Address::with_last_byte(1), ..key };
906        assert!(
907            resolver
908                .cache_source_result(rejected_key.clone(), Ok(charged))
909                .unwrap_err()
910                .contains("cumulative")
911        );
912        assert!(!resolver.fetch_cache.contains_key(&rejected_key));
913    }
914
915    #[test]
916    fn cumulative_candidate_metadata_budget_is_enforced() {
917        let mut resolver = ExternalResolver::new().unwrap();
918        let retained_candidate = candidate("A.sol:A", JsonAbi::default());
919        let expected = retained_candidate.fqn.len()
920            + retained_candidate.fingerprint.len()
921            + retained_candidate.version.to_string().len();
922        resolver.charge_candidates(vec![retained_candidate], 0).unwrap();
923        assert_eq!(resolver.retained_metadata, expected);
924
925        let mut resolver = ExternalResolver::new().unwrap();
926        resolver.retained_metadata = MAX_RETAINED_METADATA;
927        let error = resolver
928            .charge_candidates(vec![candidate("A.sol:A", JsonAbi::default())], 0)
929            .unwrap_err();
930        assert!(error.contains("metadata"));
931        assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA);
932        assert_eq!(resolver.retained_candidates, 0);
933        assert_eq!(resolver.retained_creation_bytecode, 0);
934
935        let mut resolver = ExternalResolver::new().unwrap();
936        resolver.retained_creation_bytecode = MAX_CREATION_BYTECODE;
937        let error = resolver
938            .charge_candidates(vec![candidate("A.sol:A", JsonAbi::default())], 0)
939            .unwrap_err();
940        assert!(error.contains("bytecode"));
941        assert_eq!(resolver.retained_metadata, 0);
942        assert_eq!(resolver.retained_candidates, 0);
943        assert_eq!(resolver.retained_creation_bytecode, MAX_CREATION_BYTECODE);
944    }
945
946    #[test]
947    fn candidate_constructor_metadata_is_charged() {
948        let abi: JsonAbi = serde_json::from_value(json!([{
949            "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
950        }]))
951        .unwrap();
952        let candidate = candidate("A.sol:A", abi);
953        let constructor_bytes =
954            serde_json::to_vec(candidate.constructor.as_ref().unwrap()).unwrap();
955        let mut resolver = ExternalResolver::new().unwrap();
956        resolver.charge_candidates(vec![candidate], 0).unwrap();
957        assert!(resolver.retained_metadata >= constructor_bytes.len());
958    }
959
960    #[tokio::test]
961    async fn sourcify_source_discovery_uses_selected_endpoint_on_dev_chain() {
962        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
963        let endpoint = format!("http://{}/private", listener.local_addr().unwrap());
964        let response = json!({
965            "stdJsonInput": input(),
966            "compilation": { "compilerVersion": "0.8.30+commit.73712a01" }
967        })
968        .to_string();
969        let server = tokio::spawn(async move {
970            let (mut socket, _) = listener.accept().await.unwrap();
971            let mut request = [0; 4096];
972            let bytes_read = socket.read(&mut request).await.unwrap();
973            let request = std::str::from_utf8(&request[..bytes_read]).unwrap();
974            assert!(request.starts_with("GET /private/v2/contract/31337/"), "{request}");
975            socket
976                .write_all(
977                    format!(
978                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",
979                        response.len()
980                    )
981                    .as_bytes(),
982                )
983                .await
984                .unwrap();
985        });
986
987        let source = ExternalResolver::new()
988            .unwrap()
989            .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint))
990            .await
991            .unwrap()
992            .unwrap();
993        server.await.unwrap();
994        assert_eq!(source.provider, SourceProvider::Sourcify { endpoint: endpoint.clone() });
995        assert_ne!(source.provider, SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() });
996    }
997
998    #[tokio::test]
999    async fn sourcify_cache_identity_includes_selected_endpoint() {
1000        let mut resolver = ExternalResolver::new().unwrap();
1001        for _ in 0..2 {
1002            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1003            let endpoint = format!("http://{}/private", listener.local_addr().unwrap());
1004            let server = tokio::spawn(async move {
1005                let (mut socket, _) = listener.accept().await.unwrap();
1006                let mut request = [0; 4096];
1007                assert!(socket.read(&mut request).await.unwrap() > 0);
1008                socket
1009                    .write_all(
1010                        b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1011                    )
1012                    .await
1013                    .unwrap();
1014            });
1015            assert!(
1016                resolver
1017                    .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint))
1018                    .await
1019                    .unwrap()
1020                    .is_none()
1021            );
1022            server.await.unwrap();
1023        }
1024        assert_eq!(resolver.fetch_cache.len(), 2);
1025    }
1026
1027    #[test]
1028    fn remote_versions_and_cached_errors_are_bounded_and_accounted() {
1029        let long_version = format!("0.8.30+{}", "a".repeat(MAX_COMPILER_VERSION));
1030        assert!(Version::parse(&long_version).is_ok());
1031        assert!(parse_compiler_version(&long_version).is_err());
1032
1033        let mut resolver = ExternalResolver::new().unwrap();
1034        let endpoint = "https://explorer.invalid/api";
1035        let key = FetchKey {
1036            chain: 1,
1037            provider: SourceProvider::Etherscan { endpoint: endpoint.into() },
1038            address: Address::ZERO,
1039        };
1040        let error = resolver
1041            .cache_source_result(key, Err("x".repeat(MAX_CACHED_ERROR_CHARS * 2)))
1042            .unwrap_err();
1043        assert_eq!(error.chars().count(), MAX_CACHED_ERROR_CHARS + 1);
1044        assert_eq!(resolver.retained_metadata, endpoint.len() + error.len());
1045
1046        let mut resolver = ExternalResolver::new().unwrap();
1047        resolver.retained_metadata = MAX_RETAINED_METADATA;
1048        let key = FetchKey {
1049            chain: 1,
1050            provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1051            address: Address::ZERO,
1052        };
1053        let source = ExternalSource {
1054            input: Arc::new(input()),
1055            version: Version::new(0, 8, 30),
1056            provider: key.provider.clone(),
1057        };
1058        assert!(resolver.cache_source_result(key, Ok(Some(source))).is_err());
1059        assert_eq!(resolver.retained_source_input, 0);
1060        assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA);
1061    }
1062
1063    #[tokio::test]
1064    async fn etherscan_source_discovery_does_not_follow_redirects() {
1065        let target = TcpListener::bind("127.0.0.1:0").await.unwrap();
1066        let source = TcpListener::bind("127.0.0.1:0").await.unwrap();
1067        let endpoint = format!("http://{}", source.local_addr().unwrap());
1068        let location = format!("http://{}", target.local_addr().unwrap());
1069        let server = tokio::spawn(async move {
1070            let (mut socket, _) = source.accept().await.unwrap();
1071            let mut request = [0; 4096];
1072            let bytes_read = socket.read(&mut request).await.unwrap();
1073            assert!(bytes_read > 0);
1074            socket
1075                .write_all(
1076                    format!(
1077                        "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1078                    )
1079                    .as_bytes(),
1080                )
1081                .await
1082                .unwrap();
1083        });
1084
1085        let error = ExternalResolver::new()
1086            .unwrap()
1087            .resolve_etherscan(Chain::mainnet(), Address::ZERO, Some(&endpoint), None)
1088            .await
1089            .unwrap_err();
1090        server.await.unwrap();
1091        assert!(error.contains("HTTP 307"), "unexpected discovery error: {error}");
1092        assert!(
1093            tokio::time::timeout(Duration::from_millis(50), target.accept()).await.is_err(),
1094            "external source discovery followed the redirect"
1095        );
1096    }
1097
1098    #[test]
1099    fn etherscan_parser_preserves_raw_standard_json() {
1100        let expected = json!({
1101            "language": "Solidity",
1102            "sources": { "A.sol": {
1103                "content": "contract A {}",
1104                "urls": ["dweb:/ipfs/source"],
1105                "keccak256": "0x1234"
1106            } },
1107            "settings": {},
1108            "unknown": { "preserved": true }
1109        });
1110        for source in [expected.clone(), Value::String(format!("{{{expected}}}"))] {
1111            let response = json!({
1112                "status": "1",
1113                "message": "OK",
1114                "result": [{ "SourceCode": source, "CompilerVersion": "v0.8.30+commit.73712a01" }]
1115            });
1116            let (parsed, version) =
1117                parse_etherscan_response(&serde_json::to_vec(&response).unwrap()).unwrap();
1118            assert_eq!(parsed, expected);
1119            assert_eq!(version, Version::parse("0.8.30+commit.73712a01").unwrap());
1120        }
1121
1122        let flat = json!({
1123            "status": "1", "result": [{
1124                "SourceCode": "contract A {}", "CompilerVersion": "v0.8.30+commit.73712a01"
1125            }]
1126        });
1127        assert!(parse_etherscan_response(&serde_json::to_vec(&flat).unwrap()).is_err());
1128    }
1129
1130    #[test]
1131    fn fingerprint_ignores_object_key_order() {
1132        let first = input();
1133        let second: Value = serde_json::from_str(
1134            r#"{"unknown":{"preserved":true},"settings":{"outputSelection":{"old":[]},"optimizer":{"enabled":true}},"sources":{"A.sol":{"custom":1,"content":"contract A {}"}},"language":"Solidity"}"#,
1135        )
1136        .unwrap();
1137        assert_eq!(fingerprint(&first).unwrap(), fingerprint(&second).unwrap());
1138
1139        let source_a = ExternalSource {
1140            input: Arc::new(first),
1141            version: Version::new(1, 2, 3),
1142            provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1143        };
1144        let source_b = ExternalSource {
1145            provider: SourceProvider::Etherscan { endpoint: "other".into() },
1146            ..source_a.clone()
1147        };
1148        assert_eq!(fingerprint(&source_a.input).unwrap(), fingerprint(&source_b.input).unwrap());
1149    }
1150
1151    #[test]
1152    fn compiler_version_requires_commit_but_allows_platform_suffix() {
1153        let requested = Version::parse("0.8.30+commit.73712a01").unwrap();
1154        assert!(compiler_matches(
1155            &requested,
1156            &Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap()
1157        ));
1158        assert!(!compiler_matches(&requested, &Version::parse("0.8.30+commit.deadbeef").unwrap()));
1159        assert!(!compiler_matches(&Version::new(0, 8, 30), &requested));
1160    }
1161
1162    #[test]
1163    fn parses_real_solc_version_output() {
1164        let output = b"solc, the solidity compiler commandline interface\nVersion: 0.8.30+commit.73712a01.Darwin.appleclang\n";
1165        assert_eq!(
1166            parse_solc_version_output(output).unwrap(),
1167            Version::parse("0.8.30+commit.73712a01.Darwin.appleclang").unwrap()
1168        );
1169        let linux = b"Version: 0.8.30+commit.73712a01.Linux.g++\n";
1170        assert_eq!(
1171            parse_solc_version_output(linux).unwrap(),
1172            Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap()
1173        );
1174        assert!(parse_solc_version_output(b"Version: forged").is_err());
1175    }
1176
1177    #[cfg(unix)]
1178    #[tokio::test]
1179    async fn bounded_process_times_out_and_caps_output() {
1180        let mut sleep = Command::new("sh");
1181        sleep.args(["-c", "sleep 2"]);
1182        let started = tokio::time::Instant::now();
1183        let error =
1184            run_bounded_command(sleep, None, 16, 16, Duration::from_millis(20)).await.unwrap_err();
1185        assert!(error.contains("timed out"));
1186        assert!(started.elapsed() < Duration::from_millis(500));
1187
1188        // Output closes before the process exits, so the output-task branch wins first. Waiting
1189        // for the process must remain subject to the original deadline.
1190        let mut closed_output = Command::new("sh");
1191        closed_output.args(["-c", "exec 1>&- 2>&-; sleep 2"]);
1192        assert!(
1193            run_bounded_command(closed_output, None, 16, 16, Duration::from_millis(20))
1194                .await
1195                .unwrap_err()
1196                .contains("timed out")
1197        );
1198
1199        let mut output = Command::new("sh");
1200        output.args(["-c", "printf 12345"]);
1201        assert!(
1202            run_bounded_command(output, None, 4, 16, Duration::from_secs(1))
1203                .await
1204                .unwrap_err()
1205                .contains("output limit")
1206        );
1207
1208        // The shell exits immediately, but its child retains the output pipe. The same absolute
1209        // deadline must still cover draining output after `child.wait()` completes.
1210        let mut inherited_output = Command::new("sh");
1211        inherited_output.args(["-c", "sleep 2 &"]);
1212        assert!(
1213            run_bounded_command(inherited_output, None, 16, 16, Duration::from_millis(20))
1214                .await
1215                .unwrap_err()
1216                .contains("timed out")
1217        );
1218
1219        // The direct child exits while a descendant retains stdin without reading it. Awaiting a
1220        // blocked writer must also remain subject to the same deadline.
1221        let mut inherited_input = Command::new("sh");
1222        inherited_input.args(["-c", "exec 3<&0; sleep 2 >/dev/null 2>&1 &"]);
1223        assert!(
1224            run_bounded_command(
1225                inherited_input,
1226                Some(vec![0; 1024 * 1024]),
1227                16,
1228                16,
1229                Duration::from_millis(20)
1230            )
1231            .await
1232            .unwrap_err()
1233            .contains("timed out")
1234        );
1235    }
1236
1237    #[test]
1238    fn static_constructor_matching_accepts_trailing_data_and_preserves_full_suffix() {
1239        let constructor: JsonAbi = serde_json::from_value(json!([{
1240            "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
1241        }]))
1242        .unwrap();
1243        let candidate = candidate("A.sol:A", constructor);
1244        let mut observed = vec![0x60, 0x00];
1245        observed.extend([0; 31]);
1246        observed.push(7);
1247        observed.extend([0xaa, 0xbb]);
1248        let MatchResult::Unique(found) =
1249            match_candidates(&observed, std::slice::from_ref(&candidate))
1250        else {
1251            panic!("expected match")
1252        };
1253        assert_eq!(found.constructor_args.as_ref(), &observed[2..]);
1254        assert!(matches!(match_candidates(&[0x60, 0x00, 7], &[candidate]), MatchResult::None));
1255    }
1256
1257    #[test]
1258    fn dynamic_constructor_matching_requires_a_canonical_prefix() {
1259        let constructor: JsonAbi = serde_json::from_value(json!([{
1260            "type": "constructor",
1261            "inputs": [{"name":"n", "type":"uint256"}, {"name":"data", "type":"bytes"}]
1262        }]))
1263        .unwrap();
1264        let candidate = candidate("A.sol:A", constructor);
1265        let mut args = vec![0; 128];
1266        args[31] = 7;
1267        args[63] = 64;
1268        args[95] = 2;
1269        args[96..98].copy_from_slice(&[0xaa, 0xbb]);
1270        let mut observed = candidate.creation_bytecode.to_vec();
1271        observed.extend_from_slice(&args);
1272        observed.extend([0xcc, 0xdd, 0xee]);
1273        let MatchResult::Unique(matched) =
1274            match_candidates(&observed, std::slice::from_ref(&candidate))
1275        else {
1276            panic!("expected match")
1277        };
1278        assert_eq!(matched.constructor_args.as_ref(), &observed[2..]);
1279
1280        args[127] = 1;
1281        let mut noncanonical = candidate.creation_bytecode.to_vec();
1282        noncanonical.extend(args);
1283        assert!(matches!(match_candidates(&noncanonical, &[candidate]), MatchResult::None));
1284    }
1285
1286    #[test]
1287    fn narrow_constructor_values_require_canonical_words() {
1288        let constructor: JsonAbi = serde_json::from_value(json!([{
1289            "type": "constructor", "inputs": [{"name":"n", "type":"uint8"}]
1290        }]))
1291        .unwrap();
1292        let candidate = candidate("A.sol:A", constructor);
1293        let mut observed = candidate.creation_bytecode.to_vec();
1294        observed.extend([0; 31]);
1295        observed.push(7);
1296        assert!(matches!(
1297            match_candidates(&observed, std::slice::from_ref(&candidate)),
1298            MatchResult::Unique(_)
1299        ));
1300
1301        observed[2] = 1;
1302        assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None));
1303    }
1304
1305    #[test]
1306    fn struct_constructor_values_require_canonical_words() {
1307        let constructor: JsonAbi = serde_json::from_value(json!([{
1308            "type": "constructor",
1309            "inputs": [{
1310                "name": "s",
1311                "type": "tuple",
1312                "internalType": "struct A.S",
1313                "components": [{"name": "n", "type": "uint8", "internalType": "uint8"}]
1314            }]
1315        }]))
1316        .unwrap();
1317        let candidate = candidate("A.sol:A", constructor);
1318        let mut observed = candidate.creation_bytecode.to_vec();
1319        observed.extend([0; 31]);
1320        observed.push(7);
1321        assert!(matches!(
1322            match_candidates(&observed, std::slice::from_ref(&candidate)),
1323            MatchResult::Unique(_)
1324        ));
1325
1326        observed[2] = 1;
1327        assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None));
1328    }
1329
1330    #[test]
1331    fn no_constructor_and_zero_inputs_accept_and_preserve_suffixes() {
1332        let none = candidate("A.sol:A", JsonAbi::default());
1333        assert!(matches!(
1334            match_candidates(&[0x60, 0x00], std::slice::from_ref(&none)),
1335            MatchResult::Unique(_)
1336        ));
1337        let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00, 0xaa], &[none]) else {
1338            panic!("expected match")
1339        };
1340        assert_eq!(matched.constructor_args.as_ref(), &[0xaa]);
1341
1342        let zero: JsonAbi =
1343            serde_json::from_value(json!([{"type":"constructor","inputs":[]}])).unwrap();
1344        assert!(matches!(
1345            match_candidates(&[0x60, 0x00], &[candidate("B.sol:B", zero.clone())]),
1346            MatchResult::Unique(_)
1347        ));
1348        let MatchResult::Unique(matched) =
1349            match_candidates(&[0x60, 0x00, 0xbb], &[candidate("B.sol:B", zero)])
1350        else {
1351            panic!("expected match")
1352        };
1353        assert_eq!(matched.constructor_args.as_ref(), &[0xbb]);
1354    }
1355
1356    #[test]
1357    fn ambiguity_collapses_equivalent_deployments_from_different_inputs() {
1358        let a = candidate("A.sol:A", JsonAbi::default());
1359        let first_input = a.input.clone();
1360        let mut duplicate = a.clone();
1361        duplicate.fingerprint = "different-provider-input".into();
1362        duplicate.input = Arc::new(json!({ "providerSpecific": true }));
1363        duplicate.version = Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap();
1364        let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00], &[a.clone(), duplicate])
1365        else {
1366            panic!("equivalent deployments should be deduplicated")
1367        };
1368        assert!(Arc::ptr_eq(&matched.input, &first_input));
1369        let mut other_commit = a.clone();
1370        other_commit.version = Version::parse("0.8.30+commit.deadbeef").unwrap();
1371        assert!(matches!(
1372            match_candidates(&[0x60, 0x00], &[a.clone(), other_commit]),
1373            MatchResult::Ambiguous(_)
1374        ));
1375
1376        let constructor: JsonAbi = serde_json::from_value(json!([{
1377            "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
1378        }]))
1379        .unwrap();
1380        let prefixed = candidate("A.sol:A", constructor);
1381        let mut observed = prefixed.creation_bytecode.to_vec();
1382        observed.extend([0; 32]);
1383        let exact = Candidate {
1384            constructor: None,
1385            creation_bytecode: Bytes::copy_from_slice(&observed),
1386            ..prefixed.clone()
1387        };
1388        assert!(matches!(
1389            match_candidates(&observed, &[prefixed, exact]),
1390            MatchResult::Ambiguous(_)
1391        ));
1392
1393        let b = candidate("B.sol:B", JsonAbi::default());
1394        let MatchResult::Ambiguous(matches) = match_candidates(&[0x60, 0x00], &[a, b]) else {
1395            panic!("expected ambiguity")
1396        };
1397        assert_eq!(matches.len(), 2);
1398    }
1399
1400    #[test]
1401    fn unresolved_library_links_are_flagged_without_dropping_other_candidates() {
1402        let placeholder = format!("__${}$__", "0".repeat(34));
1403        let output = serde_json::from_value(json!({
1404            "contracts": {
1405                "A.sol": {
1406                    "Linked": {
1407                        "abi": [],
1408                        "evm": { "bytecode": {
1409                            "object": "6000",
1410                            "linkReferences": {
1411                                "Lib.sol": { "Lib": [{ "start": 0, "length": 20 }] }
1412                            }
1413                        }}
1414                    },
1415                    "Placeholder": {
1416                        "abi": [],
1417                        "evm": { "bytecode": { "object": placeholder } }
1418                    },
1419                    "Plain": {
1420                        "abi": [],
1421                        "evm": { "bytecode": { "object": "6001" } }
1422                    }
1423                }
1424            }
1425        }))
1426        .unwrap();
1427        let source = ExternalSource {
1428            input: Arc::new(input()),
1429            version: Version::new(0, 8, 30),
1430            provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1431        };
1432        let (candidates, has_unresolved_links) = candidates_from_output(&source, output).unwrap();
1433        assert!(has_unresolved_links);
1434        assert_eq!(candidates.len(), 1);
1435        assert_eq!(candidates[0].fqn, "A.sol:Plain");
1436    }
1437
1438    #[test]
1439    fn capped_chunk_aggregation_rejects_limit_plus_one() {
1440        let mut output = Vec::new();
1441        assert!(append_capped(&mut output, b"123", 4));
1442        assert!(!append_capped(&mut output, b"45", 4));
1443        assert_eq!(output, b"123");
1444    }
1445
1446    #[test]
1447    fn empty_creation_bytecode_never_matches() {
1448        let mut empty = candidate("A.sol:A", JsonAbi::default());
1449        empty.creation_bytecode = Bytes::new();
1450        assert!(matches!(match_candidates(&[], &[empty]), MatchResult::None));
1451    }
1452
1453    #[test]
1454    fn candidates_share_source_input() {
1455        let first = candidate("A.sol:A", JsonAbi::default());
1456        let second = Candidate { fqn: "A.sol:B".into(), ..first.clone() };
1457        assert!(Arc::ptr_eq(&first.input, &second.input));
1458    }
1459
1460    #[test]
1461    fn remote_diagnostics_are_bounded_and_sanitized() {
1462        let message = format!("secret\n{}", "x".repeat(300));
1463        let clean = sanitize_remote(message.as_bytes());
1464        assert!(!clean.contains('\n'));
1465        assert_eq!(clean.chars().count(), 160);
1466    }
1467}