1use std::path::{Component, Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::dirs::{create_dir_all, Dirs, InstallLocator};
10use crate::error::{Error, Result};
11use crate::lock::FileLock;
12use crate::store::link::LinkMode;
13use crate::store::Cas;
14use crate::verification::{GithubAttestation, VerificationEvidence};
15use crate::version::ToolVersion;
16
17pub mod download;
18pub mod extract;
19pub mod verify;
20
21pub use extract::ArchiveKind;
22pub use verify::HashAlgo;
23
24pub struct InstallPlan {
26 pub tool: String,
27 pub version: String,
28 pub urls: Vec<String>,
31 pub file_name: String,
33 pub kind: ArchiveKind,
34 pub checksum: Option<Checksum>,
36 pub strip_root: bool,
38 pub subdir: Option<PathBuf>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Checksum {
44 pub algo: HashAlgo,
45 pub hex: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ArtifactReceipt {
50 pub url: String,
51 pub file_name: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub checksum: Option<String>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub evidence: Vec<VerificationEvidence>,
56}
57
58const ARTIFACT_RECEIPT_FILE: &str = ".osdk-artifact.json";
59const ARTIFACT_RECEIPT_MAX_BYTES: u64 = 64 * 1024;
60pub const LOCKED_ARTIFACT_URL_OPTION: &str = "__osdk_artifact_url";
61pub const LOCKED_ARTIFACT_FILE_OPTION: &str = "__osdk_artifact_file";
62pub const LOCKED_ARTIFACT_CHECKSUM_OPTION: &str = "__osdk_artifact_checksum";
63pub const LOCKED_ARTIFACT_SUBDIR_OPTION: &str = "__osdk_artifact_subdir";
64
65pub struct PipelineCtx<'a> {
67 pub client: &'a reqwest::Client,
68 pub dirs: &'a Dirs,
69 pub cas: &'a Cas,
70 pub link_mode: LinkMode,
71 pub show_progress: bool,
72 pub offline: bool,
73 pub require_checksums: bool,
74}
75
76pub fn locked_install_plan(
77 tool: &str,
78 version: &ToolVersion,
79 strip_root: bool,
80) -> Result<Option<InstallPlan>> {
81 let Some(artifact) = locked_artifact(version)? else {
82 return Ok(None);
83 };
84 Ok(Some(InstallPlan {
85 tool: tool.to_string(),
86 version: version.version.clone(),
87 urls: vec![artifact.url],
88 kind: ArchiveKind::from_name(&artifact.file_name)?,
89 file_name: artifact.file_name,
90 checksum: artifact
91 .checksum
92 .as_deref()
93 .map(parse_checksum)
94 .transpose()?,
95 strip_root,
96 subdir: version
97 .options
98 .get(LOCKED_ARTIFACT_SUBDIR_OPTION)
99 .map(PathBuf::from),
100 }))
101}
102
103pub fn locked_artifact(version: &ToolVersion) -> Result<Option<ArtifactReceipt>> {
104 let Some(url) = version.options.get(LOCKED_ARTIFACT_URL_OPTION) else {
105 return Ok(None);
106 };
107 let file_name = version
108 .options
109 .get(LOCKED_ARTIFACT_FILE_OPTION)
110 .ok_or_else(|| Error::other("locked artifact is missing its file name"))?
111 .clone();
112 Ok(Some(ArtifactReceipt {
113 url: url.clone(),
114 file_name,
115 checksum: version
116 .options
117 .get(LOCKED_ARTIFACT_CHECKSUM_OPTION)
118 .cloned(),
119 evidence: Vec::new(),
120 }))
121}
122
123const COMPLETE_MARKER: &str = ".osdk-complete";
125
126pub async fn run(plan: &InstallPlan, ctx: &PipelineCtx<'_>) -> Result<PathBuf> {
128 run_with_attestation(plan, ctx, None).await
129}
130
131pub async fn run_with_attestation(
132 plan: &InstallPlan,
133 ctx: &PipelineCtx<'_>,
134 attestation: Option<&GithubAttestation>,
135) -> Result<PathBuf> {
136 run_with_attestation_inner(plan, ctx, attestation, true).await
137}
138
139pub(crate) async fn run_with_attestation_unfinalized_at(
142 plan: &InstallPlan,
143 ctx: &PipelineCtx<'_>,
144 attestation: Option<&GithubAttestation>,
145 locator: &InstallLocator,
146) -> Result<PathBuf> {
147 run_with_attestation_inner_at(plan, ctx, attestation, false, Some(locator)).await
148}
149
150async fn run_with_attestation_inner(
151 plan: &InstallPlan,
152 ctx: &PipelineCtx<'_>,
153 attestation: Option<&GithubAttestation>,
154 mark_complete: bool,
155) -> Result<PathBuf> {
156 run_with_attestation_inner_at(plan, ctx, attestation, mark_complete, None).await
157}
158
159async fn run_with_attestation_inner_at(
160 plan: &InstallPlan,
161 ctx: &PipelineCtx<'_>,
162 attestation: Option<&GithubAttestation>,
163 mark_complete: bool,
164 locator: Option<&InstallLocator>,
165) -> Result<PathBuf> {
166 let install_dir = locator
167 .map(|locator| locator.install_root().to_path_buf())
168 .unwrap_or_else(|| ctx.dirs.install_path(&plan.tool, &plan.version));
169 let archive_path = match locator {
170 Some(locator) => artifact_cache_path_for_locator(ctx.dirs, locator, &plan.file_name)?,
171 None => artifact_cache_path(ctx.dirs, &plan.tool, &plan.version, &plan.file_name)?,
172 };
173
174 let _lock = if locator.is_none() {
178 Some(FileLock::acquire(install_lock_path(
179 ctx.dirs,
180 &plan.tool,
181 &plan.version,
182 ))?)
183 } else {
184 None
185 };
186
187 if install_dir.join(COMPLETE_MARKER).exists() {
189 if let Some(attestation) = attestation {
190 let evidence = crate::verification::verify_github_attestation(
191 ctx.client,
192 ctx.dirs,
193 ctx.offline,
194 &archive_path,
195 attestation,
196 )
197 .await?;
198 merge_artifact_evidence(&install_dir, evidence)?;
199 }
200 return Ok(install_dir);
201 }
202 if install_dir.exists() {
204 let _ = std::fs::remove_dir_all(&install_dir);
205 }
206
207 let label = format!("{}@{}", plan.tool, plan.version);
209 if ctx.offline && !archive_path.exists() {
210 return Err(Error::other(format!(
211 "offline artifact cache miss for {}@{}",
212 plan.tool, plan.version
213 )));
214 }
215 let mut selected_url =
216 read_cached_source_url(&archive_path).or_else(|| plan.urls.first().cloned());
217 if !archive_path.exists() {
218 let mut last_err: Option<Error> = None;
219 let mut downloaded = false;
220 for (i, url) in plan.urls.iter().enumerate() {
221 match download::download(ctx.client, url, &archive_path, &label, ctx.show_progress)
222 .await
223 {
224 Ok(()) => {
225 downloaded = true;
226 selected_url = Some(url.clone());
227 write_cached_source_url(&archive_path, url);
228 break;
229 }
230 Err(e) => {
231 tracing::warn!(
232 url = %url,
233 attempt = i + 1,
234 total = plan.urls.len(),
235 "{}",
236 crate::i18n::trf("log.download_failover", &[("err", &e.to_string())])
237 );
238 last_err = Some(e);
239 }
240 }
241 }
242 if !downloaded {
243 return Err(last_err.unwrap_or_else(|| Error::NoUsableSource {
244 tool: plan.tool.clone(),
245 tried: plan.urls.len(),
246 }));
247 }
248 }
249
250 let persisted_checksum = if plan.checksum.is_none() {
254 read_cached_checksum(&archive_path)
255 } else {
256 None
257 };
258 let verified_checksum = plan.checksum.as_ref().or(persisted_checksum.as_ref());
259 if let Some(cs) = verified_checksum {
260 verify::verify_file(&archive_path, &cs.hex, cs.algo, &plan.file_name)?;
261 write_cached_checksum(&archive_path, cs);
262 tracing::info!(file = %plan.file_name, "{}", crate::i18n::tr("log.checksum_verified"));
263 } else {
264 tracing::debug!(file = %plan.file_name, "no checksum available; skipping verification");
265 }
266
267 let evidence = if let Some(attestation) = attestation {
268 crate::verification::verify_github_attestation(
269 ctx.client,
270 ctx.dirs,
271 ctx.offline,
272 &archive_path,
273 attestation,
274 )
275 .await?
276 .into_iter()
277 .collect()
278 } else {
279 Vec::new()
280 };
281 let authenticated_checksum = evidence
282 .first()
283 .map(|item| parse_checksum(&item.digest))
284 .transpose()?;
285 if ctx.require_checksums && verified_checksum.is_none() && authenticated_checksum.is_none() {
286 return Err(Error::other(format!(
287 "checksum required but unavailable for {}@{} ({})",
288 plan.tool, plan.version, plan.file_name
289 )));
290 }
291 if verified_checksum.is_none() {
292 if let Some(checksum) = authenticated_checksum.as_ref() {
293 write_cached_checksum(&archive_path, checksum);
294 }
295 }
296
297 let scratch = locator
299 .map(|locator| locator.scratch_root().join(std::process::id().to_string()))
300 .unwrap_or_else(|| scratch_path(ctx.dirs, &plan.tool, &plan.version));
301 if scratch.exists() {
302 let _ = std::fs::remove_dir_all(&scratch);
303 }
304 create_dir_all(&scratch)?;
305 extract::extract(&archive_path, &scratch, plan.kind, plan.strip_root)?;
306 let materialize_root = match plan.subdir.as_deref() {
307 Some(subdir) => safe_subdir(&scratch, subdir)?,
308 None => scratch.clone(),
309 };
310
311 let report = ctx.cas.ingest_tree(
313 &materialize_root,
314 &install_dir,
315 &plan.tool,
316 &plan.version,
317 ctx.link_mode,
318 )?;
319 let _ = std::fs::remove_dir_all(&scratch);
320
321 write_artifact_receipt(
323 &install_dir,
324 &ArtifactReceipt {
325 url: selected_url.unwrap_or_default(),
326 file_name: plan.file_name.clone(),
327 checksum: verified_checksum
328 .map(format_checksum)
329 .or_else(|| authenticated_checksum.as_ref().map(format_checksum)),
330 evidence,
331 },
332 )?;
333 if mark_complete {
334 std::fs::write(install_dir.join(COMPLETE_MARKER), b"")
335 .map_err(|e| Error::io(install_dir.join(COMPLETE_MARKER), e))?;
336 }
337
338 tracing::debug!(
339 tool = %plan.tool,
340 version = %plan.version,
341 files = report.files_written,
342 new_objects = report.objects_new,
343 "install materialized"
344 );
345
346 Ok(install_dir)
347}
348
349fn scratch_path(dirs: &Dirs, tool: &str, version: &str) -> PathBuf {
350 dirs.tmp()
351 .join(crate::dirs::sanitize_tool_id(tool))
352 .join(format!(
353 "{}-{}",
354 crate::dirs::sanitize_version_component(version),
355 std::process::id()
356 ))
357}
358
359fn install_lock_path(dirs: &Dirs, tool: &str, version: &str) -> PathBuf {
360 dirs.lock_dir(tool).join(format!(
361 "{}.lock",
362 crate::dirs::sanitize_version_component(version)
363 ))
364}
365
366fn safe_subdir(root: &std::path::Path, subdir: &std::path::Path) -> Result<PathBuf> {
367 if subdir.is_absolute()
368 || subdir
369 .components()
370 .any(|component| !matches!(component, std::path::Component::Normal(_)))
371 {
372 return Err(Error::config(format!(
373 "unsafe archive subdirectory `{}`",
374 subdir.display()
375 )));
376 }
377 let selected = root.join(subdir);
378 if !selected.is_dir() {
379 return Err(Error::other(format!(
380 "archive subdirectory does not exist: {}",
381 subdir.display()
382 )));
383 }
384 Ok(selected)
385}
386
387pub fn is_installed(dirs: &Dirs, tool: &str, version: &str) -> bool {
389 dirs.install_path(tool, version)
390 .join(COMPLETE_MARKER)
391 .exists()
392}
393
394pub fn artifact_receipt(dirs: &Dirs, tool: &str, version: &str) -> Option<ArtifactReceipt> {
395 artifact_receipt_at(&dirs.install_path(tool, version))
396}
397
398pub fn artifact_receipt_at(install_root: &Path) -> Option<ArtifactReceipt> {
399 let bytes = crate::inventory::read_stable_regular_file(
400 &install_root.join(ARTIFACT_RECEIPT_FILE),
401 ARTIFACT_RECEIPT_MAX_BYTES,
402 )
403 .ok()?;
404 serde_json::from_slice(&bytes).ok()
405}
406
407#[allow(clippy::too_many_arguments)]
411pub async fn install_single_binary(
412 client: &reqwest::Client,
413 dirs: &Dirs,
414 tool: &str,
415 version: &str,
416 urls: &[String],
417 exe_name: &str,
418 download_name: &str,
419 os: crate::platform::Os,
420 checksum: Option<&Checksum>,
421 show_progress: bool,
422 offline: bool,
423 require_checksums: bool,
424 attestation: Option<&GithubAttestation>,
425) -> Result<()> {
426 install_single_binary_inner(
427 client,
428 dirs,
429 tool,
430 version,
431 urls,
432 exe_name,
433 download_name,
434 os,
435 checksum,
436 show_progress,
437 offline,
438 require_checksums,
439 attestation,
440 true,
441 )
442 .await
443}
444
445#[allow(clippy::too_many_arguments)]
448pub(crate) async fn install_single_binary_unfinalized_at(
449 client: &reqwest::Client,
450 dirs: &Dirs,
451 locator: &InstallLocator,
452 urls: &[String],
453 exe_name: &str,
454 download_name: &str,
455 os: crate::platform::Os,
456 checksum: Option<&Checksum>,
457 show_progress: bool,
458 offline: bool,
459 require_checksums: bool,
460 attestation: Option<&GithubAttestation>,
461) -> Result<()> {
462 let identity = locator.identity();
463 install_single_binary_inner_at(
464 client,
465 dirs,
466 &identity.tool,
467 &identity.version,
468 urls,
469 exe_name,
470 download_name,
471 os,
472 checksum,
473 show_progress,
474 offline,
475 require_checksums,
476 attestation,
477 false,
478 Some(locator),
479 )
480 .await
481}
482
483#[allow(clippy::too_many_arguments)]
484async fn install_single_binary_inner(
485 client: &reqwest::Client,
486 dirs: &Dirs,
487 tool: &str,
488 version: &str,
489 urls: &[String],
490 exe_name: &str,
491 download_name: &str,
492 os: crate::platform::Os,
493 checksum: Option<&Checksum>,
494 show_progress: bool,
495 offline: bool,
496 require_checksums: bool,
497 attestation: Option<&GithubAttestation>,
498 mark_complete: bool,
499) -> Result<()> {
500 install_single_binary_inner_at(
501 client,
502 dirs,
503 tool,
504 version,
505 urls,
506 exe_name,
507 download_name,
508 os,
509 checksum,
510 show_progress,
511 offline,
512 require_checksums,
513 attestation,
514 mark_complete,
515 None,
516 )
517 .await
518}
519
520#[allow(clippy::too_many_arguments)]
521async fn install_single_binary_inner_at(
522 client: &reqwest::Client,
523 dirs: &Dirs,
524 tool: &str,
525 version: &str,
526 urls: &[String],
527 exe_name: &str,
528 download_name: &str,
529 os: crate::platform::Os,
530 checksum: Option<&Checksum>,
531 show_progress: bool,
532 offline: bool,
533 require_checksums: bool,
534 attestation: Option<&GithubAttestation>,
535 mark_complete: bool,
536 locator: Option<&InstallLocator>,
537) -> Result<()> {
538 let install_dir = locator
539 .map(|locator| locator.install_root().to_path_buf())
540 .unwrap_or_else(|| dirs.install_path(tool, version));
541 validate_safe_filename("executable name", exe_name)?;
542 let cached = match locator {
543 Some(locator) => artifact_cache_path_for_locator(dirs, locator, download_name)?,
544 None => artifact_cache_path(dirs, tool, version, download_name)?,
545 };
546 if install_dir.join(COMPLETE_MARKER).exists() {
547 if let Some(attestation) = attestation {
548 let evidence = crate::verification::verify_github_attestation(
549 client,
550 dirs,
551 offline,
552 &cached,
553 attestation,
554 )
555 .await?;
556 merge_artifact_evidence(&install_dir, evidence)?;
557 }
558 return Ok(());
559 }
560 if install_dir.exists() {
561 let _ = std::fs::remove_dir_all(&install_dir);
562 }
563 let bin_dir = install_dir.join("bin");
564 create_dir_all(&bin_dir)?;
565
566 if offline && !cached.exists() {
567 return Err(Error::other(format!(
568 "offline artifact cache miss for {tool}@{version}"
569 )));
570 }
571 if !offline {
572 let _ = std::fs::remove_file(&cached);
573 }
574 let mut last_err: Option<Error> = None;
575 let mut ok = false;
576 let mut selected_url = read_cached_source_url(&cached).or_else(|| urls.first().cloned());
577 if cached.exists() {
578 ok = true;
579 } else {
580 for (i, url) in urls.iter().enumerate() {
581 match download::download(
582 client,
583 url,
584 &cached,
585 &format!("{tool}@{version}"),
586 show_progress,
587 )
588 .await
589 {
590 Ok(()) => {
591 ok = true;
592 selected_url = Some(url.clone());
593 write_cached_source_url(&cached, url);
594 break;
595 }
596 Err(e) => {
597 tracing::warn!(
598 url = %url,
599 attempt = i + 1,
600 total = urls.len(),
601 "{}",
602 crate::i18n::trf("log.binary_download_failed", &[("err", &e.to_string())])
603 );
604 last_err = Some(e);
605 }
606 }
607 }
608 }
609 if !ok {
610 return Err(last_err.unwrap_or_else(|| Error::NoUsableSource {
611 tool: tool.to_string(),
612 tried: urls.len(),
613 }));
614 }
615
616 let persisted_checksum = if checksum.is_none() {
617 read_cached_checksum(&cached)
618 } else {
619 None
620 };
621 let verified_checksum = checksum.or(persisted_checksum.as_ref());
622 if let Some(cs) = verified_checksum {
623 verify::verify_file(&cached, &cs.hex, cs.algo, download_name)?;
624 write_cached_checksum(&cached, cs);
625 tracing::info!(file = %download_name, "{}", crate::i18n::tr("log.checksum_verified"));
626 }
627 let evidence = if let Some(attestation) = attestation {
628 crate::verification::verify_github_attestation(client, dirs, offline, &cached, attestation)
629 .await?
630 .into_iter()
631 .collect()
632 } else {
633 Vec::new()
634 };
635 let authenticated_checksum = evidence
636 .first()
637 .map(|item| parse_checksum(&item.digest))
638 .transpose()?;
639 if require_checksums && verified_checksum.is_none() && authenticated_checksum.is_none() {
640 return Err(Error::other(format!(
641 "checksum required but unavailable for {tool}@{version} ({download_name})"
642 )));
643 }
644 if verified_checksum.is_none() {
645 if let Some(checksum) = authenticated_checksum.as_ref() {
646 write_cached_checksum(&cached, checksum);
647 }
648 }
649
650 let exe_suffix = os.exe_suffix();
651 let dest = bin_dir.join(format!("{exe_name}{exe_suffix}"));
652 std::fs::copy(&cached, &dest).map_err(|e| Error::io(&dest, e))?;
653 #[cfg(unix)]
654 {
655 use std::os::unix::fs::PermissionsExt;
656 let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));
657 }
658
659 write_artifact_receipt(
660 &install_dir,
661 &ArtifactReceipt {
662 url: selected_url.unwrap_or_default(),
663 file_name: download_name.to_string(),
664 checksum: verified_checksum
665 .map(format_checksum)
666 .or_else(|| authenticated_checksum.as_ref().map(format_checksum)),
667 evidence,
668 },
669 )?;
670 if mark_complete {
671 std::fs::write(install_dir.join(COMPLETE_MARKER), b"")
672 .map_err(|e| Error::io(install_dir.join(COMPLETE_MARKER), e))?;
673 }
674 Ok(())
675}
676
677fn write_artifact_receipt(install_dir: &std::path::Path, receipt: &ArtifactReceipt) -> Result<()> {
678 let path = install_dir.join(ARTIFACT_RECEIPT_FILE);
679 let bytes = serde_json::to_vec_pretty(receipt)?;
680 std::fs::write(&path, bytes).map_err(|error| Error::io(path, error))
681}
682
683fn merge_artifact_evidence(
684 install_dir: &std::path::Path,
685 evidence: Option<VerificationEvidence>,
686) -> Result<()> {
687 let Some(evidence) = evidence else {
688 return Ok(());
689 };
690 let path = install_dir.join(ARTIFACT_RECEIPT_FILE);
691 let mut receipt = artifact_receipt_at(install_dir).ok_or_else(|| {
692 Error::other(format!(
693 "artifact receipt is missing or invalid at {}",
694 path.display()
695 ))
696 })?;
697 if !receipt.evidence.contains(&evidence) {
698 receipt.evidence.push(evidence);
699 write_artifact_receipt(install_dir, &receipt)?;
700 }
701 Ok(())
702}
703
704pub fn artifact_cache_path(
705 dirs: &Dirs,
706 tool: &str,
707 version: &str,
708 file_name: &str,
709) -> Result<PathBuf> {
710 validate_safe_filename("artifact file name", file_name)?;
711 Ok(dirs
712 .downloads()
713 .join(crate::dirs::sanitize_tool_id(tool))
714 .join(crate::dirs::sanitize_version_component(version))
715 .join(file_name))
716}
717
718pub(crate) fn artifact_cache_path_for_locator(
722 dirs: &Dirs,
723 locator: &InstallLocator,
724 file_name: &str,
725) -> Result<PathBuf> {
726 validate_safe_filename("artifact file name", file_name)?;
727 let identity = locator.identity();
728 Ok(dirs
729 .downloads()
730 .join(crate::dirs::sanitize_tool_id(&identity.tool))
731 .join(crate::dirs::sanitize_version_component(&identity.version))
732 .join(crate::dirs::install_id_component(&identity.install_id)?)
733 .join(file_name))
734}
735
736pub fn dynamic_artifact_cache_path(
740 dirs: &Dirs,
741 locator: &InstallLocator,
742 file_name: &str,
743) -> Result<PathBuf> {
744 artifact_cache_path_for_locator(dirs, locator, file_name)
745}
746
747pub fn validate_safe_filename(label: &str, value: &str) -> Result<()> {
751 let path = Path::new(value);
752 if value.is_empty()
753 || value.contains(['/', '\\', ':'])
754 || path.components().count() != 1
755 || !matches!(path.components().next(), Some(Component::Normal(_)))
756 {
757 return Err(Error::config(format!("unsafe {label} `{value}`")));
758 }
759 Ok(())
760}
761
762fn format_checksum(checksum: &Checksum) -> String {
763 let algorithm = match checksum.algo {
764 HashAlgo::Sha256 => "sha256",
765 HashAlgo::Sha512 => "sha512",
766 HashAlgo::Blake3 => "blake3",
767 };
768 format!("{algorithm}:{}", checksum.hex)
769}
770
771pub fn parse_checksum(value: &str) -> Result<Checksum> {
772 let (algorithm, hex) = value
773 .split_once(':')
774 .ok_or_else(|| Error::other(format!("invalid locked checksum `{value}`")))?;
775 let algo = match algorithm {
776 "sha256" => HashAlgo::Sha256,
777 "sha512" => HashAlgo::Sha512,
778 "blake3" => HashAlgo::Blake3,
779 _ => {
780 return Err(Error::other(format!(
781 "unsupported locked checksum `{algorithm}`"
782 )));
783 }
784 };
785 Ok(Checksum {
786 algo,
787 hex: hex.to_string(),
788 })
789}
790
791fn checksum_cache_path(archive: &std::path::Path) -> PathBuf {
792 let mut path = archive.as_os_str().to_os_string();
793 path.push(".checksum");
794 PathBuf::from(path)
795}
796
797fn source_url_cache_path(archive: &std::path::Path) -> PathBuf {
798 let mut path = archive.as_os_str().to_os_string();
799 path.push(".source-url");
800 PathBuf::from(path)
801}
802
803fn write_cached_source_url(archive: &std::path::Path, url: &str) {
804 let _ = std::fs::write(source_url_cache_path(archive), url);
805}
806
807fn read_cached_source_url(archive: &std::path::Path) -> Option<String> {
808 let value = std::fs::read_to_string(source_url_cache_path(archive)).ok()?;
809 let value = value.trim();
810 (!value.is_empty()).then(|| value.to_string())
811}
812
813fn write_cached_checksum(archive: &std::path::Path, checksum: &Checksum) {
814 let algorithm = match checksum.algo {
815 HashAlgo::Sha256 => "sha256",
816 HashAlgo::Sha512 => "sha512",
817 HashAlgo::Blake3 => "blake3",
818 };
819 let _ = std::fs::write(
820 checksum_cache_path(archive),
821 format!("{algorithm} {}\n", checksum.hex),
822 );
823}
824
825fn read_cached_checksum(archive: &std::path::Path) -> Option<Checksum> {
826 let value = std::fs::read_to_string(checksum_cache_path(archive)).ok()?;
827 let (algorithm, hex) = value.trim().split_once(' ')?;
828 let algo = match algorithm {
829 "sha256" => HashAlgo::Sha256,
830 "sha512" => HashAlgo::Sha512,
831 "blake3" => HashAlgo::Blake3,
832 _ => return None,
833 };
834 Some(Checksum {
835 algo,
836 hex: hex.to_string(),
837 })
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn scratch_paths_sanitize_namespaced_tool_ids() {
846 let temporary = tempfile::tempdir().unwrap();
847 let dirs = Dirs::resolve_from(|key| match key {
848 "OSDK_DATA_DIR" => Some(temporary.path().join("data").display().to_string()),
849 "OSDK_CACHE_DIR" => Some(temporary.path().join("cache").display().to_string()),
850 "OSDK_CONFIG_DIR" => Some(temporary.path().join("config").display().to_string()),
851 _ => None,
852 })
853 .unwrap();
854
855 assert_eq!(
856 scratch_path(&dirs, "github:example/tool", "1.2.3"),
857 dirs.tmp()
858 .join("github")
859 .join("example")
860 .join("tool")
861 .join(format!("1.2.3-{}", std::process::id()))
862 );
863 }
864
865 #[test]
866 fn version_derived_scratch_and_lock_paths_stay_under_managed_roots() {
867 let temporary = tempfile::tempdir().unwrap();
868 let dirs = Dirs::resolve_from(|key| match key {
869 "OSDK_DATA_DIR" => Some(temporary.path().join("data").display().to_string()),
870 "OSDK_CACHE_DIR" => Some(temporary.path().join("cache").display().to_string()),
871 "OSDK_CONFIG_DIR" => Some(temporary.path().join("config").display().to_string()),
872 _ => None,
873 })
874 .unwrap();
875
876 for version in ["../../outside", "/outside", r"..\..\outside"] {
877 assert!(scratch_path(&dirs, "tool", version).starts_with(dirs.tmp().join("tool")));
878 assert!(install_lock_path(&dirs, "tool", version).starts_with(dirs.lock_dir("tool")));
879 }
880
881 for (left, right) in [
882 ("release/2026", "release_2026"),
883 (r"release\2026", "release_2026"),
884 ("release%2F2026", "release/2026"),
885 ] {
886 assert_ne!(
887 dirs.install_path("tool", left),
888 dirs.install_path("tool", right)
889 );
890 assert_ne!(
891 scratch_path(&dirs, "tool", left),
892 scratch_path(&dirs, "tool", right)
893 );
894 assert_ne!(
895 install_lock_path(&dirs, "tool", left),
896 install_lock_path(&dirs, "tool", right)
897 );
898 assert_ne!(
899 artifact_cache_path(&dirs, "tool", left, "tool.tar.gz").unwrap(),
900 artifact_cache_path(&dirs, "tool", right, "tool.tar.gz").unwrap()
901 );
902 }
903 }
904
905 #[test]
906 fn artifact_cache_rejects_unsafe_filenames_without_touching_sentinels() {
907 let temporary = tempfile::tempdir().unwrap();
908 let dirs = Dirs::resolve_from(|key| match key {
909 "OSDK_DATA_DIR" => Some(temporary.path().join("data").display().to_string()),
910 "OSDK_CACHE_DIR" => Some(temporary.path().join("cache").display().to_string()),
911 "OSDK_CONFIG_DIR" => Some(temporary.path().join("config").display().to_string()),
912 _ => None,
913 })
914 .unwrap();
915 let sentinel = temporary.path().join("outside.tar.gz");
916 std::fs::write(&sentinel, b"keep").unwrap();
917
918 for name in [
919 "../../outside.tar.gz",
920 "/outside.tar.gz",
921 r"..\outside.tar.gz",
922 r"C:\outside.tar.gz",
923 ".",
924 "..",
925 ] {
926 assert!(artifact_cache_path(&dirs, "tool", "1.0.0", name).is_err());
927 }
928 assert_eq!(std::fs::read(&sentinel).unwrap(), b"keep");
929 assert!(artifact_cache_path(&dirs, "tool", "1.0.0", "tool.tar.gz")
930 .unwrap()
931 .starts_with(dirs.downloads()));
932 }
933
934 #[tokio::test]
935 async fn offline_install_uses_cached_archive() {
936 let temp = tempfile::tempdir().unwrap();
937 let dirs = Dirs::resolve_from(|key| match key {
938 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
939 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
940 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
941 _ => None,
942 })
943 .unwrap();
944 dirs.ensure().unwrap();
945 let archive = dirs
946 .downloads()
947 .join("fixture")
948 .join("1.0.0")
949 .join("fixture.tgz");
950 std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
951 {
952 let file = std::fs::File::create(&archive).unwrap();
953 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
954 let mut tar = tar::Builder::new(encoder);
955 let mut header = tar::Header::new_gnu();
956 let contents = b"offline";
957 header.set_size(contents.len() as u64);
958 header.set_mode(0o755);
959 header.set_cksum();
960 tar.append_data(&mut header, "package/bin/tool", &contents[..])
961 .unwrap();
962 tar.finish().unwrap();
963 }
964 let checksum = Checksum {
965 algo: HashAlgo::Sha256,
966 hex: verify::hash_file(&archive, HashAlgo::Sha256).unwrap(),
967 };
968 let cas = Cas::new(dirs.store.clone());
969 let plan = InstallPlan {
970 tool: "fixture".into(),
971 version: "1.0.0".into(),
972 urls: vec!["http://127.0.0.1:9/never-requested".into()],
973 file_name: "fixture.tgz".into(),
974 kind: ArchiveKind::TarGz,
975 checksum: Some(checksum),
976 strip_root: true,
977 subdir: None,
978 };
979 let client = reqwest::Client::new();
980 let ctx = PipelineCtx {
981 client: &client,
982 dirs: &dirs,
983 cas: &cas,
984 link_mode: LinkMode::Copy,
985 show_progress: false,
986 offline: true,
987 require_checksums: true,
988 };
989
990 let install = run(&plan, &ctx).await.unwrap();
991 assert_eq!(
992 std::fs::read_to_string(install.join("bin/tool")).unwrap(),
993 "offline"
994 );
995 assert!(install.join(COMPLETE_MARKER).is_file());
996 assert!(checksum_cache_path(&archive).is_file());
997 assert_eq!(
998 artifact_receipt(&dirs, "fixture", "1.0.0").unwrap(),
999 ArtifactReceipt {
1000 url: "http://127.0.0.1:9/never-requested".into(),
1001 file_name: "fixture.tgz".into(),
1002 checksum: Some(format!(
1003 "sha256:{}",
1004 verify::hash_file(&archive, HashAlgo::Sha256).unwrap()
1005 )),
1006 evidence: Vec::new(),
1007 }
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn required_checksum_rejects_unverified_archive() {
1013 let temp = tempfile::tempdir().unwrap();
1014 let dirs = Dirs::resolve_from(|key| match key {
1015 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
1016 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
1017 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
1018 _ => None,
1019 })
1020 .unwrap();
1021 dirs.ensure().unwrap();
1022 let archive = dirs
1023 .downloads()
1024 .join("fixture")
1025 .join("2.0.0")
1026 .join("fixture.tgz");
1027 std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
1028 std::fs::write(&archive, b"not-read-before-checksum-gate").unwrap();
1029 let cas = Cas::new(dirs.store.clone());
1030 let client = reqwest::Client::new();
1031 let plan = InstallPlan {
1032 tool: "fixture".into(),
1033 version: "2.0.0".into(),
1034 urls: vec!["http://127.0.0.1:9/never-requested".into()],
1035 file_name: "fixture.tgz".into(),
1036 kind: ArchiveKind::TarGz,
1037 checksum: None,
1038 strip_root: true,
1039 subdir: None,
1040 };
1041 let strict = PipelineCtx {
1042 client: &client,
1043 dirs: &dirs,
1044 cas: &cas,
1045 link_mode: LinkMode::Copy,
1046 show_progress: false,
1047 offline: true,
1048 require_checksums: true,
1049 };
1050 let error = run(&plan, &strict).await.unwrap_err();
1051 assert!(error.to_string().contains("checksum required"));
1052
1053 let permissive = PipelineCtx {
1054 require_checksums: false,
1055 ..strict
1056 };
1057 let error = run(&plan, &permissive).await.unwrap_err();
1058 assert!(!error.to_string().contains("checksum required"));
1059 }
1060
1061 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1062 async fn concurrent_install_commits_once_and_failure_never_marks_complete() {
1063 let temp = tempfile::tempdir().unwrap();
1064 let dirs = Dirs::resolve_from(|key| match key {
1065 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
1066 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
1067 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
1068 _ => None,
1069 })
1070 .unwrap();
1071 dirs.ensure().unwrap();
1072 let archive = artifact_cache_path(&dirs, "contract", "1.0.0", "contract.tgz").unwrap();
1073 std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
1074 {
1075 let file = std::fs::File::create(&archive).unwrap();
1076 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
1077 let mut builder = tar::Builder::new(encoder);
1078 let contents = b"contract";
1079 let mut header = tar::Header::new_gnu();
1080 header.set_size(contents.len() as u64);
1081 header.set_mode(0o755);
1082 header.set_cksum();
1083 builder
1084 .append_data(&mut header, "root/bin/tool", &contents[..])
1085 .unwrap();
1086 builder.finish().unwrap();
1087 }
1088 let checksum = verify::hash_file(&archive, HashAlgo::Sha256).unwrap();
1089 let plan = std::sync::Arc::new(InstallPlan {
1090 tool: "contract".into(),
1091 version: "1.0.0".into(),
1092 urls: vec!["https://invalid.example/contract.tgz".into()],
1093 file_name: "contract.tgz".into(),
1094 kind: ArchiveKind::TarGz,
1095 checksum: Some(Checksum {
1096 algo: HashAlgo::Sha256,
1097 hex: checksum,
1098 }),
1099 strip_root: true,
1100 subdir: None,
1101 });
1102 let client = reqwest::Client::new();
1103 let cas = std::sync::Arc::new(Cas::new(dirs.store.clone()));
1104 let mut handles = Vec::new();
1105 for _ in 0..2 {
1106 let plan = plan.clone();
1107 let dirs = dirs.clone();
1108 let client = client.clone();
1109 let cas = cas.clone();
1110 handles.push(tokio::spawn(async move {
1111 let context = PipelineCtx {
1112 client: &client,
1113 dirs: &dirs,
1114 cas: &cas,
1115 link_mode: LinkMode::Copy,
1116 show_progress: false,
1117 offline: true,
1118 require_checksums: true,
1119 };
1120 run(&plan, &context).await
1121 }));
1122 }
1123 for handle in handles {
1124 assert!(handle.await.unwrap().is_ok());
1125 }
1126 let install = dirs.install_path("contract", "1.0.0");
1127 assert!(install.join(COMPLETE_MARKER).is_file());
1128 assert_eq!(
1129 std::fs::read(install.join("bin/tool")).unwrap(),
1130 b"contract"
1131 );
1132
1133 let bad_archive = artifact_cache_path(&dirs, "contract", "2.0.0", "bad.tgz").unwrap();
1134 std::fs::create_dir_all(bad_archive.parent().unwrap()).unwrap();
1135 std::fs::write(&bad_archive, b"not an archive").unwrap();
1136 let bad = InstallPlan {
1137 tool: "contract".into(),
1138 version: "2.0.0".into(),
1139 urls: vec!["https://invalid.example/bad.tgz".into()],
1140 file_name: "bad.tgz".into(),
1141 kind: ArchiveKind::TarGz,
1142 checksum: Some(Checksum {
1143 algo: HashAlgo::Sha256,
1144 hex: verify::hash_file(&bad_archive, HashAlgo::Sha256).unwrap(),
1145 }),
1146 strip_root: true,
1147 subdir: None,
1148 };
1149 let context = PipelineCtx {
1150 client: &client,
1151 dirs: &dirs,
1152 cas: &cas,
1153 link_mode: LinkMode::Copy,
1154 show_progress: false,
1155 offline: true,
1156 require_checksums: true,
1157 };
1158 assert!(run(&bad, &context).await.is_err());
1159 assert!(!dirs
1160 .install_path("contract", "2.0.0")
1161 .join(COMPLETE_MARKER)
1162 .exists());
1163 }
1164
1165 #[test]
1166 fn corrupt_receipt_is_not_trusted_as_artifact_identity() {
1167 let temp = tempfile::tempdir().unwrap();
1168 let dirs = Dirs::resolve_from(|key| match key {
1169 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
1170 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
1171 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
1172 _ => None,
1173 })
1174 .unwrap();
1175 let install = dirs.install_path("tool", "1.0.0");
1176 std::fs::create_dir_all(&install).unwrap();
1177 std::fs::write(install.join(ARTIFACT_RECEIPT_FILE), b"{broken").unwrap();
1178 assert!(artifact_receipt(&dirs, "tool", "1.0.0").is_none());
1179 }
1180
1181 #[test]
1182 fn oversized_receipt_is_not_trusted_as_artifact_identity() {
1183 let temp = tempfile::tempdir().unwrap();
1184 let install = temp.path().join("install");
1185 std::fs::create_dir_all(&install).unwrap();
1186 std::fs::write(
1187 install.join(ARTIFACT_RECEIPT_FILE),
1188 vec![b'x'; (ARTIFACT_RECEIPT_MAX_BYTES + 1) as usize],
1189 )
1190 .unwrap();
1191 assert!(artifact_receipt_at(&install).is_none());
1192 }
1193
1194 #[cfg(unix)]
1195 #[test]
1196 fn symlinked_receipt_is_not_trusted_as_artifact_identity() {
1197 use std::os::unix::fs::symlink;
1198
1199 let temp = tempfile::tempdir().unwrap();
1200 let install = temp.path().join("install");
1201 std::fs::create_dir_all(&install).unwrap();
1202 let outside = temp.path().join("receipt.json");
1203 std::fs::write(
1204 &outside,
1205 br#"{"url":"https://example.test/tool","file_name":"tool","evidence":[]}"#,
1206 )
1207 .unwrap();
1208 symlink(&outside, install.join(ARTIFACT_RECEIPT_FILE)).unwrap();
1209 assert!(artifact_receipt_at(&install).is_none());
1210 }
1211}