ignition_core/module/
mod.rs1pub mod fetch;
17
18use std::path::{Path, PathBuf};
19
20use crate::error::CoreError;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct ModuleSpec {
28 pub id: &'static str,
31 pub repo: &'static str,
33 pub tag_template: &'static str,
35 pub asset_template: &'static str,
37}
38
39impl ModuleSpec {
40 pub fn tag(&self, version: &str) -> String {
43 self.tag_template.replace("{version}", version)
44 }
45
46 pub fn asset_name(&self, version: &str) -> String {
49 self.asset_template.replace("{version}", version)
50 }
51}
52
53pub 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
62pub const MODULES: &[ModuleSpec] = &[GIT_MODULE];
65
66pub fn spec_for(id: &str) -> Option<&'static ModuleSpec> {
68 MODULES.iter().find(|spec| spec.id == id)
69}
70
71pub 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
95pub 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
114pub 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
131pub fn module_cache_dir(root: &Path, module_id: &str) -> PathBuf {
133 root.join("modules").join(module_id)
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CachedArtifact {
139 pub path: PathBuf,
141 pub digest_sha256: String,
143 pub bytes: u64,
145}
146
147fn 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
155pub 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 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 #[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}