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    /// The value the GATEWAY's own acceptance variables
38    /// (`ACCEPT_MODULE_CERTS`/`ACCEPT_MODULE_LICENSES`) must name — read
39    /// from the artifact's own `module.xml`, NOT derivable from
40    /// [`Self::id`] (Phase 16, D-03). The two ids are different SHAPES:
41    /// `id` is a short cache-dir/mount-basename slug (`[a-z0-9-]{1,32}`,
42    /// `validate_module_id`-constrained), while `gateway_module_id` may be
43    /// reverse-DNS (`com.axone_io.ignition.git`) or a bare slug
44    /// (`project-scan-endpoint`) depending on the module — never
45    /// interchanged.
46    pub gateway_module_id: &'static str,
47}
48
49impl ModuleSpec {
50    /// The release tag for `version` (substitutes `{version}` into
51    /// [`Self::tag_template`]).
52    pub fn tag(&self, version: &str) -> String {
53        self.tag_template.replace("{version}", version)
54    }
55
56    /// The expected release asset filename for `version` (substitutes
57    /// `{version}` into [`Self::asset_template`]).
58    pub fn asset_name(&self, version: &str) -> String {
59        self.asset_template.replace("{version}", version)
60    }
61}
62
63/// The `ignition-git-module` release feed (live-verified shape in
64/// 15-RESEARCH.md: tag `v{version}`, asset `Git-{version}-signed.modl`).
65pub const GIT_MODULE: ModuleSpec = ModuleSpec {
66    id: "git",
67    repo: "WhiskeyHouse/ignition-git-module",
68    tag_template: "v{version}",
69    asset_template: "Git-{version}-signed.modl",
70    // Verified from the artifact's own module.xml (Phase 16, D-03):
71    // reverse-DNS, requires Ignition 8.3.1.
72    gateway_module_id: "com.axone_io.ignition.git",
73};
74
75/// The `ignition-project-scan-endpoint` release feed (Phase 16,
76/// 16-02-PLAN.md — verified live this session, not re-derived):
77/// `bw-design-group/ignition-project-scan-endpoint`, release `v1.0.0`,
78/// asset `Project-Scan-Endpoint.modl` (30,150 bytes, sha256
79/// `f0ffb9cf90f0dfb55647080399c4699a32d6ded0387e0142b7c1cb6212806722`),
80/// signed (the release archive carries `certificates.p7b` and
81/// `signatures.properties`).
82///
83/// Deliberately the phase's falsification target for SC-5: a DIFFERENT
84/// GitHub org than [`GIT_MODULE`], an asset filename that carries NO
85/// version token (the same filename is published on every release, so
86/// [`ModuleSpec::asset_name`]'s `{version}` substitution is a harmless
87/// no-op here — no special-casing was needed to support it), and a
88/// BARE-SLUG `gateway_module_id` rather than reverse-DNS.
89///
90/// `id`/`gateway_module_id` are verified from the artifact's own
91/// `module.xml` (`<id>project-scan-endpoint</id>`), NOT derived from
92/// GitHub release metadata — release metadata only tells you the
93/// tag/asset shape, never what the gateway's own acceptance variables
94/// must name. Requires Ignition **8.3.0** (`module.xml`'s
95/// `requiredIgnitionVersion`) — one minor version LOWER than
96/// [`GIT_MODULE`]'s 8.3.1 floor; a rig combining both modules needs the
97/// higher of the two.
98pub const PROJECT_SCAN_ENDPOINT: ModuleSpec = ModuleSpec {
99    id: "project-scan-endpoint",
100    repo: "bw-design-group/ignition-project-scan-endpoint",
101    tag_template: "v{version}",
102    asset_template: "Project-Scan-Endpoint.modl",
103    gateway_module_id: "project-scan-endpoint",
104};
105
106/// The module registry — TWO entries as of Phase 16, Plan 02:
107/// [`GIT_MODULE`] (Phase 15) and [`PROJECT_SCAN_ENDPOINT`], registered
108/// through the identical seam to prove the registry abstraction holds
109/// (SC-5) rather than merely asserting it. A third module registers
110/// here the same way; nothing else in the provisioning path should ever
111/// need to change for it.
112pub const MODULES: &[ModuleSpec] = &[GIT_MODULE, PROJECT_SCAN_ENDPOINT];
113
114/// Look up a registered module by [`ModuleSpec::id`].
115pub fn spec_for(id: &str) -> Option<&'static ModuleSpec> {
116    MODULES.iter().find(|spec| spec.id == id)
117}
118
119/// Validate a module version string BEFORE it becomes a filesystem path
120/// segment or URL component (ASVS V5, T-15-03): 1–64 characters from
121/// `[A-Za-z0-9._+-]`, with no `..` window anywhere (path-traversal
122/// refusal — this also catches a lone `..`). Exit 2 `invalid_input` —
123/// a caller-supplied bad value is the usage class, so no new slug is
124/// warranted (the frozen taxonomy's existing precedent).
125pub fn validate_version(version: &str) -> Result<(), CoreError> {
126    let len_ok = (1..=64).contains(&version.len());
127    let chars_ok = version
128        .chars()
129        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'));
130    let no_traversal = !version.as_bytes().windows(2).any(|pair| pair == b"..");
131    if len_ok && chars_ok && no_traversal {
132        Ok(())
133    } else {
134        Err(CoreError::InvalidInput {
135            reason: format!(
136                "module version {version:?} is not a safe version string (expected \
137                 1-64 characters of [A-Za-z0-9._+-], no \"..\")"
138            ),
139        })
140    }
141}
142
143/// Validate a module id string the same way (T-15-03): 1–32 characters
144/// from `[a-z0-9-]`. Exit 2 `invalid_input`.
145pub fn validate_module_id(id: &str) -> Result<(), CoreError> {
146    let len_ok = (1..=32).contains(&id.len());
147    let chars_ok = id
148        .chars()
149        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
150    if len_ok && chars_ok {
151        Ok(())
152    } else {
153        Err(CoreError::InvalidInput {
154            reason: format!(
155                "module id {id:?} is not a safe identifier (expected 1-32 \
156                 characters of [a-z0-9-])"
157            ),
158        })
159    }
160}
161
162/// The module artifact cache root: `IGNITION_CLI_CACHE` env override
163/// first, else the platform cache dir (`directories::ProjectDirs::
164/// from("", "", "ignition-cli").cache_dir()`) — mirrors
165/// [`crate::config::config_path`]'s `IGNITION_CLI_CONFIG` shape exactly,
166/// including its `expect` on a missing home directory. The env override
167/// is what lets tests point at a `tempfile::TempDir` instead of the
168/// developer's real cache.
169pub fn cache_root() -> PathBuf {
170    std::env::var_os("IGNITION_CLI_CACHE")
171        .map(PathBuf::from)
172        .unwrap_or_else(|| {
173            let dirs = directories::ProjectDirs::from("", "", "ignition-cli")
174                .expect("no home directory discoverable");
175            dirs.cache_dir().to_path_buf()
176        })
177}
178
179/// The per-module cache directory: `<root>/modules/<module_id>`.
180pub fn module_cache_dir(root: &Path, module_id: &str) -> PathBuf {
181    root.join("modules").join(module_id)
182}
183
184/// A verified artifact already sitting in the cache.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct CachedArtifact {
187    /// Full path to the cached `.modl` file.
188    pub path: PathBuf,
189    /// The lowercase hex sha256 digest encoded in the filename.
190    pub digest_sha256: String,
191    /// On-disk size in bytes.
192    pub bytes: u64,
193}
194
195/// `true` when `s` is exactly 64 lowercase hex characters — the full
196/// digest, never a truncated prefix or an uppercase rendering (D-08).
197fn is_full_lowercase_sha256_hex(s: &str) -> bool {
198    s.len() == 64
199        && s.chars()
200            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
201}
202
203/// Look up a cached, verified artifact for `module_id`/`version` — keyed
204/// by VERSION ALONE (D-08): a hit never needs the feed, which is what
205/// makes SC-3 (no network on a repeat fetch) and SC-4 (offline-with-cache
206/// succeeds) possible. Accepts only filenames that strip to EXACTLY
207/// `<version>-` + 64 lowercase hex + `.modl` — no regex, just
208/// `strip_prefix`/`strip_suffix` plus a length + hex-digit check, which
209/// also disambiguates a prerelease version whose own string contains a
210/// hyphen (e.g. `2.3.4-rc1` never satisfies the fixed-length digest
211/// check when only `2.3.4` was requested).
212///
213/// A missing cache directory is `None`, never an error. Selection is
214/// DETERMINISTIC — never "whatever `read_dir` yielded first": the newest
215/// entry by modified time wins, ties broken by filename order. One
216/// version can legitimately own two entries once 15-02's
217/// accept-the-change override persists a second digest alongside the
218/// first.
219pub fn cached_entry(root: &Path, module_id: &str, version: &str) -> Option<CachedArtifact> {
220    let dir = module_cache_dir(root, module_id);
221    let entries = std::fs::read_dir(&dir).ok()?;
222    let prefix = format!("{version}-");
223
224    let mut candidates: Vec<(std::time::SystemTime, String, PathBuf, u64)> = Vec::new();
225    for entry in entries.flatten() {
226        let path = entry.path();
227        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
228            continue;
229        };
230        let Some(rest) = file_name.strip_prefix(&prefix) else {
231            continue;
232        };
233        let Some(digest) = rest.strip_suffix(".modl") else {
234            continue;
235        };
236        if !is_full_lowercase_sha256_hex(digest) {
237            continue;
238        }
239        let Ok(metadata) = entry.metadata() else {
240            continue;
241        };
242        let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
243        candidates.push((modified, file_name.to_string(), path, metadata.len()));
244    }
245
246    // Newest modified time wins; ties broken by filename order — never
247    // directory-iteration order, which is unspecified.
248    candidates.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
249
250    candidates
251        .into_iter()
252        .next()
253        .map(|(_, file_name, path, bytes)| {
254            let digest = file_name
255                .strip_prefix(&prefix)
256                .and_then(|rest| rest.strip_suffix(".modl"))
257                .expect("candidate matched the prefix/suffix check above")
258                .to_string();
259            CachedArtifact {
260                path,
261                digest_sha256: digest,
262                bytes,
263            }
264        })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::{
270        GIT_MODULE, MODULES, PROJECT_SCAN_ENDPOINT, cached_entry, module_cache_dir, spec_for,
271        validate_module_id, validate_version,
272    };
273
274    #[test]
275    fn module_spec_substitutes_version() {
276        assert_eq!(GIT_MODULE.tag("2.3.4"), "v2.3.4");
277        assert_eq!(GIT_MODULE.asset_name("2.3.4"), "Git-2.3.4-signed.modl");
278    }
279
280    /// D-03 (16-01 plan lock): the Git module's registry slug and
281    /// gateway id are DIFFERENT (reverse-DNS), while the second
282    /// module's slug and gateway id happen to COINCIDE (both bare
283    /// slugs). Asserted for BOTH entries — deliberately by name, the
284    /// ONE place in the registry tests an entry is singled out rather
285    /// than iterated — so a future refactor that collapses `id` and
286    /// `gateway_module_id` into one field fails on the Git module
287    /// rather than silently passing on the one where they look alike
288    /// (16-RESEARCH.md Pitfall 5).
289    #[test]
290    fn git_module_ids_differ_and_second_module_ids_coincide() {
291        assert_ne!(GIT_MODULE.id, GIT_MODULE.gateway_module_id);
292        assert_eq!(GIT_MODULE.gateway_module_id, "com.axone_io.ignition.git");
293
294        assert_eq!(
295            PROJECT_SCAN_ENDPOINT.id,
296            PROJECT_SCAN_ENDPOINT.gateway_module_id
297        );
298        assert_eq!(
299            PROJECT_SCAN_ENDPOINT.gateway_module_id,
300            "project-scan-endpoint"
301        );
302    }
303
304    /// SC-5: the registry is generic, not Git-module-shaped — proven by
305    /// iterating [`MODULES`] itself rather than naming either entry, so
306    /// a third registered module is covered by this test the day it is
307    /// added.
308    #[test]
309    fn registry_holds_both_modules() {
310        assert_eq!(MODULES.len(), 2, "exactly two modules registered so far");
311        assert!(MODULES.contains(&GIT_MODULE));
312        assert!(MODULES.contains(&PROJECT_SCAN_ENDPOINT));
313
314        for spec in MODULES {
315            assert_eq!(
316                spec_for(spec.id).map(|found| found.id),
317                Some(spec.id),
318                "{} must resolve via spec_for",
319                spec.id
320            );
321            assert!(
322                validate_module_id(spec.id).is_ok(),
323                "{} must pass validate_module_id",
324                spec.id
325            );
326            assert!(
327                !spec.gateway_module_id.is_empty(),
328                "{}'s gateway_module_id must be non-empty",
329                spec.id
330            );
331        }
332
333        assert!(spec_for("nope").is_none());
334    }
335
336    /// SC-5, plan Task 1 item 2: the second module's asset filename
337    /// carries NO version token — the falsifiable proof that
338    /// [`super::ModuleSpec::asset_name`]'s `{version}` substitution is a
339    /// harmless no-op for it, rather than something that needed a
340    /// special case to support.
341    #[test]
342    fn second_module_asset_template_carries_no_version() {
343        assert_eq!(
344            PROJECT_SCAN_ENDPOINT.asset_name("1.0.0"),
345            PROJECT_SCAN_ENDPOINT.asset_name("2.0.0"),
346            "a version-less asset template must substitute to the identical \
347             filename regardless of which version is requested"
348        );
349        assert_eq!(
350            PROJECT_SCAN_ENDPOINT.asset_name("1.0.0"),
351            "Project-Scan-Endpoint.modl"
352        );
353    }
354
355    // cache_root()'s IGNITION_CLI_CACHE env-override seam is exercised by
356    // `cache_root_honours_env_override` in
357    // tests/module_fetch_contract.rs (D-08) — kept there, not duplicated
358    // here, per the plan's file assignment.
359
360    #[test]
361    fn cached_entry_is_none_for_missing_dir() {
362        let dir = tempfile::tempdir().expect("tempdir");
363        assert!(cached_entry(dir.path(), "git", "2.3.4").is_none());
364    }
365
366    #[test]
367    fn module_cache_dir_shape() {
368        let root = std::path::Path::new("/tmp/cache-root");
369        assert_eq!(
370            module_cache_dir(root, "git"),
371            std::path::PathBuf::from("/tmp/cache-root/modules/git")
372        );
373    }
374
375    #[test]
376    fn validate_version_accepts_well_formed() {
377        assert!(validate_version("2.3.4").is_ok());
378        assert!(validate_version("2.3.4-rc1+build.1").is_ok());
379    }
380
381    #[test]
382    fn validate_version_rejects_hostile_input() {
383        for bad in [
384            "../escape",
385            "a/b",
386            "with\0null",
387            "back\\slash",
388            "2..3",
389            "",
390            &"x".repeat(65),
391        ] {
392            let err = validate_version(bad).expect_err(&format!("{bad:?} must be refused"));
393            assert_eq!(err.code(), "invalid_input");
394            assert_eq!(err.exit_code(), 2);
395        }
396    }
397
398    #[test]
399    fn validate_module_id_accepts_registry_entries() {
400        for spec in MODULES {
401            assert!(
402                validate_module_id(spec.id).is_ok(),
403                "{} must validate",
404                spec.id
405            );
406        }
407    }
408
409    #[test]
410    fn validate_module_id_rejects_hostile_input() {
411        for bad in ["../nope", "a/b", "UPPER", "", &"x".repeat(33)] {
412            let err = validate_module_id(bad).expect_err(&format!("{bad:?} must be refused"));
413            assert_eq!(err.code(), "invalid_input");
414            assert_eq!(err.exit_code(), 2);
415        }
416    }
417}