pub mod fetch;
use std::path::{Path, PathBuf};
use crate::error::CoreError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModuleSpec {
pub id: &'static str,
pub repo: &'static str,
pub tag_template: &'static str,
pub asset_template: &'static str,
pub gateway_module_id: &'static str,
}
impl ModuleSpec {
pub fn tag(&self, version: &str) -> String {
self.tag_template.replace("{version}", version)
}
pub fn asset_name(&self, version: &str) -> String {
self.asset_template.replace("{version}", version)
}
}
pub const GIT_MODULE: ModuleSpec = ModuleSpec {
id: "git",
repo: "WhiskeyHouse/ignition-git-module",
tag_template: "v{version}",
asset_template: "Git-{version}-signed.modl",
gateway_module_id: "com.axone_io.ignition.git",
};
pub const PROJECT_SCAN_ENDPOINT: ModuleSpec = ModuleSpec {
id: "project-scan-endpoint",
repo: "bw-design-group/ignition-project-scan-endpoint",
tag_template: "v{version}",
asset_template: "Project-Scan-Endpoint.modl",
gateway_module_id: "project-scan-endpoint",
};
pub const MODULES: &[ModuleSpec] = &[GIT_MODULE, PROJECT_SCAN_ENDPOINT];
pub fn spec_for(id: &str) -> Option<&'static ModuleSpec> {
MODULES.iter().find(|spec| spec.id == id)
}
pub fn validate_version(version: &str) -> Result<(), CoreError> {
let len_ok = (1..=64).contains(&version.len());
let chars_ok = version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'));
let no_traversal = !version.as_bytes().windows(2).any(|pair| pair == b"..");
if len_ok && chars_ok && no_traversal {
Ok(())
} else {
Err(CoreError::InvalidInput {
reason: format!(
"module version {version:?} is not a safe version string (expected \
1-64 characters of [A-Za-z0-9._+-], no \"..\")"
),
})
}
}
pub fn validate_module_id(id: &str) -> Result<(), CoreError> {
let len_ok = (1..=32).contains(&id.len());
let chars_ok = id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if len_ok && chars_ok {
Ok(())
} else {
Err(CoreError::InvalidInput {
reason: format!(
"module id {id:?} is not a safe identifier (expected 1-32 \
characters of [a-z0-9-])"
),
})
}
}
pub fn cache_root() -> PathBuf {
std::env::var_os("IGNITION_CLI_CACHE")
.map(PathBuf::from)
.unwrap_or_else(|| {
let dirs = directories::ProjectDirs::from("", "", "ignition-cli")
.expect("no home directory discoverable");
dirs.cache_dir().to_path_buf()
})
}
pub fn module_cache_dir(root: &Path, module_id: &str) -> PathBuf {
root.join("modules").join(module_id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedArtifact {
pub path: PathBuf,
pub digest_sha256: String,
pub bytes: u64,
}
fn is_full_lowercase_sha256_hex(s: &str) -> bool {
s.len() == 64
&& s.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
}
pub fn cached_entry(root: &Path, module_id: &str, version: &str) -> Option<CachedArtifact> {
let dir = module_cache_dir(root, module_id);
let entries = std::fs::read_dir(&dir).ok()?;
let prefix = format!("{version}-");
let mut candidates: Vec<(std::time::SystemTime, String, PathBuf, u64)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(rest) = file_name.strip_prefix(&prefix) else {
continue;
};
let Some(digest) = rest.strip_suffix(".modl") else {
continue;
};
if !is_full_lowercase_sha256_hex(digest) {
continue;
}
let Ok(metadata) = entry.metadata() else {
continue;
};
let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
candidates.push((modified, file_name.to_string(), path, metadata.len()));
}
candidates.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
candidates
.into_iter()
.next()
.map(|(_, file_name, path, bytes)| {
let digest = file_name
.strip_prefix(&prefix)
.and_then(|rest| rest.strip_suffix(".modl"))
.expect("candidate matched the prefix/suffix check above")
.to_string();
CachedArtifact {
path,
digest_sha256: digest,
bytes,
}
})
}
#[cfg(test)]
mod tests {
use super::{
GIT_MODULE, MODULES, PROJECT_SCAN_ENDPOINT, cached_entry, module_cache_dir, spec_for,
validate_module_id, validate_version,
};
#[test]
fn module_spec_substitutes_version() {
assert_eq!(GIT_MODULE.tag("2.3.4"), "v2.3.4");
assert_eq!(GIT_MODULE.asset_name("2.3.4"), "Git-2.3.4-signed.modl");
}
#[test]
fn git_module_ids_differ_and_second_module_ids_coincide() {
assert_ne!(GIT_MODULE.id, GIT_MODULE.gateway_module_id);
assert_eq!(GIT_MODULE.gateway_module_id, "com.axone_io.ignition.git");
assert_eq!(
PROJECT_SCAN_ENDPOINT.id,
PROJECT_SCAN_ENDPOINT.gateway_module_id
);
assert_eq!(
PROJECT_SCAN_ENDPOINT.gateway_module_id,
"project-scan-endpoint"
);
}
#[test]
fn registry_holds_both_modules() {
assert_eq!(MODULES.len(), 2, "exactly two modules registered so far");
assert!(MODULES.contains(&GIT_MODULE));
assert!(MODULES.contains(&PROJECT_SCAN_ENDPOINT));
for spec in MODULES {
assert_eq!(
spec_for(spec.id).map(|found| found.id),
Some(spec.id),
"{} must resolve via spec_for",
spec.id
);
assert!(
validate_module_id(spec.id).is_ok(),
"{} must pass validate_module_id",
spec.id
);
assert!(
!spec.gateway_module_id.is_empty(),
"{}'s gateway_module_id must be non-empty",
spec.id
);
}
assert!(spec_for("nope").is_none());
}
#[test]
fn second_module_asset_template_carries_no_version() {
assert_eq!(
PROJECT_SCAN_ENDPOINT.asset_name("1.0.0"),
PROJECT_SCAN_ENDPOINT.asset_name("2.0.0"),
"a version-less asset template must substitute to the identical \
filename regardless of which version is requested"
);
assert_eq!(
PROJECT_SCAN_ENDPOINT.asset_name("1.0.0"),
"Project-Scan-Endpoint.modl"
);
}
#[test]
fn cached_entry_is_none_for_missing_dir() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(cached_entry(dir.path(), "git", "2.3.4").is_none());
}
#[test]
fn module_cache_dir_shape() {
let root = std::path::Path::new("/tmp/cache-root");
assert_eq!(
module_cache_dir(root, "git"),
std::path::PathBuf::from("/tmp/cache-root/modules/git")
);
}
#[test]
fn validate_version_accepts_well_formed() {
assert!(validate_version("2.3.4").is_ok());
assert!(validate_version("2.3.4-rc1+build.1").is_ok());
}
#[test]
fn validate_version_rejects_hostile_input() {
for bad in [
"../escape",
"a/b",
"with\0null",
"back\\slash",
"2..3",
"",
&"x".repeat(65),
] {
let err = validate_version(bad).expect_err(&format!("{bad:?} must be refused"));
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
}
}
#[test]
fn validate_module_id_accepts_registry_entries() {
for spec in MODULES {
assert!(
validate_module_id(spec.id).is_ok(),
"{} must validate",
spec.id
);
}
}
#[test]
fn validate_module_id_rejects_hostile_input() {
for bad in ["../nope", "a/b", "UPPER", "", &"x".repeat(33)] {
let err = validate_module_id(bad).expect_err(&format!("{bad:?} must be refused"));
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
}
}
}