Skip to main content

foundry_common/
external_compiler.rs

1//! External compiler adapter protocol and process transport.
2
3use crate::fs::canonicalize_path;
4use alloy_primitives::hex;
5use eyre::{Context, ContextCompat, Result, bail, ensure};
6use foundry_compilers::{
7    ArtifactFile, ArtifactOutput, Artifacts, ConfigurableArtifacts, ProjectCompileOutput,
8    artifacts::{ConfigurableContractArtifact, Severity, contract::Contract},
9    compilers::{Compiler, Language, multi::MultiCompilerLanguage},
10    contracts::{VersionedContract, VersionedContracts},
11};
12use foundry_config::{Config, DenyLevel, ExternalCompiler};
13use semver::Version;
14use serde::{Deserialize, Serialize, de::DeserializeOwned};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17use std::{
18    collections::{BTreeMap, BTreeSet},
19    ffi::OsStr,
20    fs::{self, File},
21    io::{BufRead, BufReader, Read, Write},
22    path::{Component, Path, PathBuf},
23    process::{Child, ChildStdin, ChildStdout, Command, Stdio},
24    thread::JoinHandle,
25};
26
27const PROTOCOL_VERSION: &str = "1.0";
28const MAX_PROTOCOL_LINE_BYTES: u64 = 16 * 1024 * 1024;
29const EXTERNAL_CACHE_DIR: &str = "external-compilers";
30const EXTERNAL_ARTIFACT_DIR: &str = ".external";
31
32/// External artifacts and the unit inventory used to publish them.
33pub(crate) struct ExternalCompilation<'a> {
34    config: &'a Config,
35    artifacts: Artifacts<ConfigurableContractArtifact>,
36    contracts: VersionedContracts<Contract>,
37    write_outputs: bool,
38    complete_discovery: bool,
39    active_units: BTreeMap<String, BTreeSet<String>>,
40    pending_cache: Vec<PendingCache>,
41}
42
43impl<'a> ExternalCompilation<'a> {
44    /// Runs adapters, staging their output until the built-in compilation succeeds.
45    pub(crate) fn compile(
46        config: &'a Config,
47        selected_paths: &[PathBuf],
48        write_outputs: bool,
49    ) -> Result<Self> {
50        let mut compilation = Self {
51            config,
52            artifacts: Artifacts::default(),
53            contracts: VersionedContracts::default(),
54            write_outputs,
55            complete_discovery: selected_paths.is_empty(),
56            active_units: BTreeMap::new(),
57            pending_cache: Vec::new(),
58        };
59        let mut adapter_keys = BTreeSet::new();
60        for adapter in &config.external_compilers {
61            ensure!(
62                adapter_keys.insert(adapter.id.to_ascii_lowercase()),
63                "duplicate or case-insensitive external compiler adapter ID `{}`",
64                adapter.id
65            );
66            ensure_portable_child(&config.out.join(EXTERNAL_ARTIFACT_DIR), &adapter.id, None)?;
67            ensure_portable_child(&config.cache_path.join(EXTERNAL_CACHE_DIR), &adapter.id, None)?;
68            AdapterClient::new(config, adapter, selected_paths)?.compile(&mut compilation)?;
69        }
70        Ok(compilation)
71    }
72
73    /// Merges validated external artifacts into a built-in compilation result.
74    pub(crate) fn merge<C>(self, output: &mut ProjectCompileOutput<C>) -> Result<()>
75    where
76        C: Compiler<CompilerContract = Contract>,
77    {
78        for (source, contracts) in &self.artifacts {
79            for name in contracts.keys() {
80                ensure!(
81                    output.find(source, name).is_none(),
82                    "external compiler artifact conflicts with built-in artifact {}:{name}",
83                    source.display()
84                );
85            }
86        }
87
88        if self.write_outputs {
89            let output_root = self.config.out.join(EXTERNAL_ARTIFACT_DIR);
90            let cache_root = self.config.cache_path.join(EXTERNAL_CACHE_DIR);
91            let active_adapters = self.active_units.keys().cloned().collect();
92            if self.config.cache {
93                retire_children(&cache_root, &active_adapters, None)?;
94                if self.complete_discovery {
95                    for (adapter, units) in &self.active_units {
96                        retire_children(&cache_root.join(adapter), units, Some("json"))?;
97                    }
98                }
99                for cache in &self.pending_cache {
100                    write_cache(&cache.path, &cache.contents)?;
101                }
102            }
103            retire_children(&output_root, &active_adapters, None)?;
104            for (adapter, units) in &self.active_units {
105                let adapter_root = output_root.join(adapter);
106                if self.complete_discovery {
107                    retire_children(&adapter_root, units, None)?;
108                }
109                for unit in units {
110                    let unit_root = adapter_root.join(unit);
111                    if unit_root.exists() {
112                        fs::remove_dir_all(unit_root)?;
113                    }
114                }
115            }
116            self.artifacts.write_all()?;
117        }
118
119        if self.artifacts.is_empty() {
120            return Ok(());
121        }
122        let mut merged = output.compiled_artifacts().clone();
123        for (source, contracts) in self.artifacts {
124            merged.as_mut().entry(source).or_default().extend(contracts);
125        }
126        for (source, contracts) in self.contracts {
127            output.output_mut().contracts.as_mut().entry(source).or_default().extend(contracts);
128        }
129        output.set_compiled_artifacts(merged);
130        Ok(())
131    }
132}
133
134struct AdapterClient<'a> {
135    config: &'a Config,
136    cache_root: PathBuf,
137    adapter: &'a ExternalCompiler,
138    selected_paths: &'a [PathBuf],
139    command: PathBuf,
140}
141
142impl<'a> AdapterClient<'a> {
143    fn new(
144        config: &'a Config,
145        adapter: &'a ExternalCompiler,
146        selected_paths: &'a [PathBuf],
147    ) -> Result<Self> {
148        validate_id("adapter", &adapter.id)?;
149        ensure!(!adapter.roots.is_empty(), "external compiler `{}` has no roots", adapter.id);
150        let command = resolve_file(&config.root, &adapter.command).wrap_err_with(|| {
151            format!("failed to resolve external compiler adapter `{}`", adapter.id)
152        })?;
153        Ok(Self {
154            config,
155            cache_root: config.cache_path.join(EXTERNAL_CACHE_DIR).join(&adapter.id),
156            adapter,
157            selected_paths,
158            command,
159        })
160    }
161
162    fn compile(&self, output: &mut ExternalCompilation<'_>) -> Result<()> {
163        let mut process =
164            AdapterProcess::spawn(&self.config.root, &self.command, &self.adapter.args)
165                .wrap_err_with(|| {
166                    format!("failed to start external compiler `{}`", self.adapter.id)
167                })?;
168        let result = self.compile_units(output, &mut process);
169        process.finish(result)
170    }
171
172    fn compile_units(
173        &self,
174        output: &mut ExternalCompilation<'_>,
175        process: &mut AdapterProcess,
176    ) -> Result<()> {
177        let initialized: InitializeResult = process.request(
178            "initialize",
179            json!({
180                "protocols": [PROTOCOL_VERSION],
181                "host": {"name": "forge", "version": env!("CARGO_PKG_VERSION")},
182                "target": "evm",
183            }),
184        )?;
185        ensure!(
186            initialized.protocol == PROTOCOL_VERSION,
187            "external compiler `{}` selected unsupported protocol `{}`",
188            self.adapter.id,
189            initialized.protocol
190        );
191
192        let roots = self
193            .adapter
194            .roots
195            .iter()
196            .map(|path| normalize_root_path(path).map(|path| self.config.root.join(path)))
197            .collect::<Result<Vec<_>>>()?;
198        let discovery: DiscoverResult = process.request(
199            "discover",
200            json!({
201                "roots": roots,
202                "settings": self.adapter.settings,
203                "selected_paths": self.selected_paths,
204            }),
205        )?;
206
207        let mut active_units = BTreeSet::new();
208        let mut unit_keys = BTreeSet::new();
209        for unit in discovery.units {
210            validate_id("build unit", &unit.id)?;
211            ensure!(
212                active_units.insert(unit.id.clone()),
213                "external compiler `{}` returned duplicate unit ID `{}`",
214                self.adapter.id,
215                unit.id
216            );
217            ensure!(
218                !unit.compiler.name.is_empty(),
219                "external compiler `{}` unit `{}` returned an empty compiler name",
220                self.adapter.id,
221                unit.id
222            );
223            ensure!(
224                unit_keys.insert(unit.id.to_ascii_lowercase()),
225                "external compiler `{}` unit ID `{}` collides on case-insensitive filesystems",
226                self.adapter.id,
227                unit.id
228            );
229            ensure_portable_child(&self.cache_root, &unit.id, Some("json"))?;
230            ensure_portable_child(
231                &self.config.out.join(EXTERNAL_ARTIFACT_DIR).join(&self.adapter.id),
232                &unit.id,
233                None,
234            )?;
235            ensure!(
236                unit.capabilities.contains("build/1"),
237                "external compiler `{}` unit `{}` does not support `build/1`",
238                self.adapter.id,
239                unit.id
240            );
241            let fingerprint = self.fingerprint(&unit)?;
242            let cache_path = self.cache_root.join(format!("{}.json", unit.id));
243            let result = if self.config.cache && !self.config.force && unit.cacheable {
244                read_cache(&cache_path, &fingerprint)?
245            } else {
246                None
247            };
248            let (result, fresh) = match result {
249                Some(result) => (result, false),
250                None => {
251                    let result: CompileResult = process
252                        .request("compile", json!({"unit": unit.id, "fingerprint": fingerprint}))?;
253                    (result, true)
254                }
255            };
256            emit_diagnostics(&self.adapter.id, &unit.id, &result.diagnostics, self.config.deny)?;
257            let cache = (output.write_outputs && fresh && self.config.cache && unit.cacheable)
258                .then(|| {
259                    serde_json::to_vec(&CacheEntry {
260                        fingerprint: fingerprint.clone(),
261                        result: &result,
262                    })
263                })
264                .transpose()?;
265            self.add_artifacts(output, &unit, &fingerprint, result, fresh)?;
266            if let Some(contents) = cache {
267                output.pending_cache.push(PendingCache { path: cache_path, contents });
268            }
269        }
270
271        output.active_units.insert(self.adapter.id.clone(), active_units);
272        Ok(())
273    }
274
275    fn fingerprint(&self, unit: &DiscoveredUnit) -> Result<String> {
276        let mut hasher = Sha256::new();
277        hash_part(&mut hasher, PROTOCOL_VERSION.as_bytes());
278        hash_part(&mut hasher, self.command.as_os_str().as_encoded_bytes());
279        hash_part(&mut hasher, &fs::read(&self.command)?);
280        hash_part(&mut hasher, &serde_json::to_vec(&self.adapter.args)?);
281        hash_part(&mut hasher, &serde_json::to_vec(&self.adapter.settings)?);
282        hash_part(&mut hasher, &serde_json::to_vec(unit)?);
283
284        let mut inputs = unit
285            .inputs
286            .iter()
287            .map(|path| resolve_file(&self.config.root, path))
288            .collect::<Result<Vec<_>>>()?;
289        inputs.sort();
290        inputs.dedup();
291        for input in inputs {
292            hash_part(&mut hasher, input.as_os_str().as_encoded_bytes());
293            hash_part(&mut hasher, &fs::read(&input)?);
294        }
295        Ok(hex::encode(hasher.finalize()))
296    }
297
298    fn add_artifacts(
299        &self,
300        output: &mut ExternalCompilation<'_>,
301        unit: &DiscoveredUnit,
302        fingerprint: &str,
303        result: CompileResult,
304        fresh: bool,
305    ) -> Result<()> {
306        let version = Version::parse(&unit.compiler.version).wrap_err_with(|| {
307            format!(
308                "external compiler `{}` unit `{}` returned non-SemVer compiler version `{}`",
309                self.adapter.id, unit.id, unit.compiler.version
310            )
311        })?;
312        let unit_out =
313            self.config.out.join(EXTERNAL_ARTIFACT_DIR).join(&self.adapter.id).join(&unit.id);
314        let mut artifact_paths = BTreeSet::new();
315        for artifact in result.artifacts {
316            let name = artifact.name.clone();
317            validate_id("contract", &name)?;
318            ensure!(
319                !name.contains(['.', '-']),
320                "external compiler contract name must contain only ASCII letters, digits, or underscores: {name}"
321            );
322            let source = validate_relative_path("source unit", &artifact.source)?.to_path_buf();
323            let source_path = resolve_file(&self.config.root, &source)?;
324            ensure!(
325                output
326                    .artifacts
327                    .get(&source_path)
328                    .and_then(|existing| existing.get(&name))
329                    .is_none(),
330                "external compiler `{}` returned duplicate artifact {}:{}",
331                self.adapter.id,
332                source.display(),
333                name
334            );
335            let artifact_path = unit_out.join(&source).join(format!("{name}.json"));
336            let relative_artifact_path = source.join(format!("{name}.json"));
337            ensure!(
338                artifact_paths
339                    .insert(relative_artifact_path.to_string_lossy().to_ascii_lowercase()),
340                "external compiler `{}` unit `{}` artifact path `{}` collides on case-insensitive filesystems",
341                self.adapter.id,
342                unit.id,
343                relative_artifact_path.display()
344            );
345            let (contract, compiler_contract) = artifact.into_foundry_outputs()?;
346            let build_id = format!(
347                "external:{}:{}:{}:{fingerprint}",
348                self.adapter.id,
349                unit.id,
350                if unit.capabilities.contains("forge-tests/1") { "forge-tests" } else { "build" }
351            );
352            if fresh {
353                output
354                    .contracts
355                    .as_mut()
356                    .entry(source_path.clone())
357                    .or_default()
358                    .entry(name.clone())
359                    .or_default()
360                    .push(VersionedContract {
361                        contract: compiler_contract,
362                        version: version.clone(),
363                        build_id: build_id.clone(),
364                        profile: self.config.profile.to_string(),
365                    });
366            }
367            output
368                .artifacts
369                .as_mut()
370                .entry(source_path)
371                .or_default()
372                .entry(name)
373                .or_default()
374                .push(ArtifactFile {
375                    artifact: contract,
376                    file: artifact_path,
377                    version: version.clone(),
378                    build_id,
379                    profile: self.config.profile.to_string(),
380                });
381        }
382        Ok(())
383    }
384}
385
386struct AdapterProcess {
387    child: Child,
388    stdin: Option<ChildStdin>,
389    stdout: BufReader<ChildStdout>,
390    stderr: Option<JoinHandle<std::io::Result<Vec<u8>>>>,
391    request_id: u64,
392}
393
394impl AdapterProcess {
395    fn spawn(root: &Path, command: &Path, args: &[String]) -> Result<Self> {
396        let mut child = Command::new(command)
397            .args(args)
398            .current_dir(root)
399            .env_clear()
400            .stdin(Stdio::piped())
401            .stdout(Stdio::piped())
402            .stderr(Stdio::piped())
403            .spawn()?;
404        let stdin = child.stdin.take().context("adapter stdin was not piped")?;
405        let stdout = child.stdout.take().context("adapter stdout was not piped")?;
406        let mut child_stderr = child.stderr.take().context("adapter stderr was not piped")?;
407        let stderr = std::thread::spawn(move || {
408            let mut bytes = Vec::new();
409            let mut chunk = [0_u8; 8192];
410            loop {
411                let read = child_stderr.read(&mut chunk)?;
412                if read == 0 {
413                    break;
414                }
415                let remaining = MAX_PROTOCOL_LINE_BYTES.saturating_sub(bytes.len() as u64) as usize;
416                bytes.extend_from_slice(&chunk[..read.min(remaining)]);
417            }
418            Ok(bytes)
419        });
420        Ok(Self {
421            child,
422            stdin: Some(stdin),
423            stdout: BufReader::new(stdout),
424            stderr: Some(stderr),
425            request_id: 0,
426        })
427    }
428
429    fn request<P: Serialize, R: DeserializeOwned>(
430        &mut self,
431        method: &'static str,
432        params: P,
433    ) -> Result<R> {
434        self.request_id += 1;
435        let request = Request { id: self.request_id, method, params };
436        let stdin = self.stdin.as_mut().context("adapter stdin is closed")?;
437        serde_json::to_writer(&mut *stdin, &request)?;
438        stdin.write_all(b"\n")?;
439        stdin.flush()?;
440
441        let mut line = String::new();
442        self.stdout
443            .by_ref()
444            .take(MAX_PROTOCOL_LINE_BYTES + 1)
445            .read_line(&mut line)
446            .wrap_err_with(|| format!("failed reading `{method}` response"))?;
447        ensure!(!line.is_empty(), "adapter exited before responding to `{method}`");
448        ensure!(
449            line.len() as u64 <= MAX_PROTOCOL_LINE_BYTES,
450            "adapter `{method}` response exceeds {MAX_PROTOCOL_LINE_BYTES} bytes"
451        );
452        let response: Response<R> = serde_json::from_str(&line)
453            .wrap_err_with(|| format!("invalid adapter response to `{method}`"))?;
454        ensure!(response.id == self.request_id, "adapter response ID does not match request");
455        match (response.result, response.error) {
456            (Some(result), None) => Ok(result),
457            (None, Some(error)) => bail!("adapter error {}: {}", error.code, error.message),
458            _ => bail!("adapter response must contain exactly one of `result` or `error`"),
459        }
460    }
461
462    fn finish(mut self, result: Result<()>) -> Result<()> {
463        drop(self.stdin.take());
464        if result.is_err() {
465            let _ = self.child.kill();
466        }
467        let status = self.child.wait()?;
468        let stderr = self
469            .stderr
470            .take()
471            .context("adapter stderr reader missing")?
472            .join()
473            .map_err(|_| eyre::eyre!("adapter stderr reader panicked"))??;
474        if stderr.is_empty() {
475            result?;
476        } else {
477            result.wrap_err_with(|| {
478                format!("adapter stderr: {}", String::from_utf8_lossy(&stderr).trim_end())
479            })?;
480        }
481        ensure!(
482            status.success(),
483            "adapter exited with {status}: {}",
484            String::from_utf8_lossy(&stderr)
485        );
486        Ok(())
487    }
488}
489
490impl Drop for AdapterProcess {
491    fn drop(&mut self) {
492        if self.child.try_wait().ok().flatten().is_none() {
493            let _ = self.child.kill();
494            let _ = self.child.wait();
495        }
496    }
497}
498
499#[derive(Serialize)]
500struct Request<P> {
501    id: u64,
502    method: &'static str,
503    params: P,
504}
505
506#[derive(Deserialize)]
507struct Response<R> {
508    id: u64,
509    result: Option<R>,
510    error: Option<ProtocolError>,
511}
512
513#[derive(Deserialize)]
514struct ProtocolError {
515    code: String,
516    message: String,
517}
518
519#[derive(Deserialize)]
520struct InitializeResult {
521    protocol: String,
522}
523
524#[derive(Deserialize)]
525struct DiscoverResult {
526    units: Vec<DiscoveredUnit>,
527}
528
529#[derive(Deserialize, Serialize)]
530#[serde(rename_all = "camelCase")]
531struct DiscoveredUnit {
532    id: String,
533    compiler: CompilerIdentity,
534    inputs: Vec<PathBuf>,
535    #[serde(default)]
536    capabilities: BTreeSet<String>,
537    #[serde(default)]
538    effective_settings: Value,
539    #[serde(default)]
540    cacheable: bool,
541}
542
543#[derive(Deserialize, Serialize)]
544struct CompilerIdentity {
545    name: String,
546    version: String,
547}
548
549#[derive(Deserialize, Serialize)]
550#[serde(rename_all = "camelCase")]
551struct CompileResult {
552    #[serde(default)]
553    diagnostics: Vec<ExternalDiagnostic>,
554    #[serde(default)]
555    artifacts: Vec<ExternalArtifact>,
556}
557
558#[derive(Deserialize, Serialize)]
559struct ExternalDiagnostic {
560    severity: Severity,
561    message: String,
562    #[serde(default)]
563    code: Option<String>,
564    #[serde(default)]
565    source: Option<PathBuf>,
566}
567
568#[derive(Deserialize, Serialize)]
569#[serde(rename_all = "camelCase")]
570struct ExternalArtifact {
571    source: PathBuf,
572    name: String,
573    contract: Contract,
574    #[serde(default)]
575    metadata: Option<Value>,
576    #[serde(default)]
577    source_id: Option<u32>,
578}
579
580impl ExternalArtifact {
581    fn into_foundry_outputs(mut self) -> Result<(ConfigurableContractArtifact, Contract)> {
582        let method_identifiers = self
583            .contract
584            .abi
585            .get_or_insert_with(Default::default)
586            .functions()
587            .map(|function| (function.signature(), hex::encode(function.selector())))
588            .collect::<BTreeMap<_, _>>();
589        if let Some(evm) = &mut self.contract.evm {
590            ensure!(
591                evm.bytecode.is_some() || evm.deployed_bytecode.is_none(),
592                "external artifact {} has runtime bytecode without creation bytecode",
593                self.name
594            );
595            for bytecode in evm.bytecode.iter().chain(
596                evm.deployed_bytecode.iter().filter_map(|deployed| deployed.bytecode.as_ref()),
597            ) {
598                ensure!(
599                    bytecode.object.is_bytecode() && bytecode.link_references.is_empty(),
600                    "external artifact {} must provide fully linked bytecode",
601                    self.name
602                );
603            }
604            evm.method_identifiers = method_identifiers.clone();
605        }
606        let mut artifact = ConfigurableArtifacts::default().contract_to_artifact(
607            &self.source,
608            &self.name,
609            self.contract.clone(),
610            None,
611        );
612        artifact.raw_metadata = self.metadata.as_ref().map(serde_json::to_string).transpose()?;
613        artifact.id = self.source_id;
614        artifact.method_identifiers = Some(method_identifiers);
615        Ok((artifact, self.contract))
616    }
617}
618
619#[derive(Deserialize, Serialize)]
620struct CacheEntry<R> {
621    fingerprint: String,
622    result: R,
623}
624
625struct PendingCache {
626    path: PathBuf,
627    contents: Vec<u8>,
628}
629
630/// Returns whether an artifact can be selected as a Forge test contract.
631pub fn external_artifact_is_test_eligible(build_id: &str) -> bool {
632    !is_external_artifact(build_id)
633        || build_id.split(':').nth(3).is_some_and(|role| role == "forge-tests")
634}
635
636/// Returns whether an artifact was produced by an external compiler adapter.
637pub fn is_external_artifact(build_id: &str) -> bool {
638    build_id.starts_with("external:")
639}
640
641/// Returns whether a path belongs to a compiler built into Foundry.
642pub fn is_builtin_compiler_source(path: &Path) -> bool {
643    path.extension().and_then(OsStr::to_str).is_some_and(|extension| {
644        MultiCompilerLanguage::FILE_EXTENSIONS
645            .iter()
646            .any(|candidate| extension.eq_ignore_ascii_case(candidate))
647    })
648}
649
650fn emit_diagnostics(
651    adapter: &str,
652    unit: &str,
653    diagnostics: &[ExternalDiagnostic],
654    deny: DenyLevel,
655) -> Result<()> {
656    let mut errors = Vec::new();
657    for diagnostic in diagnostics {
658        let code = diagnostic.code.as_deref().map(|code| format!(" [{code}]")).unwrap_or_default();
659        let source = diagnostic
660            .source
661            .as_ref()
662            .map(|source| format!(" {}", source.display()))
663            .unwrap_or_default();
664        let message = format!(
665            "external compiler `{adapter}` unit `{unit}`{code}{source}: {}",
666            diagnostic.message
667        );
668        match diagnostic.severity {
669            Severity::Error => errors.push(message),
670            Severity::Warning if deny.warnings() => errors.push(message),
671            Severity::Warning => sh_warn!("{message}")?,
672            Severity::Info => tracing::info!("{message}"),
673        }
674    }
675    ensure!(errors.is_empty(), "{}", errors.join("\n"));
676    Ok(())
677}
678
679fn read_cache(path: &Path, fingerprint: &str) -> Result<Option<CompileResult>> {
680    let Ok(file) = File::open(path) else { return Ok(None) };
681    let entry: CacheEntry<Value> = serde_json::from_reader(file)
682        .wrap_err_with(|| format!("failed to read external compiler cache {}", path.display()))?;
683    if entry.fingerprint != fingerprint {
684        return Ok(None);
685    }
686    Ok(Some(serde_json::from_value(entry.result)?))
687}
688
689fn write_cache(path: &Path, contents: &[u8]) -> Result<()> {
690    let parent = path.parent().context("external compiler cache path has no parent")?;
691    fs::create_dir_all(parent)?;
692    let mut temp = tempfile::NamedTempFile::new_in(parent)?;
693    temp.write_all(contents)?;
694    temp.as_file_mut().flush()?;
695    temp.persist(path).map_err(|error| error.error)?;
696    Ok(())
697}
698
699fn storage_entries(root: &Path) -> Result<Option<fs::ReadDir>> {
700    match fs::symlink_metadata(root) {
701        Ok(metadata) => {
702            ensure!(
703                !metadata.is_symlink(),
704                "external compiler storage path is a symlink: {}",
705                root.display()
706            );
707            Ok(Some(fs::read_dir(root)?))
708        }
709        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
710        Err(error) => Err(error.into()),
711    }
712}
713
714fn retire_children(root: &Path, active: &BTreeSet<String>, extension: Option<&str>) -> Result<()> {
715    let Some(entries) = storage_entries(root)? else { return Ok(()) };
716    for entry in entries {
717        let entry = entry?;
718        let path = entry.path();
719        let name = if let Some(extension) = extension {
720            if path.extension() != Some(OsStr::new(extension)) {
721                continue;
722            }
723            path.file_stem()
724        } else {
725            path.file_name()
726        };
727        if name.is_some_and(|name| !active.contains(name.to_string_lossy().as_ref())) {
728            if entry.file_type()?.is_dir() {
729                fs::remove_dir_all(path)?;
730            } else {
731                fs::remove_file(path)?;
732            }
733        }
734    }
735    Ok(())
736}
737
738fn ensure_portable_child(root: &Path, name: &str, extension: Option<&str>) -> Result<()> {
739    let Some(entries) = storage_entries(root)? else { return Ok(()) };
740    for entry in entries {
741        let entry = entry?;
742        let path = entry.path();
743        let Some(candidate) = (if let Some(extension) = extension {
744            (path.extension() == Some(OsStr::new(extension))).then(|| path.file_stem()).flatten()
745        } else {
746            path.file_name()
747        }) else {
748            continue;
749        };
750        let candidate = candidate.to_string_lossy();
751        ensure!(
752            candidate != name || !entry.file_type()?.is_symlink(),
753            "external compiler storage path is a symlink: {}",
754            path.display()
755        );
756        ensure!(
757            candidate == name || !candidate.eq_ignore_ascii_case(name),
758            "external compiler storage namespace `{name}` collides with existing `{candidate}` on case-insensitive filesystems"
759        );
760    }
761    Ok(())
762}
763
764fn validate_id(kind: &str, value: &str) -> Result<()> {
765    ensure!(!value.is_empty(), "external compiler {kind} ID cannot be empty");
766    ensure!(
767        !matches!(value, "." | "..")
768            && value
769                .bytes()
770                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')),
771        "invalid external compiler {kind} ID `{value}`"
772    );
773    Ok(())
774}
775
776fn validate_relative_path<'a>(kind: &str, path: &'a Path) -> Result<&'a Path> {
777    ensure!(!path.as_os_str().is_empty(), "external compiler {kind} cannot be empty");
778    ensure!(path.is_relative(), "external compiler {kind} must be relative: {}", path.display());
779    ensure!(
780        path.components().all(|component| matches!(component, Component::Normal(_))),
781        "external compiler {kind} contains an unsafe component: {}",
782        path.display()
783    );
784    Ok(path)
785}
786
787fn normalize_root_path(path: &Path) -> Result<&Path> {
788    if path == Path::new(".") { Ok(Path::new("")) } else { validate_relative_path("root", path) }
789}
790
791fn resolve_file(root: &Path, path: &Path) -> Result<PathBuf> {
792    let path = if path.is_absolute() { path.to_path_buf() } else { root.join(path) };
793    let path = canonicalize_path(&path)
794        .wrap_err_with(|| format!("failed to canonicalize {}", path.display()))?;
795    ensure!(path.is_file(), "external compiler input is not a file: {}", path.display());
796    Ok(path)
797}
798
799fn hash_part(hasher: &mut Sha256, bytes: &[u8]) {
800    hasher.update((bytes.len() as u64).to_le_bytes());
801    hasher.update(bytes);
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    #[cfg(unix)]
809    use std::os::unix::fs::symlink;
810
811    #[test]
812    fn rejects_unsafe_protocol_paths_and_ids() {
813        assert!(validate_relative_path("source", Path::new("src/main.fe")).is_ok());
814        assert!(validate_relative_path("source", Path::new("../outside.fe")).is_err());
815        assert!(validate_relative_path("source", Path::new("/outside.fe")).is_err());
816        assert_eq!(normalize_root_path(Path::new(".")).unwrap(), Path::new(""));
817        assert!(validate_id("unit", "app-1").is_ok());
818        assert!(validate_id("unit", ".").is_err());
819        assert!(validate_id("unit", "..").is_err());
820        assert!(validate_id("unit", "../app").is_err());
821    }
822
823    #[test]
824    fn rejects_case_insensitive_storage_collisions() {
825        let dir = tempfile::tempdir().unwrap();
826        fs::create_dir(dir.path().join("App")).unwrap();
827        assert!(ensure_portable_child(dir.path(), "app", None).is_err());
828        ensure_portable_child(dir.path(), "App", None).unwrap();
829
830        fs::write(dir.path().join("Build.json"), "{}").unwrap();
831        assert!(ensure_portable_child(dir.path(), "build", Some("json")).is_err());
832        ensure_portable_child(dir.path(), "Build", Some("json")).unwrap();
833    }
834
835    #[cfg(unix)]
836    #[test]
837    fn rejects_linked_storage() {
838        let dir = tempfile::tempdir().unwrap();
839        let target = dir.path().join("target");
840        fs::create_dir(&target).unwrap();
841        let sentinel = target.join("keep");
842        fs::write(&sentinel, "unchanged").unwrap();
843        let link = dir.path().join("linked");
844        symlink(&target, &link).unwrap();
845
846        assert!(retire_children(&link, &Default::default(), None).is_err());
847        assert!(ensure_portable_child(dir.path(), "linked", None).is_err());
848        assert_eq!(fs::read_to_string(sentinel).unwrap(), "unchanged");
849    }
850
851    #[test]
852    fn rejects_unlinked_bytecode() {
853        for field in ["bytecode", "deployedBytecode"] {
854            let mut value = serde_json::json!({
855                "source": "native/src/lib.fe",
856                "name": "Token",
857                "contract": {"evm": {"bytecode": {"object": "00"}}}
858            });
859            value["contract"]["evm"][field] = serde_json::json!({
860                "object": "0000000000000000000000000000000000000000",
861                "linkReferences": {"native/src/lib.fe": {"Library": [{"start": 0, "length": 20}]}}
862            });
863            let artifact: ExternalArtifact = serde_json::from_value(value).unwrap();
864            assert_eq!(
865                artifact.into_foundry_outputs().unwrap_err().to_string(),
866                "external artifact Token must provide fully linked bytecode"
867            );
868        }
869    }
870}