Skip to main content

a3s_code_core/
moli_runtime.rs

1//! Managed Moli runtime for the Code headless search tier.
2//!
3//! `a3s-search` owns the renderer adapter but intentionally leaves executable
4//! provisioning to its host.  This module is that host boundary for A3S Code:
5//! it prefers an explicitly configured or packaged sidecar, validates a
6//! versioned cache, and only then downloads a pinned release asset with a
7//! SHA-256 check.  Installation is atomic and protected by a cross-process
8//! lock so concurrent SDK/CLI calls cannot publish a partial executable.
9
10mod manifest;
11
12use crate::config::HeadlessConfig;
13use anyhow::{bail, Context, Result};
14use fs2::FileExt;
15use futures::StreamExt;
16use manifest::{asset_for, current_target, default_version, ManifestAsset};
17use sha2::{Digest, Sha256};
18use std::io::{Read, Write};
19use std::path::{Component, Path, PathBuf};
20use std::time::{Duration, Instant};
21use tokio::io::AsyncWriteExt;
22
23pub use manifest::{DEFAULT_MOLI_VERSION, MOLI_REPOSITORY_URL};
24
25const CACHE_ENV: &str = "A3S_CODE_MOLI_CACHE_DIR";
26const EXECUTABLE_ENV: &str = "A3S_CODE_MOLI_EXECUTABLE";
27const RELEASE_BASE_ENV: &str = "A3S_CODE_MOLI_RELEASE_BASE_URL";
28const RECEIPT_SCHEMA: &str = "a3s-code/moli-runtime-receipt/v1";
29const MAX_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
30const MAX_BINARY_BYTES: u64 = 256 * 1024 * 1024;
31const LOCK_POLL: Duration = Duration::from_millis(50);
32
33/// Schema identifier for [`MoliRuntimeInfo`].
34pub const MOLI_RUNTIME_INFO_SCHEMA_V1: &str = "a3s-code/moli-runtime-info/v1";
35
36/// Secret-free diagnostics for the Moli runtime resolution path.
37///
38/// The structure is intentionally value-only so SDKs can expose it without
39/// sharing the runtime manager's filesystem handles or locks. `executable`
40/// is the currently discoverable path (if any); it is not a promise that the
41/// browser has been started. Call [`ensure_moli`] before using it for search.
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
43pub struct MoliRuntimeInfo {
44    pub schema: String,
45    pub version: String,
46    pub target: Option<String>,
47    pub executable: Option<String>,
48    pub packaged: bool,
49    pub cache_dir: Option<String>,
50    pub auto_download: bool,
51}
52
53/// Return the current, secret-free Moli resolution diagnostics.
54pub fn moli_runtime_info(config: Option<&HeadlessConfig>) -> MoliRuntimeInfo {
55    let fallback = HeadlessConfig::default();
56    let config = config.unwrap_or(&fallback);
57    let version = config
58        .moli_version
59        .as_deref()
60        .map(|value| value.trim().trim_start_matches('v').to_owned())
61        .filter(|value| !value.is_empty())
62        .unwrap_or_else(|| default_moli_version().to_owned());
63    let target = current_target().map(str::to_owned);
64    let packaged = packaged_moli();
65    let executable = config
66        .browser_path
67        .as_deref()
68        .map(PathBuf::from)
69        .filter(|path| is_executable(path))
70        .or_else(|| explicit_environment_executable().ok().flatten())
71        .or_else(|| packaged.clone())
72        .or_else(a3s_search::detect_moli)
73        .or_else(|| {
74            let target = target.as_deref()?;
75            let root = cache_root(config).ok()?;
76            let candidate = root
77                .join(&version)
78                .join(target)
79                .join(manifest::executable_name());
80            is_executable(&candidate).then_some(candidate)
81        })
82        .map(|path| path.to_string_lossy().into_owned());
83    let cache_dir = cache_root(config)
84        .ok()
85        .map(|path| path.to_string_lossy().into_owned());
86    MoliRuntimeInfo {
87        schema: MOLI_RUNTIME_INFO_SCHEMA_V1.to_owned(),
88        version,
89        target,
90        executable,
91        packaged: packaged.is_some(),
92        cache_dir,
93        auto_download: config.auto_download_moli,
94    }
95}
96
97#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98struct InstallReceipt {
99    schema: String,
100    version: String,
101    target: String,
102    archive_sha256: String,
103    binary_sha256: String,
104}
105
106/// Return the pinned Moli release used when no version is specified.
107pub fn default_moli_version() -> &'static str {
108    default_version()
109}
110
111/// Ensure a usable Moli executable exists and return its absolute path.
112pub async fn ensure_moli(config: &HeadlessConfig, timeout: Duration) -> Result<PathBuf> {
113    ensure_moli_from(config, timeout, None, false).await
114}
115
116/// Locate a packaged Moli sidecar without consulting the cache or network.
117/// Hosts that package a native SDK can use this for diagnostics and tests.
118pub fn packaged_moli() -> Option<PathBuf> {
119    packaged_candidates()
120        .into_iter()
121        .find(|path| is_executable(path))
122}
123
124async fn ensure_moli_from(
125    config: &HeadlessConfig,
126    timeout: Duration,
127    test_base_url: Option<&str>,
128    allow_insecure_test_url: bool,
129) -> Result<PathBuf> {
130    let deadline = Instant::now() + timeout.max(Duration::from_secs(1));
131    // Explicit and packaged runtimes are valid even on targets for which the
132    // upstream project does not publish a prebuilt archive (for example musl
133    // Linux). Resolve these first so a host-provided executable is never
134    // blocked by release-manifest lookup.
135    if let Some(raw_path) = config.browser_path.as_deref() {
136        let path = PathBuf::from(raw_path);
137        return validate_explicit_executable(&path);
138    }
139    if let Some(path) = explicit_environment_executable()? {
140        return Ok(path);
141    }
142    if let Some(path) = packaged_moli() {
143        return Ok(path);
144    }
145
146    let target = current_target();
147    let managed = if let Some(target) = target {
148        // Some targets (currently Linux musl) have no upstream Moli asset.
149        // Keep system/explicit discovery usable on those targets instead of
150        // failing before the fallback check below. Configuration errors for a
151        // target that does have an asset remain fatal and are not swallowed.
152        match resolve_release(config, target) {
153            Ok((version, expected_sha256, asset)) => {
154                let cache_root = cache_root(config)?;
155                prepare_cache_layout(&cache_root, &version, target).await?;
156                let binary_path = cache_root
157                    .join(&version)
158                    .join(target)
159                    .join(manifest::executable_name());
160                let Some(parent) = binary_path.parent() else {
161                    bail!("Moli cache binary path has no parent");
162                };
163                let receipt_path = parent.join("receipt.json");
164                if validate_cached(
165                    &binary_path,
166                    &receipt_path,
167                    &version,
168                    target,
169                    &expected_sha256,
170                )
171                .await
172                {
173                    return Ok(binary_path);
174                }
175                Some((
176                    target,
177                    version,
178                    expected_sha256,
179                    asset,
180                    cache_root,
181                    binary_path,
182                    receipt_path,
183                ))
184            }
185            Err(_) if asset_for(target).is_none() => None,
186            Err(error) => return Err(error),
187        }
188    } else {
189        None
190    };
191
192    // A system-installed Moli is a valid host-owned runtime even when the
193    // upstream release does not publish an archive for the current target
194    // (notably musl Linux).  Check it after the verified managed cache so a
195    // pinned cache remains authoritative when both are available.
196    if let Some(path) = a3s_search::detect_moli() {
197        return Ok(path);
198    }
199
200    let Some((target, version, expected_sha256, asset, cache_root, binary_path, receipt_path)) =
201        managed
202    else {
203        return Err(anyhow::anyhow!(
204            "Moli has no prebuilt asset for this target; use an explicit Chrome/Lightpanda backend or provide A3S_CODE_MOLI_EXECUTABLE"
205        ));
206    };
207
208    if !config.auto_download_moli {
209        bail!(
210            "Moli is unavailable and auto_download_moli is disabled; install Moli from {MOLI_REPOSITORY_URL} or set {EXECUTABLE_ENV}"
211        );
212    }
213
214    let _lock = acquire_install_lock(&cache_root, deadline).await?;
215    if validate_cached(
216        &binary_path,
217        &receipt_path,
218        &version,
219        target,
220        &expected_sha256,
221    )
222    .await
223    {
224        return Ok(binary_path);
225    }
226
227    let base_url = test_base_url
228        .map(str::to_string)
229        .or_else(|| std::env::var(RELEASE_BASE_ENV).ok())
230        .unwrap_or_else(|| format!("{MOLI_REPOSITORY_URL}/releases/download/v{version}"));
231    validate_release_base_url(&base_url, allow_insecure_test_url)?;
232    install_downloaded(InstallRequest {
233        cache_root: &cache_root,
234        binary_path: &binary_path,
235        receipt_path: &receipt_path,
236        version: &version,
237        target,
238        expected_archive_sha256: &expected_sha256,
239        asset,
240        base_url: &base_url,
241        deadline,
242        allow_insecure_test_url,
243    })
244    .await
245}
246
247/// Create and validate the shared cache hierarchy before reading or writing
248/// any runtime files. Rejecting symlinked directories prevents a less
249/// privileged process from redirecting an installation outside the selected
250/// cache root. The root is private because it contains executable code and
251/// integrity receipts shared by all local Code processes.
252async fn prepare_cache_layout(root: &Path, version: &str, target: &str) -> Result<()> {
253    tokio::fs::create_dir_all(root)
254        .await
255        .with_context(|| format!("create Moli cache {}", root.display()))?;
256    validate_cache_directory(root).await?;
257
258    let version_dir = root.join(version);
259    tokio::fs::create_dir_all(&version_dir)
260        .await
261        .with_context(|| format!("create Moli version cache {}", version_dir.display()))?;
262    validate_cache_directory(&version_dir).await?;
263
264    let target_dir = version_dir.join(target);
265    tokio::fs::create_dir_all(&target_dir)
266        .await
267        .with_context(|| format!("create Moli target cache {}", target_dir.display()))?;
268    validate_cache_directory(&target_dir).await
269}
270
271async fn validate_cache_directory(path: &Path) -> Result<()> {
272    let metadata = tokio::fs::symlink_metadata(path)
273        .await
274        .with_context(|| format!("inspect Moli cache directory {}", path.display()))?;
275    if metadata.file_type().is_symlink() || !metadata.is_dir() {
276        bail!(
277            "Moli cache path is not a real directory: {}",
278            path.display()
279        );
280    }
281    #[cfg(unix)]
282    {
283        use std::os::unix::fs::PermissionsExt;
284        let mut permissions = metadata.permissions();
285        if permissions.mode() & 0o077 != 0 {
286            permissions.set_mode(0o700);
287            tokio::fs::set_permissions(path, permissions)
288                .await
289                .with_context(|| format!("restrict Moli cache directory {}", path.display()))?;
290        }
291    }
292    Ok(())
293}
294
295fn resolve_release(
296    config: &HeadlessConfig,
297    target: &'static str,
298) -> Result<(String, String, ManifestAsset)> {
299    let version = config
300        .moli_version
301        .as_deref()
302        .unwrap_or(default_moli_version())
303        .trim()
304        .trim_start_matches('v')
305        .to_string();
306    validate_version(&version)?;
307
308    let expected_sha256 = match config.moli_sha256.as_deref() {
309        Some(value) => normalize_digest(value)?,
310        None if version == default_moli_version() => asset_for(target)
311            .map(|asset| asset.sha256.to_string())
312            .ok_or_else(|| anyhow::anyhow!("Moli asset metadata is missing for target {target}"))?,
313        None => bail!(
314            "moli_version={version} must be accompanied by moli_sha256 so the downloaded archive is pinned"
315        ),
316    };
317    let asset = asset_for(target)
318        .ok_or_else(|| anyhow::anyhow!("Moli asset metadata is missing for target {target}"))?;
319    Ok((version, expected_sha256, asset))
320}
321
322fn cache_root(config: &HeadlessConfig) -> Result<PathBuf> {
323    let configured = config
324        .moli_cache_dir
325        .clone()
326        .or_else(|| std::env::var_os(CACHE_ENV).map(PathBuf::from))
327        .unwrap_or_else(|| {
328            dirs::cache_dir()
329                .unwrap_or_else(std::env::temp_dir)
330                .join("a3s-code")
331                .join("moli")
332        });
333    if !configured.is_absolute() {
334        bail!(
335            "Moli cache directory must be absolute: {}",
336            configured.display()
337        );
338    }
339    Ok(configured)
340}
341
342fn explicit_environment_executable() -> Result<Option<PathBuf>> {
343    let Some(raw) = std::env::var_os(EXECUTABLE_ENV) else {
344        return Ok(None);
345    };
346    let path = resolve_named_path(&raw)
347        .ok_or_else(|| anyhow::anyhow!("{EXECUTABLE_ENV} does not identify an executable"))?;
348    Ok(Some(path))
349}
350
351fn packaged_candidates() -> Vec<PathBuf> {
352    let mut candidates = Vec::new();
353    if let Some(path) = std::env::var_os("A3S_CODE_MOLI_PATH") {
354        candidates.push(PathBuf::from(path));
355    }
356    if let Some(directory) = std::env::var_os("A3S_CODE_MOLI_DIR") {
357        let directory = PathBuf::from(directory);
358        candidates.push(directory.join(manifest::executable_name()));
359    }
360    if let Ok(executable) = std::env::current_exe() {
361        let mut roots = Vec::new();
362        if let Some(parent) = executable.parent() {
363            roots.push(parent.to_path_buf());
364            if let Some(grandparent) = parent.parent() {
365                roots.push(grandparent.to_path_buf());
366                roots.push(grandparent.join("Resources"));
367            }
368        }
369        for root in roots {
370            candidates.extend([
371                root.join(manifest::executable_name()),
372                root.join("moli").join(manifest::executable_name()),
373                root.join("resources").join(manifest::executable_name()),
374                root.join("resources")
375                    .join("moli")
376                    .join(manifest::executable_name()),
377            ]);
378        }
379    }
380    if let Ok(directory) = std::env::current_dir() {
381        candidates.extend([
382            directory.join(manifest::executable_name()),
383            directory.join("moli").join(manifest::executable_name()),
384        ]);
385    }
386    let mut unique = Vec::new();
387    for path in candidates {
388        if !unique.iter().any(|current: &PathBuf| current == &path) {
389            unique.push(path);
390        }
391    }
392    unique
393}
394
395fn resolve_named_path(value: &std::ffi::OsStr) -> Option<PathBuf> {
396    let path = Path::new(value);
397    if path.is_absolute() || path.components().count() > 1 {
398        return is_executable(path).then(|| path.to_path_buf());
399    }
400    let path_var = std::env::var_os("PATH")?;
401    std::env::split_paths(&path_var)
402        .map(|directory| directory.join(value))
403        .find(|candidate| is_executable(candidate))
404}
405
406fn validate_explicit_executable(path: &Path) -> Result<PathBuf> {
407    if is_executable(path) {
408        Ok(path.to_path_buf())
409    } else {
410        bail!(
411            "configured Moli executable is missing or not executable: {}",
412            path.display()
413        )
414    }
415}
416
417fn is_executable(path: &Path) -> bool {
418    let Ok(metadata) = std::fs::metadata(path) else {
419        return false;
420    };
421    if !metadata.is_file() {
422        return false;
423    }
424    #[cfg(unix)]
425    {
426        use std::os::unix::fs::PermissionsExt;
427        metadata.permissions().mode() & 0o111 != 0
428    }
429    #[cfg(not(unix))]
430    {
431        true
432    }
433}
434
435fn validate_version(version: &str) -> Result<()> {
436    if version.is_empty()
437        || version.len() > 64
438        || !version
439            .bytes()
440            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
441    {
442        bail!("invalid Moli version `{version}`");
443    }
444    Ok(())
445}
446
447fn normalize_digest(value: &str) -> Result<String> {
448    let value = value.trim();
449    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
450        bail!("moli_sha256 must contain exactly 64 hexadecimal characters");
451    }
452    Ok(value.to_ascii_lowercase())
453}
454
455fn validate_release_base_url(value: &str, allow_insecure_test_url: bool) -> Result<()> {
456    let parsed = reqwest::Url::parse(value).with_context(|| "invalid Moli release base URL")?;
457    if parsed.username() != "" || parsed.password().is_some() || parsed.host_str().is_none() {
458        bail!("Moli release base URL must not contain credentials and must include a host");
459    }
460    if parsed.scheme() != "https" && !(allow_insecure_test_url && parsed.scheme() == "http") {
461        bail!("Moli release base URL must use https");
462    }
463    Ok(())
464}
465
466async fn validate_cached(
467    binary_path: &Path,
468    receipt_path: &Path,
469    version: &str,
470    target: &str,
471    expected_archive_sha256: &str,
472) -> bool {
473    if symlink_or_missing(binary_path).await || symlink_or_missing(receipt_path).await {
474        return false;
475    }
476    if !is_executable(binary_path) {
477        return false;
478    }
479    let Ok(metadata) = tokio::fs::metadata(binary_path).await else {
480        return false;
481    };
482    if metadata.len() == 0 || metadata.len() > MAX_BINARY_BYTES {
483        return false;
484    }
485    let Ok(bytes) = tokio::fs::read(receipt_path).await else {
486        return false;
487    };
488    let Ok(receipt) = serde_json::from_slice::<InstallReceipt>(&bytes) else {
489        return false;
490    };
491    if receipt.schema != RECEIPT_SCHEMA
492        || receipt.version != version
493        || receipt.target != target
494        || receipt.archive_sha256 != expected_archive_sha256
495    {
496        return false;
497    }
498    hash_file(binary_path)
499        .await
500        .is_ok_and(|digest| digest == receipt.binary_sha256)
501}
502
503async fn symlink_or_missing(path: &Path) -> bool {
504    match tokio::fs::symlink_metadata(path).await {
505        Ok(metadata) => metadata.file_type().is_symlink(),
506        Err(_) => true,
507    }
508}
509
510async fn hash_file(path: &Path) -> Result<String> {
511    let mut file = tokio::fs::File::open(path)
512        .await
513        .with_context(|| format!("open {} for SHA-256", path.display()))?;
514    let mut digest = Sha256::new();
515    let mut buffer = vec![0_u8; 128 * 1024];
516    loop {
517        let read = tokio::io::AsyncReadExt::read(&mut file, &mut buffer)
518            .await
519            .with_context(|| format!("hash {}", path.display()))?;
520        if read == 0 {
521            break;
522        }
523        digest.update(&buffer[..read]);
524    }
525    Ok(format!("{:x}", digest.finalize()))
526}
527
528async fn acquire_install_lock(root: &Path, deadline: Instant) -> Result<std::fs::File> {
529    let path = root.join(".install.lock");
530    let path_for_open = path.clone();
531    let file = tokio::task::spawn_blocking(move || {
532        std::fs::OpenOptions::new()
533            .create(true)
534            .truncate(false)
535            .read(true)
536            .write(true)
537            .open(&path_for_open)
538            .with_context(|| format!("open Moli install lock {}", path_for_open.display()))
539    })
540    .await
541    .context("Moli install-lock worker failed")??;
542
543    loop {
544        if Instant::now() >= deadline {
545            bail!("timed out waiting for the Moli install lock");
546        }
547        match file.try_lock_exclusive() {
548            Ok(()) => return Ok(file),
549            // `fs2` exposes the platform's native contention error.  Unix
550            // normally maps it to `WouldBlock`, while Windows reports
551            // `ERROR_LOCK_VIOLATION` as `PermissionDenied`; compare the
552            // canonical fs2 error as well so waiters never fail spuriously
553            // on Windows when another Code process owns the lock.
554            Err(error) if is_lock_contended(&error) => {
555                let remaining = deadline.saturating_duration_since(Instant::now());
556                tokio::time::sleep(LOCK_POLL.min(remaining)).await;
557            }
558            Err(error) => {
559                return Err(error)
560                    .with_context(|| format!("acquire Moli install lock {}", path.display()))
561            }
562        }
563    }
564}
565
566fn is_lock_contended(error: &std::io::Error) -> bool {
567    if error.kind() == std::io::ErrorKind::WouldBlock {
568        return true;
569    }
570    fs2::lock_contended_error()
571        .raw_os_error()
572        .is_some_and(|code| error.raw_os_error() == Some(code))
573}
574
575struct InstallRequest<'a> {
576    cache_root: &'a Path,
577    binary_path: &'a Path,
578    receipt_path: &'a Path,
579    version: &'a str,
580    target: &'a str,
581    expected_archive_sha256: &'a str,
582    asset: ManifestAsset,
583    base_url: &'a str,
584    deadline: Instant,
585    allow_insecure_test_url: bool,
586}
587
588async fn install_downloaded(request: InstallRequest<'_>) -> Result<PathBuf> {
589    let InstallRequest {
590        cache_root,
591        binary_path,
592        receipt_path,
593        version,
594        target,
595        expected_archive_sha256,
596        asset,
597        base_url,
598        deadline,
599        allow_insecure_test_url,
600    } = request;
601    let Some(parent) = binary_path.parent() else {
602        bail!("Moli cache binary path has no parent");
603    };
604    let target_dir = parent.to_path_buf();
605    tokio::fs::create_dir_all(&target_dir)
606        .await
607        .with_context(|| format!("create Moli target directory {}", target_dir.display()))?;
608
609    let suffix = uuid::Uuid::new_v4().simple().to_string();
610    let archive_path = cache_root.join(format!(".moli-download-{suffix}.part"));
611    let stage_path = target_dir.join(format!(".moli-stage-{suffix}"));
612    let result = async {
613        let url = format!("{}/{}", base_url.trim_end_matches('/'), asset.archive);
614        download_archive(
615            &url,
616            expected_archive_sha256,
617            &archive_path,
618            deadline,
619            allow_insecure_test_url,
620        )
621        .await?;
622        let stage_for_extract = stage_path.clone();
623        let archive_for_extract = archive_path.clone();
624        let format = asset.format;
625        tokio::task::spawn_blocking(move || {
626            extract_binary(&archive_for_extract, &stage_for_extract, format)
627        })
628        .await
629        .context("Moli archive extraction worker failed")??;
630        if !is_executable(&stage_path) {
631            bail!("extracted Moli binary is not executable");
632        }
633        let binary_sha256 = hash_file(&stage_path).await?;
634        let receipt = InstallReceipt {
635            schema: RECEIPT_SCHEMA.to_string(),
636            version: version.to_string(),
637            target: target.to_string(),
638            archive_sha256: expected_archive_sha256.to_string(),
639            binary_sha256,
640        };
641
642        let binary_for_publish = binary_path.to_path_buf();
643        let stage_for_publish = stage_path.clone();
644        tokio::task::spawn_blocking(move || {
645            if binary_for_publish.exists() {
646                std::fs::remove_file(&binary_for_publish).with_context(|| {
647                    format!(
648                        "replace stale Moli executable {}",
649                        binary_for_publish.display()
650                    )
651                })?;
652            }
653            std::fs::rename(&stage_for_publish, &binary_for_publish).with_context(|| {
654                format!(
655                    "atomically publish Moli executable {}",
656                    binary_for_publish.display()
657                )
658            })?;
659            Ok::<_, anyhow::Error>(())
660        })
661        .await
662        .context("Moli executable publication worker failed")??;
663
664        let receipt_bytes = serde_json::to_vec_pretty(&receipt).context("encode Moli receipt")?;
665        let receipt_tmp = receipt_path.with_extension(format!("json-{suffix}.tmp"));
666        tokio::fs::write(&receipt_tmp, receipt_bytes)
667            .await
668            .with_context(|| format!("write Moli receipt {}", receipt_tmp.display()))?;
669        let receipt_for_rename = receipt_path.to_path_buf();
670        tokio::task::spawn_blocking(move || {
671            if receipt_for_rename.exists() {
672                std::fs::remove_file(&receipt_for_rename).with_context(|| {
673                    format!(
674                        "replace stale Moli receipt {}",
675                        receipt_for_rename.display()
676                    )
677                })?;
678            }
679            std::fs::rename(&receipt_tmp, &receipt_for_rename).with_context(|| {
680                format!(
681                    "atomically publish Moli receipt {}",
682                    receipt_for_rename.display()
683                )
684            })?;
685            Ok::<_, anyhow::Error>(())
686        })
687        .await
688        .context("Moli receipt publication worker failed")??;
689
690        #[cfg(unix)]
691        {
692            let directory = target_dir.clone();
693            tokio::task::spawn_blocking(move || std::fs::File::open(directory)?.sync_all())
694                .await
695                .context("Moli directory sync worker failed")??;
696        }
697        Ok::<_, anyhow::Error>(binary_path.to_path_buf())
698    }
699    .await;
700
701    let _ = tokio::fs::remove_file(&archive_path).await;
702    let _ = tokio::fs::remove_file(&stage_path).await;
703    result
704}
705
706async fn download_archive(
707    url: &str,
708    expected_sha256: &str,
709    destination: &Path,
710    deadline: Instant,
711    allow_insecure_test_url: bool,
712) -> Result<()> {
713    let client = reqwest::Client::builder()
714        .redirect(reqwest::redirect::Policy::limited(3))
715        // HTTP is enabled only for in-process wiremock tests. Production
716        // callers always pass false and therefore get an HTTPS-only client.
717        .https_only(!allow_insecure_test_url)
718        .build()
719        .context("build secure Moli download client")?;
720    let remaining = deadline.saturating_duration_since(Instant::now());
721    if remaining.is_zero() {
722        bail!("Moli download deadline expired before starting");
723    }
724    let response = tokio::time::timeout(remaining, client.get(url).send())
725        .await
726        .context("Moli download request timed out")?
727        .context("Moli download request failed")?;
728    if !response.status().is_success() {
729        bail!("Moli download returned HTTP {}", response.status());
730    }
731    if response
732        .content_length()
733        .is_some_and(|length| length > MAX_ARCHIVE_BYTES)
734    {
735        bail!(
736            "Moli archive exceeds the {} MiB limit",
737            MAX_ARCHIVE_BYTES / 1024 / 1024
738        );
739    }
740    let mut stream = response.bytes_stream();
741    let mut file = tokio::fs::File::create(destination)
742        .await
743        .with_context(|| format!("create Moli archive {}", destination.display()))?;
744    let mut digest = Sha256::new();
745    let mut total = 0_u64;
746    while let Some(chunk) = tokio::time::timeout(
747        deadline.saturating_duration_since(Instant::now()),
748        stream.next(),
749    )
750    .await
751    .context("Moli archive response timed out")?
752    {
753        let chunk = chunk.context("read Moli archive response")?;
754        total = total.saturating_add(chunk.len() as u64);
755        if total > MAX_ARCHIVE_BYTES {
756            bail!(
757                "Moli archive exceeds the {} MiB limit",
758                MAX_ARCHIVE_BYTES / 1024 / 1024
759            );
760        }
761        tokio::time::timeout(
762            deadline.saturating_duration_since(Instant::now()),
763            file.write_all(&chunk),
764        )
765        .await
766        .context("writing Moli archive timed out")?
767        .context("write Moli archive")?;
768        digest.update(&chunk);
769        if Instant::now() >= deadline {
770            bail!("Moli download timed out");
771        }
772    }
773    file.sync_all().await.context("sync Moli archive")?;
774    let actual = format!("{:x}", digest.finalize());
775    if actual != expected_sha256 {
776        bail!("Moli archive SHA-256 mismatch: expected {expected_sha256}, got {actual}");
777    }
778    Ok(())
779}
780
781fn extract_binary(archive_path: &Path, destination: &Path, format: &str) -> Result<()> {
782    if destination.exists() {
783        std::fs::remove_file(destination)
784            .with_context(|| format!("remove stale Moli staging file {}", destination.display()))?;
785    }
786    let parent = destination
787        .parent()
788        .ok_or_else(|| anyhow::anyhow!("Moli staging path has no parent"))?;
789    std::fs::create_dir_all(parent)
790        .with_context(|| format!("create Moli staging directory {}", parent.display()))?;
791    match format {
792        "tar.gz" => extract_tar_gz(archive_path, destination)?,
793        "zip" => extract_zip(archive_path, destination)?,
794        other => bail!("unsupported Moli archive format `{other}`"),
795    }
796    set_executable(destination)?;
797    std::fs::OpenOptions::new()
798        .read(true)
799        .write(true)
800        .open(destination)
801        .with_context(|| format!("open extracted Moli {}", destination.display()))?
802        .sync_all()
803        .context("sync extracted Moli binary")?;
804    Ok(())
805}
806
807fn extract_tar_gz(archive_path: &Path, destination: &Path) -> Result<()> {
808    let file = std::fs::File::open(archive_path)
809        .with_context(|| format!("open Moli archive {}", archive_path.display()))?;
810    let decoder = flate2::read::GzDecoder::new(file);
811    let mut archive = tar::Archive::new(decoder);
812    let mut found = false;
813    for entry in archive.entries().context("read Moli tar entries")? {
814        let mut entry = entry.context("read Moli tar entry")?;
815        let path = entry
816            .path()
817            .context("read Moli tar member path")?
818            .into_owned();
819        let Some(name) = validate_member_name(&path)? else {
820            continue;
821        };
822        if name != manifest::executable_name() {
823            continue;
824        }
825        if !entry.header().entry_type().is_file() || found {
826            bail!("Moli archive contains an invalid or duplicate executable member");
827        }
828        copy_bounded(&mut entry, destination)?;
829        found = true;
830    }
831    if !found {
832        bail!(
833            "Moli archive does not contain {}",
834            manifest::executable_name()
835        );
836    }
837    Ok(())
838}
839
840fn extract_zip(archive_path: &Path, destination: &Path) -> Result<()> {
841    let file = std::fs::File::open(archive_path)
842        .with_context(|| format!("open Moli archive {}", archive_path.display()))?;
843    let mut archive = zip::ZipArchive::new(file).context("read Moli zip archive")?;
844    let mut found = false;
845    for index in 0..archive.len() {
846        let mut entry = archive.by_index(index).context("read Moli zip entry")?;
847        let path = entry
848            .enclosed_name()
849            .ok_or_else(|| anyhow::anyhow!("Moli zip contains a traversal path"))?
850            .to_path_buf();
851        let Some(name) = validate_member_name(&path)? else {
852            continue;
853        };
854        if name != manifest::executable_name() {
855            continue;
856        }
857        if !entry.is_file() || found {
858            bail!("Moli zip contains an invalid or duplicate executable member");
859        }
860        copy_bounded(&mut entry, destination)?;
861        found = true;
862    }
863    if !found {
864        bail!(
865            "Moli archive does not contain {}",
866            manifest::executable_name()
867        );
868    }
869    Ok(())
870}
871
872fn validate_member_name(path: &Path) -> Result<Option<&str>> {
873    if path.is_absolute()
874        || path
875            .components()
876            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
877    {
878        bail!("Moli archive contains a traversal path: {}", path.display());
879    }
880    Ok(path
881        .file_name()
882        .and_then(|name| name.to_str())
883        .filter(|name| *name == manifest::executable_name()))
884}
885
886fn copy_bounded(reader: &mut impl Read, destination: &Path) -> Result<()> {
887    let mut output = std::fs::OpenOptions::new()
888        .write(true)
889        .create_new(true)
890        .open(destination)
891        .with_context(|| format!("create Moli staging binary {}", destination.display()))?;
892    let mut limited = reader.take(MAX_BINARY_BYTES.saturating_add(1));
893    let copied = std::io::copy(&mut limited, &mut output).context("extract Moli executable")?;
894    if copied > MAX_BINARY_BYTES {
895        bail!(
896            "Moli executable exceeds the {} MiB limit",
897            MAX_BINARY_BYTES / 1024 / 1024
898        );
899    }
900    output.flush().context("flush extracted Moli executable")?;
901    Ok(())
902}
903
904fn set_executable(path: &Path) -> Result<()> {
905    #[cfg(unix)]
906    {
907        use std::os::unix::fs::PermissionsExt;
908        let mut permissions = std::fs::metadata(path)?.permissions();
909        permissions.set_mode(0o755);
910        std::fs::set_permissions(path, permissions)?;
911    }
912    #[cfg(not(unix))]
913    let _ = path;
914    Ok(())
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use std::sync::Arc;
921    use tempfile::TempDir;
922    use wiremock::matchers::method;
923    use wiremock::{Mock, MockServer, ResponseTemplate};
924
925    fn test_config(cache: &TempDir, version: &str, sha256: &str) -> HeadlessConfig {
926        HeadlessConfig {
927            browser_path: None,
928            auto_download_moli: true,
929            moli_version: Some(version.to_string()),
930            moli_sha256: Some(sha256.to_string()),
931            moli_cache_dir: Some(cache.path().join("moli")),
932            ..HeadlessConfig::default()
933        }
934    }
935
936    fn fixture_archive() -> (Vec<u8>, &'static str) {
937        let mut bytes: Vec<u8> = Vec::new();
938        #[cfg(not(windows))]
939        {
940            let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::fast());
941            let mut builder = tar::Builder::new(encoder);
942            let content = b"#!/bin/sh\nprintf '<html></html>\\n'\n";
943            let mut header = tar::Header::new_gnu();
944            header.set_path("moli-v-test-target/moli").unwrap();
945            header.set_size(content.len() as u64);
946            header.set_mode(0o755);
947            header.set_cksum();
948            builder.append(&header, &content[..]).unwrap();
949            let encoder = builder.into_inner().unwrap();
950            encoder.finish().unwrap();
951            (bytes, "tar.gz")
952        }
953        #[cfg(windows)]
954        {
955            let cursor = std::io::Cursor::new(Vec::new());
956            let mut archive = zip::ZipWriter::new(cursor);
957            let options = zip::write::FileOptions::default();
958            archive
959                .start_file("moli-v-test-target/moli.exe", options)
960                .unwrap();
961            archive.write_all(b"fixture moli").unwrap();
962            (archive.finish().unwrap().into_inner(), "zip")
963        }
964    }
965
966    fn fixture_digest(bytes: &[u8]) -> String {
967        format!("{:x}", Sha256::digest(bytes))
968    }
969
970    #[test]
971    fn embedded_manifest_has_supported_default_asset() {
972        manifest::_manifest_resource_is_parseable().expect("embedded Moli manifest");
973        let target = current_target().expect("tests run on a supported release target");
974        let asset = asset_for(target).expect("manifest asset");
975        assert_eq!(default_moli_version(), DEFAULT_MOLI_VERSION);
976        assert_eq!(asset.sha256.len(), 64);
977        assert!(asset.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()));
978    }
979
980    #[test]
981    fn custom_version_requires_a_digest() {
982        let config = HeadlessConfig {
983            moli_version: Some("9.9.9".to_string()),
984            moli_sha256: None,
985            ..HeadlessConfig::default()
986        };
987        let error = resolve_release(&config, current_target().unwrap()).unwrap_err();
988        assert!(error.to_string().contains("moli_sha256"));
989    }
990
991    #[test]
992    fn relative_cache_directory_is_rejected() {
993        let config = HeadlessConfig {
994            moli_cache_dir: Some(PathBuf::from("relative/moli")),
995            ..HeadlessConfig::default()
996        };
997        let error = cache_root(&config).unwrap_err();
998        assert!(error.to_string().contains("absolute"));
999    }
1000
1001    #[test]
1002    fn default_configs_share_one_cache_root() {
1003        let first = cache_root(&HeadlessConfig::default()).expect("default cache root");
1004        let second = cache_root(&HeadlessConfig::default()).expect("default cache root");
1005        assert_eq!(
1006            first, second,
1007            "all Code processes must converge on one cache"
1008        );
1009    }
1010
1011    #[cfg(unix)]
1012    #[tokio::test]
1013    async fn symlinked_cache_root_is_rejected() {
1014        let parent = tempfile::tempdir().unwrap();
1015        let real = parent.path().join("real");
1016        let link = parent.path().join("moli");
1017        std::fs::create_dir(&real).unwrap();
1018        std::os::unix::fs::symlink(&real, &link).unwrap();
1019        let error = prepare_cache_layout(&link, "1.1.1", "test-target")
1020            .await
1021            .unwrap_err();
1022        assert!(error.to_string().contains("real directory"));
1023    }
1024
1025    #[test]
1026    fn traversal_member_is_not_accepted() {
1027        assert!(validate_member_name(Path::new("../../moli")).is_err());
1028        let valid_member = if cfg!(windows) {
1029            Path::new("moli-v1/moli.exe")
1030        } else {
1031            Path::new("moli-v1/moli")
1032        };
1033        assert_eq!(
1034            validate_member_name(valid_member).unwrap(),
1035            Some(manifest::executable_name())
1036        );
1037    }
1038
1039    #[test]
1040    fn lock_contention_recognizes_platform_error() {
1041        assert!(is_lock_contended(&fs2::lock_contended_error()));
1042        assert!(is_lock_contended(&std::io::Error::from(
1043            std::io::ErrorKind::WouldBlock
1044        )));
1045        assert!(!is_lock_contended(&std::io::Error::other(
1046            "unrelated installation error"
1047        )));
1048    }
1049
1050    #[tokio::test]
1051    async fn downloads_verifies_and_reuses_the_atomic_cache() {
1052        let server = MockServer::start().await;
1053        let (archive, format) = fixture_archive();
1054        let digest = fixture_digest(&archive);
1055        let target = current_target().unwrap();
1056        let asset_name = format!(
1057            "moli-{target}.{}",
1058            if format == "zip" { "zip" } else { "tar.gz" }
1059        );
1060        Mock::given(method("GET"))
1061            .and(wiremock::matchers::path(format!("/{asset_name}")))
1062            .respond_with(ResponseTemplate::new(200).set_body_bytes(archive.clone()))
1063            .expect(1)
1064            .mount(&server)
1065            .await;
1066        let cache = tempfile::tempdir().unwrap();
1067        let config = test_config(&cache, "9.9.9", &digest);
1068        let first = ensure_moli_from(&config, Duration::from_secs(10), Some(&server.uri()), true)
1069            .await
1070            .unwrap();
1071        assert!(is_executable(&first));
1072        assert_eq!(
1073            tokio::fs::read(&first).await.unwrap(),
1074            if cfg!(windows) {
1075                b"fixture moli".to_vec()
1076            } else {
1077                b"#!/bin/sh\nprintf '<html></html>\\n'\n".to_vec()
1078            }
1079        );
1080        let second = ensure_moli_from(&config, Duration::from_secs(10), Some(&server.uri()), true)
1081            .await
1082            .unwrap();
1083        assert_eq!(first, second);
1084        server.verify().await;
1085    }
1086
1087    #[tokio::test]
1088    async fn concurrent_first_use_downloads_once() {
1089        let server = MockServer::start().await;
1090        let (archive, format) = fixture_archive();
1091        let digest = fixture_digest(&archive);
1092        let target = current_target().unwrap();
1093        let asset_name = format!(
1094            "moli-{target}.{}",
1095            if format == "zip" { "zip" } else { "tar.gz" }
1096        );
1097        Mock::given(method("GET"))
1098            .and(wiremock::matchers::path(format!("/{asset_name}")))
1099            .respond_with(ResponseTemplate::new(200).set_body_bytes(archive))
1100            .expect(1)
1101            .mount(&server)
1102            .await;
1103        let cache = tempfile::tempdir().unwrap();
1104        let config = Arc::new(test_config(&cache, "9.9.8", &digest));
1105        let mut tasks = Vec::new();
1106        for _ in 0..4 {
1107            let config = Arc::clone(&config);
1108            let base = server.uri();
1109            tasks.push(tokio::spawn(async move {
1110                ensure_moli_from(&config, Duration::from_secs(10), Some(&base), true).await
1111            }));
1112        }
1113        for task in tasks {
1114            assert!(task.await.unwrap().is_ok());
1115        }
1116        server.verify().await;
1117    }
1118}