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