1use 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 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 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 link_mode: crate::store::link::LinkMode::Copy,
345 show_progress: ctx.show_progress,
346 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}