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 MAX_INSTALL_RECEIPT_BYTES: usize = 16 * 1024;
32const LOCK_POLL: Duration = Duration::from_millis(50);
33
34pub const MOLI_RUNTIME_INFO_SCHEMA_V1: &str = "a3s-code/moli-runtime-info/v1";
36
37#[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
54pub 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
107pub fn default_moli_version() -> &'static str {
109 default_version()
110}
111
112pub async fn ensure_moli(config: &HeadlessConfig, timeout: Duration) -> Result<PathBuf> {
114 ensure_moli_from(config, timeout, None, false).await
115}
116
117pub 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 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 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 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
248async 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 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 .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}