1mod 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
33pub const MOLI_RUNTIME_INFO_SCHEMA_V1: &str = "a3s-code/moli-runtime-info/v1";
35
36#[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
53pub 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
106pub fn default_moli_version() -> &'static str {
108 default_version()
109}
110
111pub async fn ensure_moli(config: &HeadlessConfig, timeout: Duration) -> Result<PathBuf> {
113 ensure_moli_from(config, timeout, None, false).await
114}
115
116pub 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 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 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 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
247async 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 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 .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}