1use 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
32pub(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 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 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
630pub 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
636pub fn is_external_artifact(build_id: &str) -> bool {
638 build_id.starts_with("external:")
639}
640
641pub 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}