Skip to main content

osdk_core/backend/
dynamic.rs

1//! Factories for backends whose complete id includes a namespace-specific value.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::{collections::BTreeMap, fmt::Write as _};
6
7use super::Backend;
8use crate::dirs::{Dirs, InstallLocator};
9use crate::error::{Error, Result};
10use crate::inventory::{DynamicToolBin, DynamicToolManifest};
11use crate::tool::{InstallDependency, InstallIdentity, InstallScope};
12
13const FINGERPRINT_DOMAIN: &[u8] = b"osdk-dynamic-options-v1";
14const INSTALL_ID_DOMAIN: &[u8] = b"osdk-install-identity-v2";
15
16/// Canonical public options that contribute to a dynamic tool's install
17/// identity. Internal lock replay metadata is deliberately kept separate.
18pub fn identity_options(
19    id: &str,
20    options: &BTreeMap<String, String>,
21) -> Result<BTreeMap<String, String>> {
22    let id = crate::tool::ToolId::parse(id)?;
23    crate::tool::dynamic_identity_options(&id, options).map(crate::tool::CanonicalOptions::into_map)
24}
25
26/// Validate namespace-specific relationships between otherwise valid public
27/// options. This is kept separate from the persisted identity projection so a
28/// sensitive acquisition location can be omitted while its required content
29/// digest still protects reuse.
30pub fn validate_options(id: &str, options: &BTreeMap<String, String>) -> Result<()> {
31    identity_options(id, options).map(|_| ())
32}
33
34/// Stable, order-independent identity for dynamic backend options.
35pub fn option_fingerprint(id: &str, options: &BTreeMap<String, String>) -> Result<String> {
36    let identity = identity_options(id, options)?;
37    fingerprint_canonical_options(id, &identity)
38}
39
40/// Fingerprint an already-canonical identity projection. This separate path is
41/// important when loading an inventory: normalizing a canonical value twice
42/// must never silently change the identity being verified.
43pub(crate) fn fingerprint_canonical_options(
44    id: &str,
45    options: &BTreeMap<String, String>,
46) -> Result<String> {
47    let id = crate::tool::ToolId::parse(id)?;
48    crate::tool::validate_canonical_identity_options(&id, options)?;
49    let id = id.to_string();
50    let mut hasher = blake3::Hasher::new();
51    update_length_prefixed(&mut hasher, FINGERPRINT_DOMAIN);
52    update_length_prefixed(&mut hasher, id.as_bytes());
53    for (key, value) in options {
54        update_length_prefixed(&mut hasher, key.as_bytes());
55        update_length_prefixed(&mut hasher, value.as_bytes());
56    }
57    let mut fingerprint = String::from("b3-v1:");
58    write!(&mut fingerprint, "{}", hasher.finalize().to_hex())
59        .expect("writing into a String cannot fail");
60    Ok(fingerprint)
61}
62
63/// Stable identity of the complete materialized install contract.
64pub fn install_identity_fingerprint(identity: &InstallIdentity) -> Result<String> {
65    let tool = crate::tool::ToolId::parse(&identity.tool)?;
66    if !tool.is_dynamic() || tool.to_string() != identity.tool {
67        return Err(Error::config(
68            "install identity contains a non-canonical dynamic tool id",
69        ));
70    }
71    crate::tool::validate_canonical_identity_options(&tool, &identity.material_options)?;
72
73    let mut hasher = blake3::Hasher::new();
74    update_length_prefixed(&mut hasher, INSTALL_ID_DOMAIN);
75    update_length_prefixed(&mut hasher, identity.tool.as_bytes());
76    update_length_prefixed(&mut hasher, identity.version.as_bytes());
77    update_length_prefixed(&mut hasher, identity.platform.as_bytes());
78    update_length_prefixed(
79        &mut hasher,
80        serde_json::to_string(&identity.scope)?.as_bytes(),
81    );
82    update_map(&mut hasher, &identity.material_options);
83    update_length_prefixed(
84        &mut hasher,
85        &(identity.dependencies.len() as u64).to_le_bytes(),
86    );
87    for dependency in &identity.dependencies {
88        update_dependency(&mut hasher, dependency)?;
89    }
90    update_map(&mut hasher, &identity.materials);
91    let mut fingerprint = String::from("b3-v2:");
92    write!(&mut fingerprint, "{}", hasher.finalize().to_hex())
93        .expect("writing into a String cannot fail");
94    Ok(fingerprint)
95}
96
97fn update_map(hasher: &mut blake3::Hasher, values: &BTreeMap<String, String>) {
98    update_length_prefixed(hasher, &(values.len() as u64).to_le_bytes());
99    for (key, value) in values {
100        update_length_prefixed(hasher, key.as_bytes());
101        update_length_prefixed(hasher, value.as_bytes());
102    }
103}
104
105fn update_dependency(hasher: &mut blake3::Hasher, dependency: &InstallDependency) -> Result<()> {
106    update_length_prefixed(hasher, serde_json::to_string(&dependency.kind)?.as_bytes());
107    update_length_prefixed(hasher, dependency.id.as_bytes());
108    update_length_prefixed(hasher, dependency.version.as_bytes());
109    match dependency.identity.as_deref() {
110        Some(identity) => {
111            update_length_prefixed(hasher, b"some");
112            update_length_prefixed(hasher, identity.as_bytes());
113        }
114        None => update_length_prefixed(hasher, b"none"),
115    }
116    Ok(())
117}
118
119fn update_length_prefixed(hasher: &mut blake3::Hasher, bytes: &[u8]) {
120    hasher.update(&(bytes.len() as u64).to_le_bytes());
121    hasher.update(bytes);
122}
123
124/// Hold a dynamic install's identity lock without blocking an async executor.
125pub(crate) async fn acquire_install_lock(
126    locator: &InstallLocator,
127    namespace: &str,
128) -> Result<crate::lock::FileLock> {
129    let path = locator.lock_path().to_path_buf();
130    let namespace = namespace.to_string();
131    tokio::task::spawn_blocking(move || crate::lock::FileLock::acquire(path))
132        .await
133        .map_err(|error| Error::other(format!("{namespace} install lock task failed: {error}")))?
134}
135
136/// Validate the namespace-neutral durable state of a completed artifact install.
137pub(crate) fn artifact_install_candidate_is_valid(
138    dirs: &Dirs,
139    install_root: &Path,
140    identity: &InstallIdentity,
141) -> Result<bool> {
142    if identity.scope != InstallScope::Isolated
143        || !is_regular_file(&install_root.join(".osdk-complete"))
144        || !is_regular_file(&DynamicToolManifest::manifest_path(install_root))
145        || !is_regular_file(&install_root.join(".osdk-artifact.json"))
146    {
147        return Ok(false);
148    }
149    let locator = InstallLocator::new(dirs, identity.clone())?;
150    if !locator.validates_existing_install_root(install_root) {
151        return Ok(false);
152    }
153    reject_symlinks(install_root)?;
154    let manifest = DynamicToolManifest::load(install_root)?;
155    if !manifest.matches_identity(identity) {
156        return Err(Error::other(format!(
157            "dynamic install identity mismatch at {}",
158            DynamicToolManifest::manifest_path(install_root).display()
159        )));
160    }
161    let receipt = crate::pipeline::artifact_receipt_at(install_root)
162        .ok_or_else(|| Error::other("dynamic artifact receipt is missing or invalid"))?;
163    if receipt.file_name
164        != identity
165            .materials
166            .get("artifact-file")
167            .cloned()
168            .unwrap_or_default()
169        || !checksum_matches(
170            receipt.checksum.as_deref(),
171            identity
172                .materials
173                .get("artifact-checksum")
174                .map(String::as_str),
175        )
176    {
177        return Err(Error::other(format!(
178            "dynamic artifact receipt does not match install identity at {}",
179            install_root.display()
180        )));
181    }
182    let canonical_root =
183        dunce::canonicalize(install_root).map_err(|error| Error::io(install_root, error))?;
184    for bin in &manifest.bins {
185        let path = install_root.join(&bin.path);
186        let metadata = std::fs::symlink_metadata(&path).map_err(|error| Error::io(&path, error))?;
187        let canonical = dunce::canonicalize(&path).map_err(|error| Error::io(&path, error))?;
188        if metadata.file_type().is_symlink()
189            || !metadata.is_file()
190            || !canonical.starts_with(&canonical_root)
191        {
192            return Err(Error::other(format!(
193                "dynamic inventory bin `{}` does not resolve inside {}",
194                bin.name,
195                install_root.display()
196            )));
197        }
198    }
199    Ok(true)
200}
201
202/// Reject links before publishing an install sourced from an arbitrary URL.
203pub(crate) fn reject_symlinks(root: &Path) -> Result<()> {
204    for entry in walkdir::WalkDir::new(root).follow_links(false) {
205        let entry = entry.map_err(|error| Error::other(format!("walkdir: {error}")))?;
206        if entry.file_type().is_symlink() {
207            return Err(Error::other(format!(
208                "artifact contains a forbidden symlink: {}",
209                entry.path().display()
210            )));
211        }
212    }
213    Ok(())
214}
215
216/// Atomically publish inventory before exposing the completion marker.
217pub(crate) fn finalize_artifact_install(locator: &InstallLocator) -> Result<()> {
218    let root = locator.install_root();
219    let result = (|| {
220        reject_symlinks(root)?;
221        let mut manifest = DynamicToolManifest::from_identity(locator.identity().clone())?;
222        let bin = root.join("bin");
223        let directories = if bin.is_dir() {
224            vec![bin, root.to_path_buf()]
225        } else {
226            vec![root.to_path_buf()]
227        };
228        let canonical_root = dunce::canonicalize(root).map_err(|error| Error::io(root, error))?;
229        for directory in directories {
230            for name in super::bin_names_in_dirs(std::slice::from_ref(&directory)) {
231                let Some(path) = executable_in_dir(&directory, &name) else {
232                    continue;
233                };
234                let canonical =
235                    dunce::canonicalize(&path).map_err(|error| Error::io(&path, error))?;
236                let relative = canonical.strip_prefix(&canonical_root).map_err(|_| {
237                    Error::other(format!(
238                        "installed dynamic binary `{name}` resolves outside {}",
239                        root.display()
240                    ))
241                })?;
242                manifest.bins.push(DynamicToolBin {
243                    name,
244                    path: relative.to_string_lossy().replace('\\', "/"),
245                });
246            }
247        }
248        manifest
249            .bins
250            .sort_by(|left, right| left.name.cmp(&right.name));
251        manifest
252            .bins
253            .dedup_by(|left, right| left.name == right.name);
254        if manifest.bins.is_empty() {
255            return Err(Error::other(
256                "artifact installation did not publish any executable",
257            ));
258        }
259        manifest.write_atomic(root)?;
260        std::fs::write(root.join(".osdk-complete"), b"")
261            .map_err(|error| Error::io(root.join(".osdk-complete"), error))
262    })();
263    if result.is_err() {
264        let _ = std::fs::remove_dir_all(root);
265    }
266    result
267}
268
269fn checksum_matches(actual: Option<&str>, expected: Option<&str>) -> bool {
270    match (actual, expected) {
271        (Some(actual), Some(expected)) => {
272            let Ok(actual) = crate::pipeline::parse_checksum(actual) else {
273                return false;
274            };
275            let Ok(expected) = crate::pipeline::parse_checksum(expected) else {
276                return false;
277            };
278            actual.algo == expected.algo && actual.hex.eq_ignore_ascii_case(&expected.hex)
279        }
280        _ => false,
281    }
282}
283
284fn is_regular_file(path: &Path) -> bool {
285    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
286}
287
288fn executable_in_dir(directory: &Path, name: &str) -> Option<PathBuf> {
289    #[cfg(windows)]
290    let candidates = [format!("{name}.exe")];
291    #[cfg(not(windows))]
292    let candidates = [name.to_string()];
293    candidates
294        .into_iter()
295        .map(|candidate| directory.join(candidate))
296        .find(|candidate| candidate.is_file())
297}
298
299/// Constructs a backend for a registered dynamic namespace.
300pub(super) trait DynamicBackendFactory: Send + Sync {
301    /// Namespace before the `:` in a dynamic backend id.
302    fn prefix(&self) -> &'static str;
303
304    /// Parse and construct a backend from its complete namespaced id.
305    fn create(&self, id: &str) -> Option<Arc<dyn Backend>>;
306}
307
308pub(super) fn builtin_factories() -> Vec<Arc<dyn DynamicBackendFactory>> {
309    vec![
310        Arc::new(CargoBackendFactory),
311        Arc::new(GoBackendFactory),
312        Arc::new(GithubBackendFactory),
313        Arc::new(NpmBackendFactory),
314        Arc::new(HttpBackendFactory),
315    ]
316}
317
318struct GoBackendFactory;
319
320impl DynamicBackendFactory for GoBackendFactory {
321    fn prefix(&self) -> &'static str {
322        "go"
323    }
324
325    fn create(&self, id: &str) -> Option<Arc<dyn Backend>> {
326        crate::backend::go_package::GoPackageBackend::from_id(id)
327            .map(|backend| Arc::new(backend) as Arc<dyn Backend>)
328    }
329}
330
331struct CargoBackendFactory;
332
333impl DynamicBackendFactory for CargoBackendFactory {
334    fn prefix(&self) -> &'static str {
335        "cargo"
336    }
337
338    fn create(&self, id: &str) -> Option<Arc<dyn Backend>> {
339        crate::backend::cargo_package::CargoPackageBackend::from_id(id)
340            .map(|backend| Arc::new(backend) as Arc<dyn Backend>)
341    }
342}
343
344struct GithubBackendFactory;
345
346impl DynamicBackendFactory for GithubBackendFactory {
347    fn prefix(&self) -> &'static str {
348        "github"
349    }
350
351    fn create(&self, id: &str) -> Option<Arc<dyn Backend>> {
352        crate::backend::github::GithubBackend::from_id(id)
353            .map(|backend| Arc::new(backend) as Arc<dyn Backend>)
354    }
355}
356
357struct NpmBackendFactory;
358
359impl DynamicBackendFactory for NpmBackendFactory {
360    fn prefix(&self) -> &'static str {
361        "npm"
362    }
363
364    fn create(&self, id: &str) -> Option<Arc<dyn Backend>> {
365        crate::backend::npm_package::NpmPackageBackend::from_id(id)
366            .map(|backend| Arc::new(backend) as Arc<dyn Backend>)
367    }
368}
369
370struct HttpBackendFactory;
371
372impl DynamicBackendFactory for HttpBackendFactory {
373    fn prefix(&self) -> &'static str {
374        "http"
375    }
376
377    fn create(&self, id: &str) -> Option<Arc<dyn Backend>> {
378        crate::backend::http::HttpBackend::from_id(id)
379            .map(|backend| Arc::new(backend) as Arc<dyn Backend>)
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn fingerprints_are_order_independent_and_ignore_locked_metadata() {
389        let first = BTreeMap::from([
390            ("installer".into(), "aube".into()),
391            ("allow_builds".into(), "Sharp, esbuild, sharp".into()),
392        ]);
393        let mut second = BTreeMap::new();
394        second.insert("allow_builds".into(), "esbuild,sharp".into());
395        second.insert("installer".into(), "aube".into());
396        second.insert("__osdk_node_version".into(), "24.1.0".into());
397
398        assert_eq!(
399            option_fingerprint("npm:prettier", &first).unwrap(),
400            option_fingerprint("npm:prettier", &second).unwrap()
401        );
402        assert_eq!(
403            identity_options("npm:prettier", &first).unwrap()["allow_builds"],
404            "esbuild,sharp"
405        );
406    }
407
408    #[test]
409    fn npm_default_spellings_share_an_identity() {
410        let defaults = BTreeMap::new();
411        for options in [
412            BTreeMap::from([("allow_builds".into(), "false".into())]),
413            BTreeMap::from([("allow_builds".into(), "  ".into())]),
414            BTreeMap::from([("installer".into(), "AUTO".into())]),
415        ] {
416            assert_eq!(
417                option_fingerprint("npm:prettier", &defaults).unwrap(),
418                option_fingerprint("npm:prettier", &options).unwrap()
419            );
420        }
421    }
422
423    #[test]
424    fn github_identity_canonicalizes_consumed_aliases_and_omits_catalog_location() {
425        let first = BTreeMap::from([
426            ("os".into(), "darwin".into()),
427            ("arch".into(), "amd64".into()),
428            ("bins".into(), "bin/a, bin/b".into()),
429            (
430                "catalog-url".into(),
431                "https://example.test/catalog.json".into(),
432            ),
433            ("catalog-sha256".into(), "A".repeat(64)),
434        ]);
435        let second = BTreeMap::from([
436            ("os".into(), "macos".into()),
437            ("arch".into(), "x64".into()),
438            ("bins".into(), "bin/a,bin/b".into()),
439            (
440                "catalog-url".into(),
441                "https://mirror.example.test/catalog.json".into(),
442            ),
443            ("catalog-sha256".into(), "a".repeat(64)),
444        ]);
445        let identity = identity_options("github:owner/repo", &first).unwrap();
446        assert!(!identity.contains_key("catalog-url"));
447        assert_eq!(
448            option_fingerprint("github:owner/repo", &first).unwrap(),
449            option_fingerprint("github:owner/repo", &second).unwrap()
450        );
451    }
452
453    #[test]
454    fn github_bin_and_bins_spellings_share_an_identity() {
455        let singular = BTreeMap::from([("bin".into(), "bin/tool".into())]);
456        let plural = BTreeMap::from([("bins".into(), " bin/tool ".into())]);
457        assert_eq!(
458            option_fingerprint("github:owner/repo", &singular).unwrap(),
459            option_fingerprint("github:owner/repo", &plural).unwrap()
460        );
461    }
462
463    #[test]
464    fn github_bin_and_bins_remain_mutually_exclusive() {
465        let options = BTreeMap::from([
466            ("bin".into(), "bin/tool".into()),
467            ("bins".into(), "bin/tool".into()),
468        ]);
469        assert!(identity_options("github:owner/repo", &options)
470            .unwrap_err()
471            .to_string()
472            .contains("mutually exclusive"));
473    }
474
475    #[test]
476    fn github_catalog_url_rejects_userinfo_credentials() {
477        let options = BTreeMap::from([(
478            "catalog-url".into(),
479            "https://user:secret@example.test/catalog.json".into(),
480        )]);
481        assert!(identity_options("github:owner/repo", &options)
482            .unwrap_err()
483            .to_string()
484            .contains("must not contain credentials"));
485    }
486
487    #[test]
488    fn github_catalog_url_rejects_signed_queries() {
489        let options = BTreeMap::from([
490            (
491                "catalog-url".into(),
492                "https://example.test/catalog.json?token=secret".into(),
493            ),
494            ("catalog-sha256".into(), "a".repeat(64)),
495        ]);
496        assert!(identity_options("github:owner/repo", &options)
497            .unwrap_err()
498            .to_string()
499            .contains("query or fragment"));
500    }
501
502    #[test]
503    fn github_catalog_location_requires_a_content_digest() {
504        let options = BTreeMap::from([(
505            "catalog-url".into(),
506            "https://example.test/catalog.json".into(),
507        )]);
508        assert!(validate_options("github:owner/repo", &options)
509            .unwrap_err()
510            .to_string()
511            .contains("catalog-sha256 is required"));
512    }
513
514    #[test]
515    fn canonical_identity_fingerprint_rejects_second_normalization() {
516        let non_canonical = BTreeMap::from([("allow_builds".into(), "sharp,esbuild".into())]);
517        assert!(fingerprint_canonical_options("npm:prettier", &non_canonical).is_err());
518        let identity = identity_options("npm:prettier", &non_canonical).unwrap();
519        assert!(fingerprint_canonical_options("npm:prettier", &identity).is_ok());
520    }
521
522    #[test]
523    fn fingerprints_change_with_identity_and_options() {
524        let options = BTreeMap::from([("rename".into(), "rg".into())]);
525        assert_ne!(
526            option_fingerprint("github:owner/one", &options).unwrap(),
527            option_fingerprint("github:owner/two", &options).unwrap()
528        );
529        let changed = BTreeMap::from([("rename".into(), "ripgrep".into())]);
530        assert_ne!(
531            option_fingerprint("github:owner/one", &options).unwrap(),
532            option_fingerprint("github:owner/one", &changed).unwrap()
533        );
534    }
535
536    #[test]
537    fn unknown_public_options_fail_before_installation() {
538        let options = BTreeMap::from([("token".into(), "secret".into())]);
539        assert!(option_fingerprint("github:owner/repo", &options).is_err());
540        assert!(option_fingerprint("npm:prettier", &options).is_err());
541    }
542}