Skip to main content

osdk_core/backend/
http.rs

1//! Inline HTTPS artifacts addressed as `http:https://host/path-{version}`.
2
3use std::collections::BTreeMap;
4use std::io::Write as _;
5use std::net::{IpAddr, SocketAddr, ToSocketAddrs as _};
6use std::path::{Path, PathBuf};
7
8use async_trait::async_trait;
9use futures_util::StreamExt as _;
10
11use crate::backend::{Backend, Ctx, InstallCtx};
12use crate::dirs::InstallLocator;
13use crate::error::{Error, Result};
14use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
15use crate::source::Source;
16use crate::tool::{InstallIdentity, InstallScope};
17use crate::version::{ToolRequest, ToolVersion, VersionInfo, VersionSpec};
18
19const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
20const MAX_ARCHIVE_ENTRIES: usize = 16 * 1024;
21const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
22const HTTP_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 * 60);
23const DNS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
24
25#[derive(Debug)]
26pub struct HttpBackend {
27    id: String,
28    template: String,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum HttpArtifactKind {
33    TarGz,
34    TarXz,
35    Zip,
36    File,
37}
38
39#[derive(Debug)]
40struct HttpArtifact {
41    url: String,
42    file_name: String,
43    kind: HttpArtifactKind,
44    checksum: Checksum,
45}
46
47impl HttpBackend {
48    pub fn from_id(id: &str) -> Option<Self> {
49        let id = crate::tool::canonical_dynamic_id(id).ok()?;
50        let template = id.strip_prefix("http:")?.to_string();
51        Some(Self { id, template })
52    }
53
54    fn artifact(&self, tv: &ToolVersion) -> Result<HttpArtifact> {
55        if let Some(locked) = pipeline::locked_artifact(tv)? {
56            validate_rendered_url(&locked.url)?;
57            let checksum = locked
58                .checksum
59                .as_deref()
60                .map(pipeline::parse_checksum)
61                .transpose()?
62                .ok_or_else(|| Error::config("locked HTTP artifact is missing its checksum"))?;
63            if checksum.algo != HashAlgo::Sha256
64                || checksum.hex.len() != 64
65                || !checksum.hex.bytes().all(|byte| byte.is_ascii_hexdigit())
66                || checksum.hex != checksum.hex.to_ascii_lowercase()
67            {
68                return Err(Error::config(
69                    "locked HTTP artifact requires a SHA-256 checksum",
70                ));
71            }
72            let canonical_options =
73                crate::backend::dynamic::identity_options(self.id(), &tv.options)?;
74            let public_checksum = canonical_options
75                .get("sha256")
76                .ok_or_else(|| Error::config("sha256 is required for HTTP artifacts"))?;
77            if &checksum.hex != public_checksum {
78                return Err(Error::config(
79                    "locked HTTP artifact checksum does not match the public sha256 option",
80                ));
81            }
82            return Ok(HttpArtifact {
83                kind: kind_from_options_or_name(&tv.options, &self.template)?,
84                url: locked.url,
85                file_name: locked.file_name,
86                checksum,
87            });
88        }
89
90        let url = render_template(&self.template, &tv.version)?;
91        validate_rendered_url(&url)?;
92        let file_name = download_file_name(&url)?;
93        let checksum = Checksum {
94            algo: HashAlgo::Sha256,
95            hex: tv
96                .options
97                .get("sha256")
98                .ok_or_else(|| Error::config("sha256 is required for HTTP artifacts"))?
99                .clone(),
100        };
101        Ok(HttpArtifact {
102            kind: kind_from_options_or_name(&tv.options, &file_name)?,
103            url,
104            file_name,
105            checksum,
106        })
107    }
108
109    fn locator(
110        &self,
111        ctx: &Ctx,
112        tv: &ToolVersion,
113        artifact: &HttpArtifact,
114    ) -> Result<InstallLocator> {
115        let materials = BTreeMap::from([
116            ("artifact-file".into(), artifact.file_name.clone()),
117            (
118                "artifact-checksum".into(),
119                format!("sha256:{}", artifact.checksum.hex),
120            ),
121            (
122                "artifact-url-blake3".into(),
123                artifact_url_hash(&artifact.url),
124            ),
125        ]);
126        let identity = InstallIdentity::new(
127            self.id(),
128            &tv.version,
129            ctx.platform.to_string(),
130            InstallScope::Isolated,
131            &tv.options,
132            Vec::new(),
133            materials,
134        )?;
135        InstallLocator::new(&ctx.dirs, identity)
136    }
137
138    /// Derive an exact fingerprinted locator from public and lock-replay
139    /// options without performing DNS or I/O.
140    pub fn install_locator_for(
141        dirs: &crate::dirs::Dirs,
142        platform: crate::platform::Platform,
143        backend_id: &str,
144        tv: &ToolVersion,
145    ) -> Result<InstallLocator> {
146        let backend = Self::from_id(backend_id)
147            .ok_or_else(|| Error::UnknownBackend(backend_id.to_string()))?;
148        let artifact = backend.artifact(tv)?;
149        let materials = BTreeMap::from([
150            ("artifact-file".into(), artifact.file_name),
151            (
152                "artifact-checksum".into(),
153                format!("sha256:{}", artifact.checksum.hex),
154            ),
155            (
156                "artifact-url-blake3".into(),
157                artifact_url_hash(&artifact.url),
158            ),
159        ]);
160        let identity = InstallIdentity::new(
161            backend.id(),
162            &tv.version,
163            platform.to_string(),
164            InstallScope::Isolated,
165            &tv.options,
166            Vec::new(),
167            materials,
168        )?;
169        InstallLocator::new(dirs, identity)
170    }
171
172    pub(crate) fn installed_locator(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<InstallLocator> {
173        let artifact = self.artifact(tv)?;
174        self.locator(ctx, tv, &artifact)
175    }
176
177    /// Validate an inventory candidate without network access. Lifecycle and
178    /// lockfile callers use this narrow HTTP-specific boundary.
179    pub fn install_candidate_is_valid(
180        dirs: &crate::dirs::Dirs,
181        install_root: &Path,
182        identity: &InstallIdentity,
183    ) -> Result<bool> {
184        if !crate::backend::dynamic::artifact_install_candidate_is_valid(
185            dirs,
186            install_root,
187            identity,
188        )? {
189            return Ok(false);
190        }
191        if !identity.tool.starts_with("http:") {
192            return Ok(false);
193        }
194        let receipt = crate::pipeline::artifact_receipt_at(install_root)
195            .ok_or_else(|| Error::other("HTTP artifact receipt is missing or invalid"))?;
196        validate_rendered_url(&receipt.url)?;
197        let expected_url = identity
198            .materials
199            .get("artifact-url-blake3")
200            .ok_or_else(|| Error::other("HTTP install identity is missing its URL fingerprint"))?;
201        let expected_checksum = identity
202            .materials
203            .get("artifact-checksum")
204            .ok_or_else(|| Error::other("HTTP install identity is missing its checksum"))?;
205        if artifact_url_hash(&receipt.url) != *expected_url
206            || receipt.checksum.as_deref() != Some(expected_checksum.as_str())
207            || !receipt.evidence.is_empty()
208        {
209            return Err(Error::other(format!(
210                "HTTP artifact receipt does not match install identity at {}",
211                install_root.display()
212            )));
213        }
214        Ok(true)
215    }
216}
217
218#[async_trait]
219impl Backend for HttpBackend {
220    fn id(&self) -> &str {
221        &self.id
222    }
223
224    fn default_sources(&self) -> Vec<Source> {
225        Vec::new()
226    }
227
228    fn probe_url(&self, _ctx: &Ctx, _source: &Source) -> Option<String> {
229        None
230    }
231
232    async fn list_remote_versions(&self, _ctx: &Ctx) -> Result<Vec<VersionInfo>> {
233        Err(Error::other(
234            "HTTP artifacts require an exact semantic version selector",
235        ))
236    }
237
238    async fn resolve_version(&self, _ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
239        let VersionSpec::Exact(version) = &req.spec else {
240            return Err(Error::VersionResolve {
241                tool: self.id.clone(),
242                spec: req.spec.to_string(),
243                hint: Some("HTTP artifacts require an exact semantic version selector".into()),
244            });
245        };
246        crate::backend::dynamic::validate_options(self.id(), &req.options)?;
247        let mut resolved = ToolVersion::new(self.id(), version);
248        resolved.options = req.options.clone();
249        Ok(resolved)
250    }
251
252    async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
253        let ctx = ictx.ctx;
254        crate::backend::dynamic::validate_options(self.id(), &tv.options)?;
255        if !matches!(VersionSpec::parse(&tv.version), VersionSpec::Exact(ref exact) if exact == &tv.version)
256        {
257            return Err(Error::config(
258                "HTTP artifacts require an exact semantic version selector",
259            ));
260        }
261        let artifact = self.artifact(tv)?;
262        let locator = self.locator(ctx, tv, &artifact)?;
263        let _lock = crate::backend::dynamic::acquire_install_lock(&locator, "HTTP").await?;
264        let root = locator.install_root();
265        if root.join(".osdk-complete").exists() {
266            if Self::install_candidate_is_valid(&ctx.dirs, root, locator.identity())? {
267                return Ok(());
268            }
269            return Err(Error::other(format!(
270                "refusing to reuse incomplete or invalid HTTP artifact install at {}",
271                root.display()
272            )));
273        }
274
275        match artifact.kind {
276            HttpArtifactKind::File => {
277                let name = tv
278                    .options
279                    .get("rename")
280                    .map(String::as_str)
281                    .unwrap_or_else(|| artifact.file_name.as_str());
282                let name = executable_stem(name, ctx.platform.os)?;
283                let cached = pipeline::dynamic_artifact_cache_path(
284                    &ctx.dirs,
285                    &locator,
286                    &artifact.file_name,
287                )?;
288                prepare_cached_artifact(
289                    ctx,
290                    &artifact.url,
291                    &artifact.file_name,
292                    &artifact.checksum,
293                    &cached,
294                )
295                .await?;
296                let client = offline_client()?;
297                pipeline::install_single_binary_unfinalized_at(
298                    &client,
299                    &ctx.dirs,
300                    &locator,
301                    std::slice::from_ref(&artifact.url),
302                    &name,
303                    &artifact.file_name,
304                    ctx.platform.os,
305                    Some(&artifact.checksum),
306                    ctx.show_progress,
307                    true,
308                    true,
309                    None,
310                )
311                .await?;
312            }
313            kind => {
314                let plan = InstallPlan {
315                    tool: self.id.clone(),
316                    version: tv.version.clone(),
317                    urls: vec![artifact.url],
318                    file_name: artifact.file_name,
319                    kind: kind.archive_kind().expect("archive branch"),
320                    checksum: Some(artifact.checksum),
321                    strip_root: false,
322                    subdir: tv.options.get("subdir").map(PathBuf::from),
323                };
324                let cached =
325                    pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, &plan.file_name)?;
326                prepare_cached_artifact(
327                    ctx,
328                    &plan.urls[0],
329                    &plan.file_name,
330                    plan.checksum
331                        .as_ref()
332                        .expect("HTTP plans always have checksums"),
333                    &cached,
334                )
335                .await?;
336                let client = offline_client()?;
337                validate_archive_entries(&cached, kind)?;
338                let pipeline_ctx = PipelineCtx {
339                    client: &client,
340                    dirs: &ctx.dirs,
341                    cas: &ctx.cas,
342                    // Arbitrary archives may contain links. Materialize real
343                    // files so the final no-symlink validation is meaningful.
344                    link_mode: crate::store::link::LinkMode::Copy,
345                    show_progress: ctx.show_progress,
346                    // The artifact is already downloaded and security-checked
347                    // above. Force the shared pipeline to consume only that
348                    // exact cache entry and never perform a second request.
349                    offline: true,
350                    require_checksums: true,
351                };
352                pipeline::run_with_attestation_unfinalized_at(&plan, &pipeline_ctx, None, &locator)
353                    .await?;
354                postprocess_archive(ctx, &locator, &tv.options)?;
355            }
356        }
357        crate::backend::dynamic::finalize_artifact_install(&locator)
358    }
359
360    async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
361        let locator = self.installed_locator(ctx, tv)?;
362        let _lock = crate::backend::dynamic::acquire_install_lock(&locator, "HTTP").await?;
363        match std::fs::symlink_metadata(locator.install_root()) {
364            Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
365                std::fs::remove_dir_all(locator.install_root())
366                    .map_err(|error| Error::io(locator.install_root(), error))
367            }
368            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
369            Ok(_) => Err(Error::other(
370                "refusing to remove non-directory HTTP install root",
371            )),
372            Err(error) => Err(Error::io(locator.install_root(), error)),
373        }
374    }
375
376    fn list_installed(&self, ctx: &Ctx) -> Result<Vec<String>> {
377        let report = crate::inventory::scan_installs(
378            &ctx.dirs.installs,
379            &crate::inventory::ScanOptions::default(),
380        )?;
381        let mut versions = std::collections::BTreeSet::new();
382        for install in report.installs {
383            if install.manifest.identity.tool != self.id
384                || install.manifest.identity.platform != ctx.platform.to_string()
385                || install.manifest.identity.scope != InstallScope::Isolated
386            {
387                continue;
388            }
389            if Self::install_candidate_is_valid(
390                &ctx.dirs,
391                &install.install_root,
392                &install.manifest.identity,
393            )? {
394                versions.insert(install.manifest.identity.version);
395            }
396        }
397        Ok(versions.into_iter().collect())
398    }
399
400    fn ensure_post_install(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
401        let locator = self.installed_locator(ctx, tv)?;
402        if Self::install_candidate_is_valid(&ctx.dirs, locator.install_root(), locator.identity())?
403        {
404            Ok(())
405        } else {
406            Err(Error::other("HTTP artifact install is incomplete"))
407        }
408    }
409
410    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
411        let root = self
412            .installed_locator(ctx, tv)?
413            .install_root()
414            .to_path_buf();
415        let bin = root.join("bin");
416        Ok(if bin.is_dir() {
417            vec![bin, root]
418        } else {
419            vec![root]
420        })
421    }
422
423    fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
424        let locator = self.installed_locator(ctx, tv)?;
425        Ok(
426            crate::inventory::DynamicToolManifest::load(locator.install_root())?
427                .bins
428                .into_iter()
429                .map(|bin| bin.name)
430                .collect(),
431        )
432    }
433
434    fn dynamic_install_identity(
435        &self,
436        ctx: &Ctx,
437        tv: &ToolVersion,
438    ) -> Result<Option<InstallIdentity>> {
439        self.installed_locator(ctx, tv)
440            .map(|locator| Some(locator.identity().clone()))
441    }
442
443    fn validate_dynamic_install(
444        &self,
445        ctx: &Ctx,
446        _tv: &ToolVersion,
447        install_root: &Path,
448        identity: &InstallIdentity,
449    ) -> Result<bool> {
450        Self::install_candidate_is_valid(&ctx.dirs, install_root, identity)
451    }
452}
453
454impl HttpArtifactKind {
455    fn archive_kind(self) -> Option<ArchiveKind> {
456        match self {
457            Self::TarGz => Some(ArchiveKind::TarGz),
458            Self::TarXz => Some(ArchiveKind::TarXz),
459            Self::Zip => Some(ArchiveKind::Zip),
460            Self::File => None,
461        }
462    }
463}
464
465fn kind_from_options_or_name(
466    options: &BTreeMap<String, String>,
467    file_name: &str,
468) -> Result<HttpArtifactKind> {
469    match options.get("kind").map(String::as_str) {
470        Some("tar.gz") => Ok(HttpArtifactKind::TarGz),
471        Some("tar.xz") => Ok(HttpArtifactKind::TarXz),
472        Some("zip") => Ok(HttpArtifactKind::Zip),
473        Some("file") => Ok(HttpArtifactKind::File),
474        Some(other) => Err(Error::config(format!(
475            "invalid HTTP artifact kind `{other}`"
476        ))),
477        None => match ArchiveKind::from_name(file_name) {
478            Ok(ArchiveKind::TarGz) => Ok(HttpArtifactKind::TarGz),
479            Ok(ArchiveKind::TarXz) => Ok(HttpArtifactKind::TarXz),
480            Ok(ArchiveKind::Zip) => Ok(HttpArtifactKind::Zip),
481            Ok(ArchiveKind::TarZst) => Err(Error::config(
482                "HTTP artifacts support tar.gz, tar.xz, zip, or file",
483            )),
484            Err(_) => Ok(HttpArtifactKind::File),
485        },
486    }
487}
488
489fn render_template(template: &str, version: &str) -> Result<String> {
490    if version.is_empty()
491        || version.len() > 128
492        || version.chars().any(|character| {
493            !character.is_ascii_alphanumeric() && !matches!(character, '.' | '-' | '_' | '+')
494        })
495    {
496        return Err(Error::config("unsafe HTTP artifact version"));
497    }
498    Ok(template.replace("{version}", version))
499}
500
501fn validate_rendered_url(value: &str) -> Result<()> {
502    let parsed = reqwest::Url::parse(value)
503        .map_err(|error| Error::config(format!("invalid HTTP artifact URL: {error}")))?;
504    if parsed.scheme() != "https"
505        || parsed.host_str().is_none()
506        || !parsed.username().is_empty()
507        || parsed.password().is_some()
508        || parsed.query().is_some()
509        || parsed.fragment().is_some()
510    {
511        return Err(Error::config(
512            "HTTP artifact URL must remain absolute HTTPS without credentials, query, or fragment",
513        ));
514    }
515    let host = parsed.host_str().expect("host checked above");
516    let literal = host
517        .strip_prefix('[')
518        .and_then(|host| host.strip_suffix(']'))
519        .unwrap_or(host);
520    if literal
521        .parse::<IpAddr>()
522        .is_ok_and(|address| !crate::tool::is_public_ip(address))
523    {
524        return Err(Error::config(
525            "HTTP artifact URL must not target a non-public IP address",
526        ));
527    }
528    Ok(())
529}
530
531fn artifact_url_hash(url: &str) -> String {
532    let mut hasher = blake3::Hasher::new_derive_key("osdk-http-artifact-url-v1");
533    hasher.update(url.as_bytes());
534    hasher.finalize().to_hex().to_string()
535}
536
537async fn secure_client(url: &str) -> Result<reqwest::Client> {
538    let parsed = reqwest::Url::parse(url)
539        .map_err(|error| Error::config(format!("invalid HTTP artifact URL: {error}")))?;
540    let url_host = parsed
541        .host_str()
542        .ok_or_else(|| Error::config("HTTP artifact URL requires a host"))?;
543    let host = url_host
544        .strip_prefix('[')
545        .and_then(|host| host.strip_suffix(']'))
546        .unwrap_or(url_host);
547    let port = parsed
548        .port_or_known_default()
549        .ok_or_else(|| Error::config("HTTP artifact URL requires a known port"))?;
550    let addresses = resolve_public_addresses(host, port).await?;
551    reqwest::Client::builder()
552        .user_agent(concat!(
553            "osdk/",
554            env!("CARGO_PKG_VERSION"),
555            " http-artifact"
556        ))
557        .no_proxy()
558        .connect_timeout(std::time::Duration::from_secs(15))
559        .timeout(HTTP_REQUEST_TIMEOUT)
560        .pool_idle_timeout(std::time::Duration::from_secs(30))
561        .resolve_to_addrs(host, &addresses)
562        .redirect(reqwest::redirect::Policy::custom(|attempt| {
563            if let Err(error) = validate_redirect(attempt.url(), attempt.previous()) {
564                return attempt.error(error);
565            }
566            attempt.follow()
567        }))
568        .build()
569        .map_err(Error::from)
570}
571
572fn offline_client() -> Result<reqwest::Client> {
573    reqwest::Client::builder()
574        .no_proxy()
575        .redirect(reqwest::redirect::Policy::none())
576        .build()
577        .map_err(Error::from)
578}
579
580async fn resolve_public_addresses(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
581    let addresses = if let Ok(address) = host.parse::<IpAddr>() {
582        vec![SocketAddr::new(address, port)]
583    } else {
584        let endpoint = (host.to_string(), port);
585        tokio::time::timeout(
586            DNS_TIMEOUT,
587            tokio::task::spawn_blocking(move || {
588                endpoint
589                    .to_socket_addrs()
590                    .map(|addresses| addresses.collect::<Vec<_>>())
591            }),
592        )
593        .await
594        .map_err(|_| Error::other(format!("HTTP artifact DNS lookup timed out for `{host}`")))?
595        .map_err(|error| {
596            Error::other(format!(
597                "HTTP artifact DNS task failed for `{host}`: {error}"
598            ))
599        })?
600        .map_err(|error| {
601            Error::other(format!(
602                "HTTP artifact DNS lookup failed for `{host}`: {error}"
603            ))
604        })?
605    };
606    if addresses.is_empty() {
607        return Err(Error::other(format!(
608            "HTTP artifact DNS lookup returned no addresses for `{host}`"
609        )));
610    }
611    if let Some(address) = addresses
612        .iter()
613        .map(SocketAddr::ip)
614        .find(|address| !crate::tool::is_public_ip(*address))
615    {
616        return Err(Error::other(format!(
617            "HTTP artifact destination `{host}` resolved to forbidden address {address}"
618        )));
619    }
620    let mut addresses = addresses;
621    addresses.sort();
622    addresses.dedup();
623    if addresses.is_empty() {
624        return Err(Error::other(format!(
625            "HTTP artifact DNS lookup returned no usable addresses for `{host}`"
626        )));
627    }
628    Ok(addresses)
629}
630
631fn validate_redirect(
632    next: &reqwest::Url,
633    previous: &[reqwest::Url],
634) -> std::result::Result<(), &'static str> {
635    let Some(initial) = previous.first() else {
636        return Err("HTTP artifact redirect has no origin");
637    };
638    if previous.len() >= 10 {
639        return Err("HTTP artifact redirect limit exceeded");
640    }
641    if next.scheme() != "https" {
642        return Err("HTTP artifact redirects must remain on HTTPS");
643    }
644    if !next.username().is_empty()
645        || next.password().is_some()
646        || next.query().is_some()
647        || next.fragment().is_some()
648    {
649        return Err("HTTP artifact redirect must not contain credentials, query, or fragment");
650    }
651    if initial.host_str() != next.host_str()
652        || initial.port_or_known_default() != next.port_or_known_default()
653    {
654        return Err("HTTP artifact redirect must remain on the original origin");
655    }
656    if previous.iter().any(|url| url == next) {
657        return Err("HTTP artifact redirect loop detected");
658    }
659    Ok(())
660}
661
662async fn prepare_cached_artifact(
663    ctx: &Ctx,
664    url: &str,
665    file_name: &str,
666    checksum: &Checksum,
667    cached: &Path,
668) -> Result<()> {
669    if cached.exists() {
670        let validation = validate_cached_artifact(cached).and_then(|()| {
671            pipeline::verify::verify_file(cached, &checksum.hex, checksum.algo, file_name)
672        });
673        match validation {
674            Ok(()) => return Ok(()),
675            Err(error) if ctx.config.settings.offline => return Err(error),
676            Err(error) => match std::fs::symlink_metadata(cached) {
677                Ok(metadata) if metadata.is_file() || metadata.file_type().is_symlink() => {
678                    std::fs::remove_file(cached).map_err(|remove| Error::io(cached, remove))?;
679                }
680                _ => return Err(error),
681            },
682        }
683    } else if ctx.config.settings.offline {
684        return Err(Error::other(format!(
685            "offline artifact cache miss for {}",
686            cached.display()
687        )));
688    }
689    let client = secure_client(url).await?;
690    download_bounded(&client, url, cached).await?;
691    validate_cached_artifact(cached)?;
692    pipeline::verify::verify_file(cached, &checksum.hex, checksum.algo, file_name)
693}
694
695fn validate_cached_artifact(cached: &Path) -> Result<()> {
696    let metadata = std::fs::symlink_metadata(cached).map_err(|error| Error::io(cached, error))?;
697    if metadata.file_type().is_symlink() || !metadata.is_file() {
698        return Err(Error::other(
699            "HTTP artifact cache entry must be a regular non-symlink file",
700        ));
701    }
702    if metadata.len() > MAX_ARTIFACT_BYTES {
703        return Err(Error::other(format!(
704            "HTTP artifact exceeds the {MAX_ARTIFACT_BYTES} byte download limit"
705        )));
706    }
707    Ok(())
708}
709
710async fn download_bounded(client: &reqwest::Client, url: &str, destination: &Path) -> Result<()> {
711    if let Some(parent) = destination.parent() {
712        crate::dirs::create_dir_all(parent)?;
713    }
714    let response = client
715        .get(url)
716        .send()
717        .await
718        .map_err(|error| Error::network(url, error))?
719        .error_for_status()
720        .map_err(|error| Error::network(url, error))?;
721    if response
722        .content_length()
723        .is_some_and(|length| length > MAX_ARTIFACT_BYTES)
724    {
725        return Err(Error::other(format!(
726            "HTTP artifact exceeds the {MAX_ARTIFACT_BYTES} byte download limit"
727        )));
728    }
729    let parent = destination
730        .parent()
731        .ok_or_else(|| Error::other("HTTP artifact cache path has no parent"))?;
732    let mut temporary = tempfile::Builder::new()
733        .prefix(".osdk-http-download.")
734        .tempfile_in(parent)
735        .map_err(|error| Error::io(parent, error))?;
736    let result = async {
737        let mut stream = response.bytes_stream();
738        let mut downloaded = 0_u64;
739        while let Some(chunk) = stream.next().await {
740            let chunk = chunk.map_err(|error| Error::network(url, error))?;
741            downloaded = downloaded
742                .checked_add(chunk.len() as u64)
743                .ok_or_else(|| Error::other("HTTP artifact download size overflow"))?;
744            if downloaded > MAX_ARTIFACT_BYTES {
745                return Err(Error::other(format!(
746                    "HTTP artifact exceeds the {MAX_ARTIFACT_BYTES} byte download limit"
747                )));
748            }
749            temporary
750                .write_all(&chunk)
751                .map_err(|error| Error::io(temporary.path(), error))?;
752        }
753        temporary
754            .as_file()
755            .sync_all()
756            .map_err(|error| Error::io(temporary.path(), error))?;
757        temporary
758            .persist(destination)
759            .map_err(|error| Error::io(destination, error.error))?;
760        Ok(())
761    }
762    .await;
763    result
764}
765
766fn validate_archive_entries(path: &Path, kind: HttpArtifactKind) -> Result<()> {
767    match kind {
768        HttpArtifactKind::TarGz => {
769            let file = std::fs::File::open(path).map_err(|error| Error::io(path, error))?;
770            validate_tar_entries(flate2::read::GzDecoder::new(std::io::BufReader::new(file)))
771        }
772        HttpArtifactKind::TarXz => {
773            let file = std::fs::File::open(path).map_err(|error| Error::io(path, error))?;
774            validate_tar_entries(xz2::read::XzDecoder::new(std::io::BufReader::new(file)))
775        }
776        HttpArtifactKind::Zip => validate_zip_entries(path),
777        HttpArtifactKind::File => Ok(()),
778    }
779}
780
781fn validate_tar_entries(reader: impl std::io::Read) -> Result<()> {
782    let mut archive = tar::Archive::new(reader);
783    let mut count = 0_usize;
784    let mut expanded = 0_u64;
785    let mut paths = std::collections::BTreeSet::new();
786    for entry in archive
787        .entries()
788        .map_err(|error| Error::other(format!("invalid HTTP tar archive: {error}")))?
789    {
790        let entry =
791            entry.map_err(|error| Error::other(format!("invalid HTTP tar entry: {error}")))?;
792        let path = entry
793            .path()
794            .map_err(|error| Error::other(format!("invalid HTTP tar path: {error}")))?;
795        validate_archive_relative_path(&path)?;
796        register_archive_path(&mut paths, &path)?;
797        let kind = entry.header().entry_type();
798        if !(kind.is_file() || kind.is_dir()) {
799            return Err(Error::other(format!(
800                "HTTP archives may contain only regular files and directories: {}",
801                path.display()
802            )));
803        }
804        count = count
805            .checked_add(1)
806            .ok_or_else(|| Error::other("HTTP archive entry count overflow"))?;
807        expanded = expanded
808            .checked_add(entry.size())
809            .ok_or_else(|| Error::other("HTTP archive expanded size overflow"))?;
810        validate_archive_limits(count, expanded)?;
811    }
812    Ok(())
813}
814
815fn validate_zip_entries(path: &Path) -> Result<()> {
816    let file = std::fs::File::open(path).map_err(|error| Error::io(path, error))?;
817    let mut archive = zip::ZipArchive::new(std::io::BufReader::new(file))
818        .map_err(|error| Error::other(format!("invalid HTTP zip archive: {error}")))?;
819    if archive.len() > MAX_ARCHIVE_ENTRIES {
820        return Err(Error::other(format!(
821            "HTTP archive exceeds the {MAX_ARCHIVE_ENTRIES} entry limit"
822        )));
823    }
824    let mut expanded = 0_u64;
825    let mut paths = std::collections::BTreeSet::new();
826    for index in 0..archive.len() {
827        let entry = archive
828            .by_index(index)
829            .map_err(|error| Error::other(format!("invalid HTTP zip entry: {error}")))?;
830        let enclosed = entry
831            .enclosed_name()
832            .ok_or_else(|| Error::other(format!("unsafe HTTP zip entry `{}`", entry.name())))?;
833        validate_archive_relative_path(&enclosed)?;
834        register_archive_path(&mut paths, &enclosed)?;
835        if entry
836            .unix_mode()
837            .is_some_and(|mode| mode & 0o170000 == 0o120000)
838        {
839            return Err(Error::other(format!(
840                "HTTP archives may not contain symlinks: {}",
841                entry.name()
842            )));
843        }
844        expanded = expanded
845            .checked_add(entry.size())
846            .ok_or_else(|| Error::other("HTTP archive expanded size overflow"))?;
847        validate_archive_limits(index + 1, expanded)?;
848    }
849    Ok(())
850}
851
852fn validate_archive_limits(entries: usize, expanded_bytes: u64) -> Result<()> {
853    if entries > MAX_ARCHIVE_ENTRIES {
854        return Err(Error::other(format!(
855            "HTTP archive exceeds the {MAX_ARCHIVE_ENTRIES} entry limit"
856        )));
857    }
858    if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES {
859        return Err(Error::other(format!(
860            "HTTP archive exceeds the {MAX_ARCHIVE_EXPANDED_BYTES} byte expanded-size limit"
861        )));
862    }
863    Ok(())
864}
865
866fn validate_archive_relative_path(path: &Path) -> Result<()> {
867    crate::tool::canonical_safe_relative_path("HTTP archive entry", &path.to_string_lossy())
868        .map(|_| ())
869}
870
871fn register_archive_path(
872    paths: &mut std::collections::BTreeSet<String>,
873    path: &Path,
874) -> Result<()> {
875    let canonical =
876        crate::tool::canonical_safe_relative_path("HTTP archive entry", &path.to_string_lossy())?;
877    let windows_key = canonical.to_ascii_lowercase();
878    if !paths.insert(windows_key) {
879        return Err(Error::other(format!(
880            "HTTP archive contains a cross-platform path collision: {}",
881            path.display()
882        )));
883    }
884    Ok(())
885}
886
887fn download_file_name(url: &str) -> Result<String> {
888    let parsed = reqwest::Url::parse(url)
889        .map_err(|error| Error::config(format!("invalid HTTP artifact URL: {error}")))?;
890    let name = parsed
891        .path_segments()
892        .and_then(Iterator::last)
893        .ok_or_else(|| Error::config("HTTP artifact URL requires a filename"))?;
894    pipeline::validate_safe_filename("HTTP artifact filename", name)?;
895    Ok(name.to_string())
896}
897
898fn postprocess_archive(
899    ctx: &Ctx,
900    locator: &InstallLocator,
901    options: &BTreeMap<String, String>,
902) -> Result<()> {
903    let root = locator.install_root();
904    crate::backend::dynamic::reject_symlinks(root)?;
905    let count = options
906        .get("strip-components")
907        .map(|value| value.parse::<u32>())
908        .transpose()
909        .map_err(|error| Error::config(format!("invalid strip-components: {error}")))?
910        .unwrap_or(0);
911    let base = descend_unique(root, count)?;
912    let Some(bins) = options.get("bins") else {
913        return Ok(());
914    };
915    let sources = bins.split(',').map(PathBuf::from).collect::<Vec<_>>();
916    if options.get("rename").is_some() && sources.len() != 1 {
917        return Err(Error::config(
918            "rename requires exactly one HTTP archive bin",
919        ));
920    }
921    let bin_dir = root.join("bin");
922    crate::dirs::create_dir_all(&bin_dir)?;
923    let canonical_root = dunce::canonicalize(root).map_err(|error| Error::io(root, error))?;
924    for source in sources {
925        let source_path = base.join(&source);
926        let metadata = std::fs::symlink_metadata(&source_path)
927            .map_err(|error| Error::io(&source_path, error))?;
928        let canonical =
929            dunce::canonicalize(&source_path).map_err(|error| Error::io(&source_path, error))?;
930        if metadata.file_type().is_symlink()
931            || !metadata.is_file()
932            || !canonical.starts_with(&canonical_root)
933        {
934            return Err(Error::other(format!(
935                "configured HTTP binary is unsafe: {}",
936                source.display()
937            )));
938        }
939        if ctx.platform.os == crate::platform::Os::Windows {
940            let source_name = source
941                .file_name()
942                .and_then(|name| name.to_str())
943                .ok_or_else(|| Error::config("configured HTTP bin has no safe filename"))?;
944            executable_stem(source_name, ctx.platform.os)?;
945        }
946        let name = options
947            .get("rename")
948            .cloned()
949            .or_else(|| {
950                source
951                    .file_name()
952                    .map(|name| name.to_string_lossy().into_owned())
953            })
954            .ok_or_else(|| Error::config("configured HTTP bin has no filename"))?;
955        let destination = bin_dir.join(executable_name(&name, ctx.platform.os)?);
956        if destination.exists() {
957            return Err(Error::other(format!(
958                "HTTP archive maps multiple executables to `{}`",
959                destination.display()
960            )));
961        }
962        std::fs::copy(&source_path, &destination)
963            .map_err(|error| Error::io(&destination, error))?;
964        #[cfg(unix)]
965        {
966            use std::os::unix::fs::PermissionsExt as _;
967            std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o755))
968                .map_err(|error| Error::io(&destination, error))?;
969        }
970    }
971    Ok(())
972}
973
974fn descend_unique(root: &Path, count: u32) -> Result<PathBuf> {
975    let mut selected = root.to_path_buf();
976    for _ in 0..count {
977        let mut children = std::fs::read_dir(&selected)
978            .map_err(|error| Error::io(&selected, error))?
979            .collect::<std::io::Result<Vec<_>>>()?;
980        children.retain(|entry| !entry.file_name().to_string_lossy().starts_with(".osdk-"));
981        if children.len() != 1 || !children[0].path().is_dir() {
982            return Err(Error::other(format!(
983                "strip-components cannot descend through {}",
984                selected.display()
985            )));
986        }
987        selected = children.remove(0).path();
988    }
989    Ok(selected)
990}
991
992fn executable_name(name: &str, os: crate::platform::Os) -> Result<String> {
993    if os == crate::platform::Os::Windows {
994        let lower = name.to_ascii_lowercase();
995        if lower.ends_with(".cmd") || lower.ends_with(".bat") {
996            return Err(Error::config(
997                "HTTP artifacts support only native .exe executables on Windows",
998            ));
999        }
1000        if !lower.ends_with(".exe") {
1001            return Ok(format!("{name}.exe"));
1002        }
1003    }
1004    Ok(name.to_string())
1005}
1006
1007fn executable_stem(name: &str, os: crate::platform::Os) -> Result<String> {
1008    if os == crate::platform::Os::Windows
1009        && matches!(
1010            Path::new(name)
1011                .extension()
1012                .and_then(|extension| extension.to_str())
1013                .map(str::to_ascii_lowercase)
1014                .as_deref(),
1015            Some("cmd") | Some("bat")
1016        )
1017    {
1018        return Err(Error::config(
1019            "HTTP artifacts support only native .exe executables on Windows",
1020        ));
1021    }
1022    if os == crate::platform::Os::Windows && name.to_ascii_lowercase().ends_with(".exe") {
1023        return Ok(name[..name.len() - ".exe".len()].to_string());
1024    }
1025    Ok(name.to_string())
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use std::sync::Arc;
1031
1032    use super::*;
1033    use crate::config::{Config, Settings, SourcesConfig};
1034    use crate::inventory::DynamicToolManifest;
1035    use crate::platform::{Arch, Libc, Os, Platform};
1036    use crate::source::Selection;
1037    use crate::store::Cas;
1038
1039    #[test]
1040    fn parses_only_strict_https_templates() {
1041        assert!(HttpBackend::from_id("http:https://example.test/tool-{version}.tar.gz").is_some());
1042        for invalid in [
1043            "http:http://example.test/tool-{version}.tar.gz",
1044            "http:https://user@example.test/tool-{version}.tar.gz",
1045            "http:https://example.test/tool-{version}.tar.gz?token=x",
1046            "http:https://example.test/tool.tar.gz",
1047        ] {
1048            assert!(HttpBackend::from_id(invalid).is_none(), "{invalid}");
1049        }
1050    }
1051
1052    #[test]
1053    fn redirect_policy_rejects_cross_origin_downgrade_and_loops() {
1054        let initial = reqwest::Url::parse("https://example.test/start").unwrap();
1055        let same = reqwest::Url::parse("https://example.test/final").unwrap();
1056        assert_eq!(
1057            validate_redirect(&same, std::slice::from_ref(&initial)),
1058            Ok(())
1059        );
1060        let downgrade = reqwest::Url::parse("http://example.test/final").unwrap();
1061        assert!(validate_redirect(&downgrade, std::slice::from_ref(&initial)).is_err());
1062        let cross = reqwest::Url::parse("https://other.test/final").unwrap();
1063        assert!(validate_redirect(&cross, std::slice::from_ref(&initial)).is_err());
1064        assert!(validate_redirect(&initial, std::slice::from_ref(&initial)).is_err());
1065    }
1066
1067    #[test]
1068    fn archive_validation_rejects_traversal_and_links() {
1069        for path in [
1070            "../escape",
1071            "/absolute",
1072            "safe/../../escape",
1073            r"bin\tool",
1074            "C:/tool",
1075            "bin/tool.",
1076            "bin/AUX",
1077        ] {
1078            assert!(validate_archive_relative_path(Path::new(path)).is_err());
1079        }
1080
1081        let temp = tempfile::tempdir().unwrap();
1082        let archive = temp.path().join("link.tar.gz");
1083        let file = std::fs::File::create(&archive).unwrap();
1084        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
1085        let mut builder = tar::Builder::new(encoder);
1086        let mut header = tar::Header::new_gnu();
1087        header.set_entry_type(tar::EntryType::Symlink);
1088        header.set_size(0);
1089        header.set_mode(0o777);
1090        header.set_link_name("../../outside").unwrap();
1091        header.set_cksum();
1092        builder
1093            .append_data(&mut header, "bin/tool", std::io::empty())
1094            .unwrap();
1095        builder.finish().unwrap();
1096        assert!(validate_archive_entries(&archive, HttpArtifactKind::TarGz).is_err());
1097
1098        let mut paths = std::collections::BTreeSet::new();
1099        register_archive_path(&mut paths, Path::new("bin/Tool")).unwrap();
1100        assert!(register_archive_path(&mut paths, Path::new("BIN/tool")).is_err());
1101    }
1102
1103    #[test]
1104    fn public_address_policy_rejects_local_metadata_and_mapped_addresses() {
1105        for address in [
1106            "0.0.0.0",
1107            "10.0.0.1",
1108            "127.0.0.1",
1109            "169.254.169.254",
1110            "172.16.0.1",
1111            "192.168.0.1",
1112            "::1",
1113            "fc00::1",
1114            "fe80::1",
1115            "::ffff:127.0.0.1",
1116            "::ffff:169.254.169.254",
1117        ] {
1118            assert!(
1119                !crate::tool::is_public_ip(address.parse().unwrap()),
1120                "{address}"
1121            );
1122        }
1123        for address in ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"] {
1124            assert!(
1125                crate::tool::is_public_ip(address.parse().unwrap()),
1126                "{address}"
1127            );
1128        }
1129    }
1130
1131    #[test]
1132    fn archive_limits_are_fail_closed() {
1133        assert!(validate_archive_limits(MAX_ARCHIVE_ENTRIES, MAX_ARCHIVE_EXPANDED_BYTES).is_ok());
1134        assert!(validate_archive_limits(MAX_ARCHIVE_ENTRIES + 1, 0)
1135            .unwrap_err()
1136            .to_string()
1137            .contains("entry limit"));
1138        assert!(validate_archive_limits(1, MAX_ARCHIVE_EXPANDED_BYTES + 1)
1139            .unwrap_err()
1140            .to_string()
1141            .contains("expanded-size limit"));
1142    }
1143
1144    #[test]
1145    fn windows_batch_executables_fail_closed() {
1146        for name in ["tool.cmd", "TOOL.BAT"] {
1147            assert!(executable_stem(name, Os::Windows).is_err(), "{name}");
1148        }
1149        assert_eq!(executable_stem("tool.exe", Os::Windows).unwrap(), "tool");
1150        assert_eq!(executable_stem("Tool.EXE", Os::Windows).unwrap(), "Tool");
1151        assert_eq!(executable_name("tool", Os::Windows).unwrap(), "tool.exe");
1152        assert_eq!(
1153            executable_name("Tool.EXE", Os::Windows).unwrap(),
1154            "Tool.EXE"
1155        );
1156        assert!(executable_name("tool.cmd", Os::Windows).is_err());
1157    }
1158
1159    #[test]
1160    fn locked_checksum_must_match_public_sha256() {
1161        let backend =
1162            HttpBackend::from_id("http:https://downloads.example.test/tool-{version}").unwrap();
1163        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1164        version.options = BTreeMap::from([
1165            ("sha256".into(), "a".repeat(64)),
1166            ("kind".into(), "file".into()),
1167            (
1168                pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
1169                "https://downloads.example.test/tool-1.2.3".into(),
1170            ),
1171            (
1172                pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
1173                "tool-1.2.3".into(),
1174            ),
1175            (
1176                pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
1177                format!("sha256:{}", "b".repeat(64)),
1178            ),
1179        ]);
1180        let error = backend.artifact(&version).unwrap_err();
1181        assert!(error.to_string().contains("public sha256"), "{error}");
1182    }
1183
1184    #[tokio::test]
1185    async fn offline_cache_hit_never_resolves_the_artifact_host() {
1186        let temp = tempfile::tempdir().unwrap();
1187        let mut ctx = test_ctx(temp.path());
1188        ctx.config.settings.offline = true;
1189        let cached = temp.path().join("cached-tool");
1190        let bytes = b"fixture";
1191        std::fs::write(&cached, bytes).unwrap();
1192        let checksum = Checksum {
1193            algo: HashAlgo::Sha256,
1194            hex: pipeline::verify::hash_bytes(bytes, HashAlgo::Sha256),
1195        };
1196        prepare_cached_artifact(
1197            &ctx,
1198            "https://does-not-resolve.invalid/tool",
1199            "tool",
1200            &checksum,
1201            &cached,
1202        )
1203        .await
1204        .unwrap();
1205    }
1206
1207    #[tokio::test]
1208    async fn dns_resolution_rejects_non_public_results() {
1209        let error = resolve_public_addresses("localhost", 443)
1210            .await
1211            .unwrap_err();
1212        assert!(error.to_string().contains("forbidden address"), "{error}");
1213    }
1214
1215    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1216    async fn locked_bare_file_replays_offline_and_concurrent_installs_serialize() {
1217        let temp = tempfile::tempdir().unwrap();
1218        let mut ctx = test_ctx(temp.path());
1219        ctx.config.settings.offline = true;
1220        let backend =
1221            Arc::new(HttpBackend::from_id("http:https://changed.invalid/tool-{version}").unwrap());
1222        let bytes = b"fixture executable";
1223        let digest = pipeline::verify::hash_bytes(bytes, HashAlgo::Sha256);
1224        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1225        version.options = BTreeMap::from([
1226            ("sha256".into(), digest.clone()),
1227            ("kind".into(), "file".into()),
1228            (
1229                "rename".into(),
1230                if cfg!(windows) {
1231                    "fixture.exe"
1232                } else {
1233                    "fixture"
1234                }
1235                .into(),
1236            ),
1237            (
1238                pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
1239                "https://unreachable.invalid/original".into(),
1240            ),
1241            (
1242                pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
1243                "original".into(),
1244            ),
1245            (
1246                pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
1247                format!("sha256:{digest}"),
1248            ),
1249        ]);
1250        let artifact = backend.artifact(&version).unwrap();
1251        assert_eq!(artifact.url, "https://unreachable.invalid/original");
1252        let locator = backend.locator(&ctx, &version, &artifact).unwrap();
1253        let cached =
1254            pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, "original").unwrap();
1255        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
1256        std::fs::write(&cached, bytes).unwrap();
1257
1258        let ctx = Arc::new(ctx);
1259        let first = {
1260            let backend = backend.clone();
1261            let ctx = ctx.clone();
1262            let version = version.clone();
1263            tokio::spawn(async move { backend.install(&InstallCtx { ctx: &ctx }, &version).await })
1264        };
1265        let second = {
1266            let backend = backend.clone();
1267            let ctx = ctx.clone();
1268            let version = version.clone();
1269            tokio::spawn(async move { backend.install(&InstallCtx { ctx: &ctx }, &version).await })
1270        };
1271        first.await.unwrap().unwrap();
1272        second.await.unwrap().unwrap();
1273
1274        assert_eq!(
1275            std::fs::read(locator.install_root().join("bin").join(if cfg!(windows) {
1276                "fixture.exe"
1277            } else {
1278                "fixture"
1279            }))
1280            .unwrap(),
1281            bytes
1282        );
1283        assert!(locator.install_root().join(".osdk-complete").is_file());
1284        assert!(
1285            crate::backend::dynamic::artifact_install_candidate_is_valid(
1286                &ctx.dirs,
1287                locator.install_root(),
1288                locator.identity()
1289            )
1290            .unwrap()
1291        );
1292    }
1293
1294    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1295    async fn uninstall_waits_for_the_identity_lock() {
1296        let temp = tempfile::tempdir().unwrap();
1297        let ctx = Arc::new(test_ctx(temp.path()));
1298        let backend =
1299            Arc::new(HttpBackend::from_id("http:https://example.test/tool-{version}").unwrap());
1300        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1301        version.options = BTreeMap::from([
1302            ("sha256".into(), "a".repeat(64)),
1303            ("kind".into(), "file".into()),
1304            (
1305                "rename".into(),
1306                if cfg!(windows) {
1307                    "fixture.exe"
1308                } else {
1309                    "fixture"
1310                }
1311                .into(),
1312            ),
1313        ]);
1314        let artifact = backend.artifact(&version).unwrap();
1315        let locator = backend.locator(&ctx, &version, &artifact).unwrap();
1316        std::fs::create_dir_all(locator.install_root()).unwrap();
1317        let held = crate::backend::dynamic::acquire_install_lock(&locator, "test")
1318            .await
1319            .unwrap();
1320        let root = locator.install_root().to_path_buf();
1321        let uninstall = {
1322            let backend = backend.clone();
1323            let ctx = ctx.clone();
1324            let version = version.clone();
1325            tokio::spawn(async move { backend.uninstall(&ctx, &version).await })
1326        };
1327        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1328        assert!(!uninstall.is_finished());
1329        assert!(root.exists());
1330        drop(held);
1331        tokio::time::timeout(std::time::Duration::from_secs(5), uninstall)
1332            .await
1333            .unwrap()
1334            .unwrap()
1335            .unwrap();
1336        assert!(!root.exists());
1337    }
1338
1339    #[tokio::test]
1340    async fn checksum_mismatch_never_publishes_completion() {
1341        let temp = tempfile::tempdir().unwrap();
1342        let mut ctx = test_ctx(temp.path());
1343        ctx.config.settings.offline = true;
1344        let backend = HttpBackend::from_id("http:https://example.test/tool-{version}.zip").unwrap();
1345        let digest = "a".repeat(64);
1346        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1347        version.options = BTreeMap::from([
1348            ("sha256".into(), digest.clone()),
1349            ("kind".into(), "zip".into()),
1350            ("bins".into(), "bin/tool".into()),
1351            (
1352                pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
1353                "https://example.test/tool.zip".into(),
1354            ),
1355            (
1356                pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
1357                "tool.zip".into(),
1358            ),
1359            (
1360                pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
1361                format!("sha256:{digest}"),
1362            ),
1363        ]);
1364        let artifact = backend.artifact(&version).unwrap();
1365        let locator = backend.locator(&ctx, &version, &artifact).unwrap();
1366        let cached =
1367            pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, "tool.zip").unwrap();
1368        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
1369        std::fs::write(&cached, b"wrong bytes").unwrap();
1370        assert!(backend
1371            .install(&InstallCtx { ctx: &ctx }, &version)
1372            .await
1373            .is_err());
1374        assert!(!locator.install_root().join(".osdk-complete").exists());
1375    }
1376
1377    #[tokio::test]
1378    async fn locked_archive_replays_offline_with_safe_layout() {
1379        let temp = tempfile::tempdir().unwrap();
1380        let mut ctx = test_ctx(temp.path());
1381        ctx.config.settings.offline = true;
1382        let backend =
1383            HttpBackend::from_id("http:https://changed.invalid/tool-{version}.tar.gz").unwrap();
1384        let archive = temp.path().join("fixture.tar.gz");
1385        let archived_name = if cfg!(windows) {
1386            "package/dist/tool.exe"
1387        } else {
1388            "package/dist/tool"
1389        };
1390        write_archive(&archive, archived_name, b"archive executable");
1391        let digest = pipeline::verify::hash_file(&archive, HashAlgo::Sha256).unwrap();
1392        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1393        version.options = BTreeMap::from([
1394            ("sha256".into(), digest.clone()),
1395            ("kind".into(), "tar.gz".into()),
1396            (
1397                "bins".into(),
1398                if cfg!(windows) {
1399                    "dist/tool.exe"
1400                } else {
1401                    "dist/tool"
1402                }
1403                .into(),
1404            ),
1405            ("strip-components".into(), "1".into()),
1406            (
1407                "rename".into(),
1408                if cfg!(windows) {
1409                    "fixture.exe"
1410                } else {
1411                    "fixture"
1412                }
1413                .into(),
1414            ),
1415            (
1416                pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
1417                "https://unreachable.invalid/original.tar.gz".into(),
1418            ),
1419            (
1420                pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
1421                "original.tar.gz".into(),
1422            ),
1423            (
1424                pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
1425                format!("sha256:{digest}"),
1426            ),
1427        ]);
1428        let artifact = backend.artifact(&version).unwrap();
1429        let locator = backend.locator(&ctx, &version, &artifact).unwrap();
1430        let cached =
1431            pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, "original.tar.gz").unwrap();
1432        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
1433        std::fs::copy(&archive, &cached).unwrap();
1434
1435        backend
1436            .install(&InstallCtx { ctx: &ctx }, &version)
1437            .await
1438            .unwrap();
1439        assert_eq!(
1440            std::fs::read(locator.install_root().join("bin").join(if cfg!(windows) {
1441                "fixture.exe"
1442            } else {
1443                "fixture"
1444            }))
1445            .unwrap(),
1446            b"archive executable"
1447        );
1448        let manifest = DynamicToolManifest::load(locator.install_root()).unwrap();
1449        assert_eq!(manifest.bins[0].name, "fixture");
1450    }
1451
1452    fn write_archive(path: &Path, name: &str, bytes: &[u8]) {
1453        let file = std::fs::File::create(path).unwrap();
1454        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
1455        let mut builder = tar::Builder::new(encoder);
1456        let mut header = tar::Header::new_gnu();
1457        header.set_size(bytes.len() as u64);
1458        header.set_mode(0o755);
1459        header.set_cksum();
1460        builder.append_data(&mut header, name, bytes).unwrap();
1461        builder.finish().unwrap();
1462    }
1463
1464    fn test_ctx(root: &Path) -> Ctx {
1465        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
1466            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
1467            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
1468            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
1469            "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
1470            "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
1471            _ => None,
1472        })
1473        .unwrap();
1474        dirs.ensure().unwrap();
1475        Ctx {
1476            cas: Arc::new(Cas::new(dirs.store.clone())),
1477            dirs,
1478            platform: Platform {
1479                os: Os::Linux,
1480                arch: Arch::X64,
1481                libc: Libc::Glibc,
1482            },
1483            config: Config {
1484                settings: Settings::default(),
1485                sources: SourcesConfig {
1486                    selection: Selection::Ordered,
1487                    ..Default::default()
1488                },
1489                tools: Default::default(),
1490                tool_configs: Default::default(),
1491                global_tools: Default::default(),
1492                global_tool_configs: Default::default(),
1493                tool_origins: Default::default(),
1494                aliases: Default::default(),
1495                project_config_path: None,
1496            },
1497            client: reqwest::Client::new(),
1498            show_progress: false,
1499        }
1500    }
1501}