Skip to main content

ignition_core/module/
mod.rs

1//! Pinned third-party `.modl` module artifacts (Phase 15) — the registry
2//! seam ([`ModuleSpec`], [`spec_for`]) and the on-disk cache ([`cache_root`],
3//! [`module_cache_dir`], [`cached_entry`]) that [`fetch`] verifies into.
4//!
5//! **Security boundary (read this before touching `fetch.rs`):** the
6//! sha256 check this module performs proves ONE thing — that the bytes on
7//! disk are byte-for-byte what the release feed currently serves for a
8//! given digest. It does NOT prove the module is validly signed by its
9//! author, and it is not a substitute for one. Ignition's own module-scan
10//! owns the signature check, at gateway load time, entirely outside
11//! `ign`'s control. An attacker who can both upload a malicious `.modl`
12//! AND control what digest the feed reports for it defeats this check by
13//! construction — that supply-chain trust boundary is accepted, not
14//! mitigated, here (see the phase's threat register, T-15-07).
15
16pub mod fetch;
17
18use std::path::{Path, PathBuf};
19
20use crate::error::CoreError;
21
22/// One third-party module's release-feed coordinates: where its releases
23/// live (`repo`) and how a version string becomes a release tag / asset
24/// filename (`tag_template` / `asset_template`, both substituting the
25/// literal token `{version}`).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct ModuleSpec {
28    /// Short, filesystem- and URL-safe identifier (`git`) — also the cache
29    /// subdirectory name under `<cache_root>/modules/`.
30    pub id: &'static str,
31    /// `owner/repo` on GitHub.
32    pub repo: &'static str,
33    /// The release tag shape, e.g. `v{version}`.
34    pub tag_template: &'static str,
35    /// The release asset filename shape, e.g. `Git-{version}-signed.modl`.
36    pub asset_template: &'static str,
37}
38
39impl ModuleSpec {
40    /// The release tag for `version` (substitutes `{version}` into
41    /// [`Self::tag_template`]).
42    pub fn tag(&self, version: &str) -> String {
43        self.tag_template.replace("{version}", version)
44    }
45
46    /// The expected release asset filename for `version` (substitutes
47    /// `{version}` into [`Self::asset_template`]).
48    pub fn asset_name(&self, version: &str) -> String {
49        self.asset_template.replace("{version}", version)
50    }
51}
52
53/// The `ignition-git-module` release feed (live-verified shape in
54/// 15-RESEARCH.md: tag `v{version}`, asset `Git-{version}-signed.modl`).
55pub const GIT_MODULE: ModuleSpec = ModuleSpec {
56    id: "git",
57    repo: "WhiskeyHouse/ignition-git-module",
58    tag_template: "v{version}",
59    asset_template: "Git-{version}-signed.modl",
60};
61
62/// The module registry — one entry today. Phase 16's second module
63/// registers through this seam; nothing else grows here in Phase 15.
64pub const MODULES: &[ModuleSpec] = &[GIT_MODULE];
65
66/// Look up a registered module by [`ModuleSpec::id`].
67pub fn spec_for(id: &str) -> Option<&'static ModuleSpec> {
68    MODULES.iter().find(|spec| spec.id == id)
69}
70
71/// Validate a module version string BEFORE it becomes a filesystem path
72/// segment or URL component (ASVS V5, T-15-03): 1–64 characters from
73/// `[A-Za-z0-9._+-]`, with no `..` window anywhere (path-traversal
74/// refusal — this also catches a lone `..`). Exit 2 `invalid_input` —
75/// a caller-supplied bad value is the usage class, so no new slug is
76/// warranted (the frozen taxonomy's existing precedent).
77pub fn validate_version(version: &str) -> Result<(), CoreError> {
78    let len_ok = (1..=64).contains(&version.len());
79    let chars_ok = version
80        .chars()
81        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'));
82    let no_traversal = !version.as_bytes().windows(2).any(|pair| pair == b"..");
83    if len_ok && chars_ok && no_traversal {
84        Ok(())
85    } else {
86        Err(CoreError::InvalidInput {
87            reason: format!(
88                "module version {version:?} is not a safe version string (expected \
89                 1-64 characters of [A-Za-z0-9._+-], no \"..\")"
90            ),
91        })
92    }
93}
94
95/// Validate a module id string the same way (T-15-03): 1–32 characters
96/// from `[a-z0-9-]`. Exit 2 `invalid_input`.
97pub fn validate_module_id(id: &str) -> Result<(), CoreError> {
98    let len_ok = (1..=32).contains(&id.len());
99    let chars_ok = id
100        .chars()
101        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
102    if len_ok && chars_ok {
103        Ok(())
104    } else {
105        Err(CoreError::InvalidInput {
106            reason: format!(
107                "module id {id:?} is not a safe identifier (expected 1-32 \
108                 characters of [a-z0-9-])"
109            ),
110        })
111    }
112}
113
114/// The module artifact cache root: `IGNITION_CLI_CACHE` env override
115/// first, else the platform cache dir (`directories::ProjectDirs::
116/// from("", "", "ignition-cli").cache_dir()`) — mirrors
117/// [`crate::config::config_path`]'s `IGNITION_CLI_CONFIG` shape exactly,
118/// including its `expect` on a missing home directory. The env override
119/// is what lets tests point at a `tempfile::TempDir` instead of the
120/// developer's real cache.
121pub fn cache_root() -> PathBuf {
122    std::env::var_os("IGNITION_CLI_CACHE")
123        .map(PathBuf::from)
124        .unwrap_or_else(|| {
125            let dirs = directories::ProjectDirs::from("", "", "ignition-cli")
126                .expect("no home directory discoverable");
127            dirs.cache_dir().to_path_buf()
128        })
129}
130
131/// The per-module cache directory: `<root>/modules/<module_id>`.
132pub fn module_cache_dir(root: &Path, module_id: &str) -> PathBuf {
133    root.join("modules").join(module_id)
134}
135
136/// A verified artifact already sitting in the cache.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CachedArtifact {
139    /// Full path to the cached `.modl` file.
140    pub path: PathBuf,
141    /// The lowercase hex sha256 digest encoded in the filename.
142    pub digest_sha256: String,
143    /// On-disk size in bytes.
144    pub bytes: u64,
145}
146
147/// `true` when `s` is exactly 64 lowercase hex characters — the full
148/// digest, never a truncated prefix or an uppercase rendering (D-08).
149fn is_full_lowercase_sha256_hex(s: &str) -> bool {
150    s.len() == 64
151        && s.chars()
152            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
153}
154
155/// Look up a cached, verified artifact for `module_id`/`version` — keyed
156/// by VERSION ALONE (D-08): a hit never needs the feed, which is what
157/// makes SC-3 (no network on a repeat fetch) and SC-4 (offline-with-cache
158/// succeeds) possible. Accepts only filenames that strip to EXACTLY
159/// `<version>-` + 64 lowercase hex + `.modl` — no regex, just
160/// `strip_prefix`/`strip_suffix` plus a length + hex-digit check, which
161/// also disambiguates a prerelease version whose own string contains a
162/// hyphen (e.g. `2.3.4-rc1` never satisfies the fixed-length digest
163/// check when only `2.3.4` was requested).
164///
165/// A missing cache directory is `None`, never an error. Selection is
166/// DETERMINISTIC — never "whatever `read_dir` yielded first": the newest
167/// entry by modified time wins, ties broken by filename order. One
168/// version can legitimately own two entries once 15-02's
169/// accept-the-change override persists a second digest alongside the
170/// first.
171pub fn cached_entry(root: &Path, module_id: &str, version: &str) -> Option<CachedArtifact> {
172    let dir = module_cache_dir(root, module_id);
173    let entries = std::fs::read_dir(&dir).ok()?;
174    let prefix = format!("{version}-");
175
176    let mut candidates: Vec<(std::time::SystemTime, String, PathBuf, u64)> = Vec::new();
177    for entry in entries.flatten() {
178        let path = entry.path();
179        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
180            continue;
181        };
182        let Some(rest) = file_name.strip_prefix(&prefix) else {
183            continue;
184        };
185        let Some(digest) = rest.strip_suffix(".modl") else {
186            continue;
187        };
188        if !is_full_lowercase_sha256_hex(digest) {
189            continue;
190        }
191        let Ok(metadata) = entry.metadata() else {
192            continue;
193        };
194        let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
195        candidates.push((modified, file_name.to_string(), path, metadata.len()));
196    }
197
198    // Newest modified time wins; ties broken by filename order — never
199    // directory-iteration order, which is unspecified.
200    candidates.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
201
202    candidates
203        .into_iter()
204        .next()
205        .map(|(_, file_name, path, bytes)| {
206            let digest = file_name
207                .strip_prefix(&prefix)
208                .and_then(|rest| rest.strip_suffix(".modl"))
209                .expect("candidate matched the prefix/suffix check above")
210                .to_string();
211            CachedArtifact {
212                path,
213                digest_sha256: digest,
214                bytes,
215            }
216        })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{
222        GIT_MODULE, MODULES, cached_entry, module_cache_dir, spec_for, validate_module_id,
223        validate_version,
224    };
225
226    #[test]
227    fn module_spec_substitutes_version() {
228        assert_eq!(GIT_MODULE.tag("2.3.4"), "v2.3.4");
229        assert_eq!(GIT_MODULE.asset_name("2.3.4"), "Git-2.3.4-signed.modl");
230    }
231
232    #[test]
233    fn registry_finds_git_module() {
234        assert!(MODULES.contains(&GIT_MODULE));
235        assert_eq!(spec_for("git").map(|spec| spec.id), Some("git"));
236        assert!(spec_for("nope").is_none());
237    }
238
239    // cache_root()'s IGNITION_CLI_CACHE env-override seam is exercised by
240    // `cache_root_honours_env_override` in
241    // tests/module_fetch_contract.rs (D-08) — kept there, not duplicated
242    // here, per the plan's file assignment.
243
244    #[test]
245    fn cached_entry_is_none_for_missing_dir() {
246        let dir = tempfile::tempdir().expect("tempdir");
247        assert!(cached_entry(dir.path(), "git", "2.3.4").is_none());
248    }
249
250    #[test]
251    fn module_cache_dir_shape() {
252        let root = std::path::Path::new("/tmp/cache-root");
253        assert_eq!(
254            module_cache_dir(root, "git"),
255            std::path::PathBuf::from("/tmp/cache-root/modules/git")
256        );
257    }
258
259    #[test]
260    fn validate_version_accepts_well_formed() {
261        assert!(validate_version("2.3.4").is_ok());
262        assert!(validate_version("2.3.4-rc1+build.1").is_ok());
263    }
264
265    #[test]
266    fn validate_version_rejects_hostile_input() {
267        for bad in [
268            "../escape",
269            "a/b",
270            "with\0null",
271            "back\\slash",
272            "2..3",
273            "",
274            &"x".repeat(65),
275        ] {
276            let err = validate_version(bad).expect_err(&format!("{bad:?} must be refused"));
277            assert_eq!(err.code(), "invalid_input");
278            assert_eq!(err.exit_code(), 2);
279        }
280    }
281
282    #[test]
283    fn validate_module_id_accepts_registry_entries() {
284        for spec in MODULES {
285            assert!(
286                validate_module_id(spec.id).is_ok(),
287                "{} must validate",
288                spec.id
289            );
290        }
291    }
292
293    #[test]
294    fn validate_module_id_rejects_hostile_input() {
295        for bad in ["../nope", "a/b", "UPPER", "", &"x".repeat(33)] {
296            let err = validate_module_id(bad).expect_err(&format!("{bad:?} must be refused"));
297            assert_eq!(err.code(), "invalid_input");
298            assert_eq!(err.exit_code(), 2);
299        }
300    }
301}