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}