1use std::path::{Component, Path, PathBuf};
9
10use async_trait::async_trait;
11use serde::Deserialize;
12
13use crate::backend::{Backend, Ctx, InstallCtx};
14use crate::error::{Error, Result};
15use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
16use crate::platform::{Arch, Libc, Os, Platform};
17use crate::source::Source;
18use crate::version::{ToolVersion, VersionInfo};
19
20pub const SCHEMA_VERSION: u32 = 1;
22
23const MAX_DEFINITION_BYTES: u64 = 1024 * 1024;
24const MAX_VERSIONS: usize = 10_000;
25
26pub struct DeclarativeBackend {
32 id: String,
33 versions: VersionSource,
34 archive: ArchiveDefinition,
35 bin_paths: Vec<PathBuf>,
36 bin_names: Vec<String>,
37 idiomatic_files: Vec<&'static str>,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct BackendDefinition {
43 schema: u32,
44 id: String,
45 versions: VersionDefinition,
46 archive: ArchiveDefinition,
47 bin_paths: Vec<String>,
48 bin_names: Vec<String>,
49 #[serde(default)]
50 idiomatic_files: Vec<String>,
51}
52
53#[derive(Debug, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct VersionDefinition {
56 #[serde(default)]
57 values: Vec<String>,
58 url: Option<String>,
59}
60
61enum VersionSource {
62 Static(Vec<String>),
63 Url(String),
64}
65
66#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct ArchiveDefinition {
69 url: String,
70 file: String,
71 kind: ArchiveKindDefinition,
72 #[serde(default)]
73 strip_root: bool,
74 checksum: ChecksumDefinition,
75}
76
77#[derive(Debug, Clone, Copy, Deserialize)]
78enum ArchiveKindDefinition {
79 #[serde(rename = "tar.gz")]
80 TarGz,
81 #[serde(rename = "tar.xz")]
82 TarXz,
83 #[serde(rename = "tar.zst")]
84 TarZst,
85 #[serde(rename = "zip")]
86 Zip,
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct ChecksumDefinition {
92 algorithm: ChecksumAlgorithm,
93 value: Option<String>,
94 url: Option<String>,
95}
96
97#[derive(Debug, Clone, Copy, Deserialize)]
98#[serde(rename_all = "lowercase")]
99enum ChecksumAlgorithm {
100 Sha256,
101 Sha512,
102 Blake3,
103}
104
105impl DeclarativeBackend {
106 pub fn from_toml(input: &str) -> Result<Self> {
108 let definition: BackendDefinition = toml::from_str(input)?;
109 Self::from_definition(definition)
110 }
111
112 pub fn load_file(path: &Path) -> Result<Self> {
114 let metadata = std::fs::symlink_metadata(path).map_err(|error| Error::io(path, error))?;
115 if !metadata.file_type().is_file() {
116 return Err(Error::config(format!(
117 "declarative backend definition must be a regular file: {}",
118 path.display()
119 )));
120 }
121 if metadata.len() > MAX_DEFINITION_BYTES {
122 return Err(Error::config(format!(
123 "declarative backend definition exceeds {MAX_DEFINITION_BYTES} bytes: {}",
124 path.display()
125 )));
126 }
127 let input = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
128 Self::from_toml(&input).map_err(|error| {
129 Error::config(format!(
130 "invalid declarative backend {}: {error}",
131 path.display()
132 ))
133 })
134 }
135
136 fn from_definition(mut definition: BackendDefinition) -> Result<Self> {
137 if definition.schema != SCHEMA_VERSION {
138 return Err(Error::config(format!(
139 "unsupported declarative backend schema {}; expected {SCHEMA_VERSION}",
140 definition.schema
141 )));
142 }
143 validate_id(&definition.id)?;
144
145 let has_values = !definition.versions.values.is_empty();
146 let has_url = definition.versions.url.is_some();
147 if has_values == has_url {
148 return Err(Error::config(
149 "`versions` must set exactly one of `values` or `url`",
150 ));
151 }
152 let versions = if let Some(url) = definition.versions.url {
153 validate_url_template("versions.url", &url, &["id", "os", "arch", "libc"], false)?;
154 VersionSource::Url(url)
155 } else {
156 validate_versions(&mut definition.versions.values)?;
157 VersionSource::Static(definition.versions.values)
158 };
159
160 validate_url_template(
161 "archive.url",
162 &definition.archive.url,
163 &["id", "version", "os", "arch", "libc", "file"],
164 false,
165 )?;
166 validate_file_template(&definition.archive.file)?;
167 if !definition.archive.url.contains("{version}")
168 && !definition.archive.url.contains("{file}")
169 && !definition.archive.file.contains("{version}")
170 {
171 return Err(Error::config(
172 "`archive.url` or `archive.file` must vary by `{version}`",
173 ));
174 }
175 definition.archive.checksum.validate()?;
176
177 if definition.bin_paths.is_empty() {
178 return Err(Error::config("`bin_paths` must not be empty"));
179 }
180 let bin_paths = definition
181 .bin_paths
182 .iter()
183 .map(|path| validate_relative_path("bin path", path))
184 .collect::<Result<Vec<_>>>()?;
185
186 if definition.bin_names.is_empty() {
187 return Err(Error::config("`bin_names` must not be empty"));
188 }
189 for name in &definition.bin_names {
190 validate_basename("bin name", name)?;
191 }
192 for name in &definition.idiomatic_files {
193 validate_basename("idiomatic file", name)?;
194 }
195
196 let idiomatic_files = definition
200 .idiomatic_files
201 .into_iter()
202 .map(|name| -> &'static str { Box::leak(name.into_boxed_str()) })
203 .collect();
204
205 Ok(Self {
206 id: definition.id,
207 versions,
208 archive: definition.archive,
209 bin_paths,
210 bin_names: definition.bin_names,
211 idiomatic_files,
212 })
213 }
214
215 fn rendered_file(&self, platform: Platform, version: &str) -> Result<String> {
216 validate_version(version)?;
217 let rendered = render_template(
218 &self.archive.file,
219 &self.id,
220 Some(version),
221 platform,
222 None,
223 None,
224 );
225 validate_basename("rendered archive file", &rendered)?;
226 Ok(rendered)
227 }
228
229 fn rendered_url(
230 &self,
231 template: &str,
232 platform: Platform,
233 version: Option<&str>,
234 file: Option<&str>,
235 archive_url: Option<&str>,
236 ) -> Result<String> {
237 let rendered = render_template(template, &self.id, version, platform, file, archive_url);
238 validate_rendered_url(&rendered)?;
239 Ok(rendered)
240 }
241
242 async fn remote_versions(&self, ctx: &Ctx, template: &str) -> Result<Vec<VersionInfo>> {
243 let url = self.rendered_url(template, ctx.platform, None, None, None)?;
244 let body = crate::http::get_cached_text(ctx, &url).await?;
245 let mut values = body
246 .lines()
247 .map(str::trim)
248 .filter(|line| !line.is_empty() && !line.starts_with('#'))
249 .map(str::to_string)
250 .collect::<Vec<_>>();
251 validate_versions(&mut values)?;
252 Ok(values.into_iter().map(VersionInfo::stable).collect())
253 }
254
255 async fn checksum(
256 &self,
257 ctx: &Ctx,
258 version: &str,
259 file: &str,
260 archive_urls: &[String],
261 ) -> Result<Checksum> {
262 let definition = &self.archive.checksum;
263 let algo = definition.algorithm.into();
264 if let Some(value) = &definition.value {
265 validate_checksum(value, definition.algorithm)?;
266 return Ok(Checksum {
267 algo,
268 hex: value.clone(),
269 });
270 }
271
272 let template = definition
273 .url
274 .as_deref()
275 .ok_or_else(|| Error::config("checksum URL is missing"))?;
276 let mut last_error = None;
277 for archive_url in archive_urls {
278 let url = self.rendered_url(
279 template,
280 ctx.platform,
281 Some(version),
282 Some(file),
283 Some(archive_url),
284 )?;
285 match crate::http::get_cached_text(ctx, &url).await {
286 Ok(body) => {
287 let value = body.split_whitespace().next().ok_or_else(|| {
288 Error::other(format!("empty checksum response from {url}"))
289 })?;
290 validate_checksum(value, definition.algorithm)?;
291 return Ok(Checksum {
292 algo,
293 hex: value.to_string(),
294 });
295 }
296 Err(error) => last_error = Some(error),
297 }
298 }
299 Err(last_error.unwrap_or_else(|| Error::NoUsableSource {
300 tool: self.id.clone(),
301 tried: archive_urls.len(),
302 }))
303 }
304}
305
306impl ChecksumDefinition {
307 fn validate(&self) -> Result<()> {
308 if self.value.is_some() == self.url.is_some() {
309 return Err(Error::config(
310 "`archive.checksum` must set exactly one of `value` or `url`",
311 ));
312 }
313 if let Some(value) = &self.value {
314 validate_checksum(value, self.algorithm)?;
315 }
316 if let Some(url) = &self.url {
317 validate_url_template(
318 "archive.checksum.url",
319 url,
320 &["id", "version", "os", "arch", "libc", "file", "archive_url"],
321 true,
322 )?;
323 }
324 Ok(())
325 }
326}
327
328impl From<ArchiveKindDefinition> for ArchiveKind {
329 fn from(value: ArchiveKindDefinition) -> Self {
330 match value {
331 ArchiveKindDefinition::TarGz => ArchiveKind::TarGz,
332 ArchiveKindDefinition::TarXz => ArchiveKind::TarXz,
333 ArchiveKindDefinition::TarZst => ArchiveKind::TarZst,
334 ArchiveKindDefinition::Zip => ArchiveKind::Zip,
335 }
336 }
337}
338
339impl From<ChecksumAlgorithm> for HashAlgo {
340 fn from(value: ChecksumAlgorithm) -> Self {
341 match value {
342 ChecksumAlgorithm::Sha256 => HashAlgo::Sha256,
343 ChecksumAlgorithm::Sha512 => HashAlgo::Sha512,
344 ChecksumAlgorithm::Blake3 => HashAlgo::Blake3,
345 }
346 }
347}
348
349#[async_trait]
350impl Backend for DeclarativeBackend {
351 fn id(&self) -> &str {
352 &self.id
353 }
354
355 fn default_sources(&self) -> Vec<Source> {
356 let mut source = Source::official("declarative", &self.archive.url);
357 if let VersionSource::Url(url) = &self.versions {
358 source = source.with_index(url);
359 }
360 vec![source]
361 }
362
363 fn probe_url(&self, ctx: &Ctx, source: &Source) -> Option<String> {
364 source.index_url.as_deref().and_then(|template| {
365 self.rendered_url(template, ctx.platform, None, None, None)
366 .ok()
367 })
368 }
369
370 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
371 match &self.versions {
372 VersionSource::Static(values) => {
373 Ok(values.iter().cloned().map(VersionInfo::stable).collect())
374 }
375 VersionSource::Url(default_url) => {
376 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
377 let mut last_error = None;
378 for source in &sources {
379 let template = source.index_url.as_deref().unwrap_or(default_url);
380 match self.remote_versions(ctx, template).await {
381 Ok(versions) => return Ok(versions),
382 Err(error) => last_error = Some(error),
383 }
384 }
385 Err(last_error.unwrap_or_else(|| Error::NoUsableSource {
386 tool: self.id.clone(),
387 tried: sources.len(),
388 }))
389 }
390 }
391 }
392
393 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
394 let ctx = ictx.ctx;
395 validate_version(&tv.version)?;
396 let plan = if let Some(plan) =
397 pipeline::locked_install_plan(self.id(), tv, self.archive.strip_root)?
398 {
399 plan
400 } else {
401 let file = self.rendered_file(ctx.platform, &tv.version)?;
402 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
403 let urls = sources
404 .iter()
405 .map(|source| {
406 self.rendered_url(
407 &source.download_url,
408 ctx.platform,
409 Some(&tv.version),
410 Some(&file),
411 None,
412 )
413 })
414 .collect::<Result<Vec<_>>>()?;
415 let checksum = self.checksum(ctx, &tv.version, &file, &urls).await?;
416 InstallPlan {
417 tool: self.id.clone(),
418 version: tv.version.clone(),
419 urls,
420 file_name: file,
421 kind: self.archive.kind.into(),
422 checksum: Some(checksum),
423 strip_root: self.archive.strip_root,
424 subdir: None,
425 }
426 };
427 let pipeline_ctx = PipelineCtx {
428 client: &ctx.client,
429 dirs: &ctx.dirs,
430 cas: &ctx.cas,
431 link_mode: ctx.config.settings.link_mode,
432 show_progress: ctx.show_progress,
433 offline: ctx.config.settings.offline,
434 require_checksums: ctx.config.settings.require_checksums,
435 };
436 pipeline::run(&plan, &pipeline_ctx).await?;
437 Ok(())
438 }
439
440 async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
441 validate_version(&tv.version)?;
442 let directory = ctx.dirs.install_path(self.id(), &tv.version);
443 if directory.exists() {
444 std::fs::remove_dir_all(&directory).map_err(|error| Error::io(&directory, error))?;
445 }
446 Ok(())
447 }
448
449 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
450 validate_version(&tv.version)?;
451 let install = ctx.dirs.install_path(self.id(), &tv.version);
452 Ok(self
453 .bin_paths
454 .iter()
455 .map(|path| install.join(path))
456 .collect())
457 }
458
459 fn bin_names(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<String>> {
460 Ok(self.bin_names.clone())
461 }
462
463 fn idiomatic_files(&self) -> &[&str] {
464 &self.idiomatic_files
465 }
466}
467
468pub fn load_dir(directory: &Path) -> Result<Vec<DeclarativeBackend>> {
473 if !directory.exists() {
474 return Ok(Vec::new());
475 }
476 let mut paths = std::fs::read_dir(directory)
477 .map_err(|error| Error::io(directory, error))?
478 .map(|entry| {
479 entry
480 .map(|entry| entry.path())
481 .map_err(|error| Error::io(directory, error))
482 })
483 .collect::<Result<Vec<_>>>()?;
484 paths.retain(|path| path.extension().and_then(|ext| ext.to_str()) == Some("toml"));
485 paths.sort();
486 paths
487 .iter()
488 .map(|path| DeclarativeBackend::load_file(path))
489 .collect()
490}
491
492fn validate_id(id: &str) -> Result<()> {
493 let mut chars = id.chars();
494 if !chars
495 .next()
496 .map(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
497 .unwrap_or(false)
498 || !chars.all(|character| {
499 character.is_ascii_lowercase()
500 || character.is_ascii_digit()
501 || matches!(character, '-' | '_')
502 })
503 {
504 return Err(Error::config(format!(
505 "invalid declarative backend id `{id}`; use lowercase ASCII letters, digits, `-`, or `_`"
506 )));
507 }
508 if id == "github" || id.starts_with("github:") {
509 return Err(Error::config(
510 "declarative backend ids cannot use the reserved `github` namespace",
511 ));
512 }
513 Ok(())
514}
515
516fn validate_versions(versions: &mut Vec<String>) -> Result<()> {
517 if versions.is_empty() {
518 return Err(Error::config("version list must not be empty"));
519 }
520 if versions.len() > MAX_VERSIONS {
521 return Err(Error::config(format!(
522 "version list exceeds {MAX_VERSIONS} entries"
523 )));
524 }
525 for version in versions.iter() {
526 validate_version(version)?;
527 }
528 versions.sort_by(|left, right| {
529 match (semver::Version::parse(left), semver::Version::parse(right)) {
530 (Ok(left), Ok(right)) => left.cmp(&right),
531 (Ok(_), Err(_)) => std::cmp::Ordering::Less,
532 (Err(_), Ok(_)) => std::cmp::Ordering::Greater,
533 (Err(_), Err(_)) => left.cmp(right),
534 }
535 });
536 versions.dedup();
537 Ok(())
538}
539
540fn validate_version(version: &str) -> Result<()> {
541 if version.is_empty()
542 || version == "."
543 || version == ".."
544 || !version.chars().all(|character| {
545 character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_' | '+')
546 })
547 {
548 return Err(Error::config(format!(
549 "invalid declarative backend version `{version}`"
550 )));
551 }
552 Ok(())
553}
554
555fn validate_relative_path(label: &str, value: &str) -> Result<PathBuf> {
556 if value.is_empty() || value.contains('\\') || value.contains(':') {
557 return Err(Error::config(format!("invalid {label} `{value}`")));
558 }
559 let path = Path::new(value);
560 if path.is_absolute()
561 || path.components().any(|component| {
562 matches!(
563 component,
564 Component::ParentDir | Component::RootDir | Component::Prefix(_)
565 )
566 })
567 {
568 return Err(Error::config(format!(
569 "{label} must stay inside the install root: `{value}`"
570 )));
571 }
572 Ok(path.to_path_buf())
573}
574
575fn validate_basename(label: &str, value: &str) -> Result<()> {
576 if value.is_empty()
577 || value == "."
578 || value == ".."
579 || value.contains('/')
580 || value.contains('\\')
581 || value.contains(':')
582 {
583 return Err(Error::config(format!(
584 "{label} must be a single safe filename: `{value}`"
585 )));
586 }
587 Ok(())
588}
589
590fn validate_file_template(template: &str) -> Result<()> {
591 validate_template(
592 "archive.file",
593 template,
594 &["id", "version", "os", "arch", "libc"],
595 )?;
596 if template.contains('/') || template.contains('\\') || template.contains(':') {
597 return Err(Error::config(
598 "`archive.file` must render to a single filename",
599 ));
600 }
601 Ok(())
602}
603
604fn validate_url_template(
605 label: &str,
606 template: &str,
607 placeholders: &[&str],
608 allow_archive_url_prefix: bool,
609) -> Result<()> {
610 validate_template(label, template, placeholders)?;
611 if !(template.starts_with("https://")
612 || template.starts_with("http://")
613 || (allow_archive_url_prefix && template.starts_with("{archive_url}")))
614 {
615 return Err(Error::config(format!(
616 "`{label}` must use an HTTP or HTTPS URL"
617 )));
618 }
619 Ok(())
620}
621
622fn validate_template(label: &str, template: &str, placeholders: &[&str]) -> Result<()> {
623 if template.is_empty() {
624 return Err(Error::config(format!("`{label}` must not be empty")));
625 }
626 let mut remainder = template;
627 while let Some(open) = remainder.find('{') {
628 if remainder[..open].contains('}') {
629 return Err(Error::config(format!(
630 "`{label}` contains an unmatched `}}`"
631 )));
632 }
633 let after_open = &remainder[open + 1..];
634 let close = after_open
635 .find('}')
636 .ok_or_else(|| Error::config(format!("`{label}` contains an unmatched `{{`")))?;
637 let placeholder = &after_open[..close];
638 if !placeholders.contains(&placeholder) {
639 return Err(Error::config(format!(
640 "`{label}` uses unsupported placeholder `{{{placeholder}}}`"
641 )));
642 }
643 remainder = &after_open[close + 1..];
644 }
645 if remainder.contains('}') {
646 return Err(Error::config(format!(
647 "`{label}` contains an unmatched `}}`"
648 )));
649 }
650 Ok(())
651}
652
653fn validate_rendered_url(url: &str) -> Result<()> {
654 if url.contains('{') || url.contains('}') {
655 return Err(Error::config(format!(
656 "URL template left an unresolved placeholder: `{url}`"
657 )));
658 }
659 let parsed = reqwest::Url::parse(url)
660 .map_err(|error| Error::config(format!("invalid rendered URL `{url}`: {error}")))?;
661 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
662 return Err(Error::config(format!(
663 "rendered URL must use HTTP or HTTPS with a host: `{url}`"
664 )));
665 }
666 Ok(())
667}
668
669fn validate_checksum(value: &str, algorithm: ChecksumAlgorithm) -> Result<()> {
670 let expected_length = match algorithm {
671 ChecksumAlgorithm::Sha256 | ChecksumAlgorithm::Blake3 => 64,
672 ChecksumAlgorithm::Sha512 => 128,
673 };
674 if value.len() != expected_length
675 || !value.chars().all(|character| character.is_ascii_hexdigit())
676 {
677 return Err(Error::config(format!(
678 "invalid {:?} checksum; expected {expected_length} hexadecimal characters",
679 algorithm
680 )));
681 }
682 Ok(())
683}
684
685fn render_template(
686 template: &str,
687 id: &str,
688 version: Option<&str>,
689 platform: Platform,
690 file: Option<&str>,
691 archive_url: Option<&str>,
692) -> String {
693 let os = match platform.os {
694 Os::Linux => "linux",
695 Os::Macos => "macos",
696 Os::Windows => "windows",
697 };
698 let arch = match platform.arch {
699 Arch::X64 => "x64",
700 Arch::Arm64 => "arm64",
701 Arch::X86 => "x86",
702 Arch::Arm => "arm",
703 };
704 let libc = match platform.libc {
705 Libc::Glibc => "glibc",
706 Libc::Musl => "musl",
707 Libc::None => "none",
708 };
709 let mut rendered = template
710 .replace("{id}", id)
711 .replace("{os}", os)
712 .replace("{arch}", arch)
713 .replace("{libc}", libc);
714 if let Some(version) = version {
715 rendered = rendered.replace("{version}", version);
716 }
717 if let Some(file) = file {
718 rendered = rendered.replace("{file}", file);
719 }
720 if let Some(archive_url) = archive_url {
721 rendered = rendered.replace("{archive_url}", archive_url);
722 }
723 rendered
724}
725
726#[cfg(test)]
727mod tests {
728 use std::collections::{BTreeMap, HashMap};
729 use std::io::{Read, Write};
730 use std::net::TcpListener;
731 use std::sync::Arc;
732
733 use super::*;
734 use crate::config::{Config, Settings, SourcesConfig};
735 use crate::dirs::Dirs;
736 use crate::source::Selection;
737 use crate::store::link::LinkMode;
738 use crate::store::Cas;
739
740 const STATIC_FIXTURE: &str =
741 include_str!("../../tests/fixtures/declarative/static-backend.toml");
742
743 #[test]
744 fn parses_static_fixture() {
745 let backend = DeclarativeBackend::from_toml(STATIC_FIXTURE).unwrap();
746 assert_eq!(backend.id(), "acme");
747 assert_eq!(backend.bin_names, ["acme", "acmectl"]);
748 assert_eq!(backend.idiomatic_files(), [".acme-version"]);
749 }
750
751 #[test]
752 fn rejects_executable_hooks_and_unsafe_paths() {
753 let with_script = STATIC_FIXTURE.replace(
754 "id = \"acme\"",
755 "id = \"acme\"\ninstall_script = \"curl example.test | sh\"",
756 );
757 assert!(DeclarativeBackend::from_toml(&with_script).is_err());
758
759 let unsafe_path =
760 STATIC_FIXTURE.replace("bin_paths = [\"bin\"]", "bin_paths = [\"../bin\"]");
761 assert!(DeclarativeBackend::from_toml(&unsafe_path).is_err());
762 }
763
764 #[tokio::test]
765 async fn loads_lists_and_installs_from_local_fixtures() {
766 let temp = tempfile::tempdir().unwrap();
767 let dirs = isolated_dirs(temp.path());
768 dirs.ensure().unwrap();
769
770 let archive_path = temp.path().join("acme.tar.gz");
771 write_fixture_archive(&archive_path);
772 let archive = std::fs::read(&archive_path).unwrap();
773 let checksum = crate::pipeline::verify::hash_file(&archive_path, HashAlgo::Sha256).unwrap();
774 let versions = include_bytes!("../../tests/fixtures/declarative/versions.txt").to_vec();
775
776 let mut routes = HashMap::new();
777 routes.insert("/versions.txt".to_string(), versions);
778 routes.insert(
779 "/downloads/acme-1.2.3-linux-x64.tar.gz".to_string(),
780 archive,
781 );
782 routes.insert(
783 "/downloads/acme-1.2.3-linux-x64.tar.gz.sha256".to_string(),
784 format!("{checksum} acme-1.2.3-linux-x64.tar.gz\n").into_bytes(),
785 );
786 let (base_url, server) = serve(routes, 3);
787
788 let plugin_dir = dirs.config.join("plugins");
789 std::fs::create_dir_all(&plugin_dir).unwrap();
790 std::fs::write(
791 plugin_dir.join("acme.toml"),
792 format!(
793 r#"
794schema = 1
795id = "acme"
796bin_paths = ["bin"]
797bin_names = ["acme"]
798idiomatic_files = [".acme-version"]
799
800[versions]
801url = "{base_url}/versions.txt"
802
803[archive]
804url = "{base_url}/downloads/{{file}}"
805file = "acme-{{version}}-{{os}}-{{arch}}.tar.gz"
806kind = "tar.gz"
807strip_root = true
808
809[archive.checksum]
810algorithm = "sha256"
811url = "{{archive_url}}.sha256"
812"#
813 ),
814 )
815 .unwrap();
816
817 let registry = crate::backend::registry::Registry::load(&dirs).unwrap();
818 let backend = registry.get("acme").unwrap();
819 let ctx = test_ctx(dirs);
820 let versions = backend.list_remote_versions(&ctx).await.unwrap();
821 assert_eq!(
822 versions
823 .iter()
824 .map(|version| version.version.as_str())
825 .collect::<Vec<_>>(),
826 ["1.2.3"]
827 );
828
829 let tool_version = ToolVersion::new("acme", "1.2.3");
830 backend
831 .install(&InstallCtx { ctx: &ctx }, &tool_version)
832 .await
833 .unwrap();
834 let installed = ctx.dirs.install_path("acme", "1.2.3");
835 assert_eq!(
836 std::fs::read_to_string(installed.join("bin/acme")).unwrap(),
837 "fixture executable\n"
838 );
839 assert!(installed.join(".osdk-complete").is_file());
840 assert_eq!(
841 backend.bin_paths(&ctx, &tool_version).unwrap(),
842 [installed.join("bin")]
843 );
844 server.join().unwrap();
845 }
846
847 #[tokio::test]
848 async fn locked_artifact_reinstalls_offline_without_rendering_current_templates() {
849 let temp = tempfile::tempdir().unwrap();
850 let dirs = isolated_dirs(temp.path());
851 dirs.ensure().unwrap();
852
853 let file_name = "locked-acme.tar.gz";
854 let archive_path =
855 pipeline::artifact_cache_path(&dirs, "acme", "1.2.3", file_name).unwrap();
856 std::fs::create_dir_all(archive_path.parent().unwrap()).unwrap();
857 write_fixture_archive(&archive_path);
858 let checksum = pipeline::verify::hash_file(&archive_path, HashAlgo::Sha256).unwrap();
859
860 let backend = DeclarativeBackend::from_toml(
864 &STATIC_FIXTURE
865 .replace("acme-{version}-{os}-{arch}.tar.gz", "changed-{version}.zip")
866 .replace("kind = \"tar.gz\"", "kind = \"zip\""),
867 )
868 .unwrap();
869 let mut ctx = test_ctx(dirs);
870 ctx.config.settings.offline = true;
871 ctx.config.settings.require_checksums = true;
872 let mut tool_version = ToolVersion::new("acme", "1.2.3");
873 tool_version.options.extend(BTreeMap::from([
874 (
875 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
876 "https://unreachable.invalid/locked-acme.tar.gz".into(),
877 ),
878 (
879 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
880 file_name.into(),
881 ),
882 (
883 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
884 format!("sha256:{checksum}"),
885 ),
886 ]));
887
888 backend
889 .install(&InstallCtx { ctx: &ctx }, &tool_version)
890 .await
891 .unwrap();
892
893 let installed = ctx.dirs.install_path("acme", "1.2.3");
894 assert_eq!(
895 std::fs::read_to_string(installed.join("bin/acme")).unwrap(),
896 "fixture executable\n"
897 );
898 assert!(installed.join(".osdk-complete").is_file());
899 }
900
901 fn isolated_dirs(root: &Path) -> Dirs {
902 Dirs::resolve_from(|key| match key {
903 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
904 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
905 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
906 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
907 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
908 _ => None,
909 })
910 .unwrap()
911 }
912
913 fn test_ctx(dirs: Dirs) -> Ctx {
914 let settings = Settings {
915 link_mode: LinkMode::Copy,
916 ..Default::default()
917 };
918 let sources = SourcesConfig {
919 selection: Selection::Ordered,
920 ..Default::default()
921 };
922 Ctx {
923 cas: Arc::new(Cas::new(dirs.store.clone())),
924 dirs,
925 platform: Platform {
926 os: Os::Linux,
927 arch: Arch::X64,
928 libc: Libc::Glibc,
929 },
930 config: Config {
931 settings,
932 sources,
933 tools: Default::default(),
934 tool_configs: Default::default(),
935 global_tools: Default::default(),
936 global_tool_configs: Default::default(),
937 tool_origins: Default::default(),
938 aliases: Default::default(),
939 project_config_path: None,
940 },
941 client: reqwest::Client::new(),
942 show_progress: false,
943 }
944 }
945
946 fn write_fixture_archive(path: &Path) {
947 let file = std::fs::File::create(path).unwrap();
948 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
949 let mut archive = tar::Builder::new(encoder);
950 let contents = b"fixture executable\n";
951 let mut header = tar::Header::new_gnu();
952 header.set_size(contents.len() as u64);
953 header.set_mode(0o755);
954 header.set_cksum();
955 archive
956 .append_data(&mut header, "acme/bin/acme", &contents[..])
957 .unwrap();
958 archive.finish().unwrap();
959 archive.into_inner().unwrap().finish().unwrap();
960 }
961
962 fn serve(
963 routes: HashMap<String, Vec<u8>>,
964 request_count: usize,
965 ) -> (String, std::thread::JoinHandle<()>) {
966 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
967 let address = listener.local_addr().unwrap();
968 let server = std::thread::spawn(move || {
969 for _ in 0..request_count {
970 let (mut stream, _) = listener.accept().unwrap();
971 let mut request = Vec::new();
972 let mut buffer = [0u8; 1024];
973 while !request.ends_with(b"\r\n\r\n") {
974 let read = stream.read(&mut buffer).unwrap();
975 if read == 0 {
976 break;
977 }
978 request.extend_from_slice(&buffer[..read]);
979 }
980 let request = String::from_utf8_lossy(&request);
981 let path = request
982 .lines()
983 .next()
984 .and_then(|line| line.split_whitespace().nth(1))
985 .unwrap_or("/");
986 match routes.get(path) {
987 Some(body) => {
988 write!(
989 stream,
990 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
991 body.len()
992 )
993 .unwrap();
994 stream.write_all(body).unwrap();
995 }
996 None => {
997 write!(
998 stream,
999 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1000 )
1001 .unwrap();
1002 }
1003 }
1004 }
1005 });
1006 (format!("http://{address}"), server)
1007 }
1008}