Skip to main content

osdk_core/pipeline/
mod.rs

1//! Install pipeline orchestrator: download → verify → extract → CAS ingest →
2//! materialize. Shared by all archive-based backends (node/go/python/java,
3//! standalone pnpm/yarn).
4
5use 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
24/// Everything a backend must supply to install one version via the pipeline.
25pub struct InstallPlan {
26    pub tool: String,
27    pub version: String,
28    /// Candidate archive URLs, best-first. The pipeline tries each until one
29    /// downloads successfully (source failover).
30    pub urls: Vec<String>,
31    /// Filename to save the archive as (used for kind detection + naming).
32    pub file_name: String,
33    pub kind: ArchiveKind,
34    /// Optional expected checksum for the archive.
35    pub checksum: Option<Checksum>,
36    /// Whether to strip a single top-level directory during extraction.
37    pub strip_root: bool,
38    /// Optional safe relative subdirectory to materialize after extraction.
39    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
65/// Context passed into the pipeline run.
66pub 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
123/// Marker file written at the install root once an install is complete.
124const COMPLETE_MARKER: &str = ".osdk-complete";
125
126/// Run the full pipeline for one plan. Returns the install directory.
127pub 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
139/// Materialize an archive and write its receipt at an identity-qualified root,
140/// leaving the completion marker to the dynamic backend finalizer.
141pub(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    // Fixed backends acquire the traditional tool/version lock here. Dynamic
175    // callers hold their locator lock across backend-specific post-processing
176    // and manifest publication, so reacquiring it here would self-deadlock.
177    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    // Idempotency: already installed and marked complete.
188    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    // Stale/partial dir from a previous failed run: clean it.
203    if install_dir.exists() {
204        let _ = std::fs::remove_dir_all(&install_dir);
205    }
206
207    // 1. Download to the shared downloads cache, trying candidate URLs in order.
208    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    // 2. Verify checksum if provided now or persisted from an earlier online
251    // install. This keeps offline reinstalls verifiable even when checksum
252    // discovery itself requires the network.
253    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    // 3. Extract into a scratch dir under the cache tmp.
298    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    // 4. Ingest into CAS + materialize into the install dir.
312    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    // 5. Finalize.
322    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
387/// Whether a tool@version is installed (complete marker present).
388pub 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/// Download a single executable into `<install>/bin/<exe_name>` and mark the
408/// install complete. Tries each URL in order (failover), optionally verifying a
409/// checksum. Used for standalone binaries (e.g. github: bare binaries).
410#[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/// Install a bare executable and its receipt at an identity-qualified root
446/// without publishing the completion marker.
447#[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
718/// Cache path for an identity-qualified dynamic artifact. Keeping the install
719/// fingerprint in this path prevents two material variants that happen to use
720/// the same release filename from sharing unverified cached bytes.
721pub(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
736/// Seed or inspect the exact cache entry used by a dynamic install identity.
737/// This is public for lock replay and integration fixtures; callers must still
738/// verify the artifact before trusting cached bytes.
739pub 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
747/// Reject filesystem-facing names that are not exactly one ordinary path
748/// component on either Unix or Windows. Callers should reject rather than
749/// sanitize these names because aliases could otherwise collide in the cache.
750pub 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}