use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const SIG_EXTENSION: &str = "sig";
pub fn sig_file_name(artifact_file_name: &str) -> String {
format!("{artifact_file_name}.{SIG_EXTENSION}")
}
pub fn read_sig_file(path: &Path) -> Result<String, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
crate::crypto::ed25519::normalize_signature(&text)
.map_err(|e| format!("{}: {e}", path.display()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Plugin,
Model,
}
impl Kind {
pub fn noun(self) -> &'static str {
match self {
Kind::Plugin => "plugin",
Kind::Model => "model",
}
}
}
#[derive(Debug, Clone)]
pub struct Subject {
pub kind: Kind,
pub id: String,
pub file_names: Vec<String>,
pub digest: String,
pub carried: Option<String>,
}
impl Subject {
pub fn candidates(&self) -> Vec<String> {
let mut out = Vec::new();
if !self.id.is_empty() {
out.push(sig_file_name(&self.id));
}
for name in &self.file_names {
let candidate = sig_file_name(name);
if !out.contains(&candidate) {
out.push(candidate);
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Signed {
signature: String,
file: PathBuf,
replaced_carried: bool,
},
Carried,
Unsigned { looked_for: Vec<String> },
}
#[derive(Debug)]
pub struct SignatureDir {
root: PathBuf,
files: BTreeMap<String, PathBuf>,
}
impl SignatureDir {
pub fn open(dir: &Path) -> Result<Self, String> {
let entries = std::fs::read_dir(dir)
.map_err(|e| format!("signatures directory {}: {e}", dir.display()))?;
let mut files = BTreeMap::new();
for entry in entries {
let entry =
entry.map_err(|e| format!("signatures directory {}: {e}", dir.display()))?;
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
if name.starts_with('.')
|| path.extension().and_then(|e| e.to_str()) != Some(SIG_EXTENSION)
|| !std::fs::metadata(&path).is_ok_and(|m| m.is_file())
{
continue;
}
files.insert(name, path);
}
Ok(Self {
root: dir.to_path_buf(),
files,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn file_names(&self) -> impl Iterator<Item = &str> {
self.files.keys().map(String::as_str)
}
pub fn lookup(&self, subject: &Subject) -> Option<&Path> {
subject
.candidates()
.iter()
.find_map(|name| self.files.get(name))
.map(PathBuf::as_path)
}
pub fn resolve(&self, subjects: &[Subject]) -> Result<Vec<Outcome>, Vec<String>> {
let mut errors = Vec::new();
let mut claimed: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
let mut outcomes = Vec::with_capacity(subjects.len());
for subject in subjects {
let found = subject
.candidates()
.into_iter()
.find_map(|name| self.files.get_key_value(&name));
let Some((name, path)) = found else {
outcomes.push(match &subject.carried {
Some(_) => Outcome::Carried,
None => Outcome::Unsigned {
looked_for: subject.candidates(),
},
});
continue;
};
claimed.entry(name.as_str()).or_default().push(&subject.id);
match read_sig_file(path) {
Ok(signature) => outcomes.push(Outcome::Signed {
replaced_carried: subject
.carried
.as_deref()
.is_some_and(|carried| carried.trim() != signature),
signature,
file: path.clone(),
}),
Err(e) => {
errors.push(e);
outcomes.push(Outcome::Unsigned {
looked_for: subject.candidates(),
});
}
}
}
for (name, ids) in &claimed {
if ids.len() > 1 {
errors.push(format!(
"{} is claimed by both '{}' and '{}' — name them {} and {}",
self.root.join(name).display(),
ids[0],
ids[1],
sig_file_name(ids[0]),
sig_file_name(ids[1]),
));
}
}
let expected: Vec<String> = subjects.iter().flat_map(Subject::candidates).collect();
for name in self.files.keys() {
if !claimed.contains_key(name.as_str()) {
errors.push(format!(
"{} matches no plugin or model in this artifact (expected one of: {})",
self.root.join(name).display(),
if expected.is_empty() {
"nothing — the artifact carries no plugin or model".to_string()
} else {
expected.join(", ")
}
));
}
}
if errors.is_empty() {
Ok(outcomes)
} else {
Err(errors)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let path =
std::env::temp_dir().join(format!("orion-signatures-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path).expect("scratch dir");
Self(path)
}
fn write(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent");
}
std::fs::write(&path, text).expect("write");
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn subject(id: &str, files: &[&str]) -> Subject {
Subject {
kind: Kind::Plugin,
id: id.to_string(),
file_names: files.iter().map(|f| (*f).to_string()).collect(),
digest: crate::crypto::sha256_digest(b"x"),
carried: None,
}
}
#[test]
fn a_sig_file_is_named_after_the_artifact() {
assert_eq!(sig_file_name("scoring.wasm"), "scoring.wasm.sig");
assert_eq!(
subject("acme.scoring", &["scoring.wasm"]).candidates(),
["acme.scoring.sig", "scoring.wasm.sig"]
);
assert_eq!(
subject("", &["model.onnx"]).candidates(),
["model.onnx.sig"]
);
}
#[test]
fn the_id_form_wins_over_the_file_form() {
let dir = Scratch::new();
let key = crate::crypto::ed25519::SigningKey::generate();
let sig = key.sign("sha256:00");
dir.write("scoring.wasm.sig", &sig);
let by_id = dir.write("acme.scoring.sig", &sig);
let sigs = SignatureDir::open(&dir.0).expect("open");
assert_eq!(
sigs.lookup(&subject("acme.scoring", &["scoring.wasm"])),
Some(by_id.as_path())
);
assert!(
sigs.lookup(&subject("acme.other", &["other.wasm"]))
.is_none()
);
}
#[test]
fn sub_directories_and_dotfiles_are_ignored() {
let dir = Scratch::new();
dir.write("..data/scoring.wasm.sig", "x");
dir.write(".hidden.sig", "x");
dir.write("notes.txt", "x");
dir.write("scoring.wasm.sig", "x");
let sigs = SignatureDir::open(&dir.0).expect("open");
assert_eq!(sigs.file_names().collect::<Vec<_>>(), ["scoring.wasm.sig"]);
}
#[test]
fn whitespace_is_tolerated_and_garbage_names_the_file() {
let dir = Scratch::new();
let key = crate::crypto::ed25519::SigningKey::generate();
let sig = key.sign("sha256:00");
let (a, b) = sig.split_at(40);
let wrapped = dir.write("a.sig", &format!("{a}\n{b}\n"));
assert_eq!(read_sig_file(&wrapped).expect("wrapped"), sig);
let bad = dir.write("b.sig", "not base64!");
let err = read_sig_file(&bad).expect_err("garbage");
assert!(err.contains("b.sig") && err.contains("not base64"), "{err}");
}
#[test]
fn resolving_reports_every_problem_not_the_first() {
let dir = Scratch::new();
let key = crate::crypto::ed25519::SigningKey::generate();
let sig = key.sign("sha256:00");
dir.write("acme.scoring.sig", &format!("{sig}\n"));
dir.write("model.onnx.sig", &sig);
dir.write("typo.sig", &sig);
dir.write("acme.broken.sig", "not base64!");
let sigs = SignatureDir::open(&dir.0).expect("open");
let mut a = subject("acme.a", &["model.onnx"]);
a.kind = Kind::Model;
let mut b = subject("acme.b", &["model.onnx"]);
b.kind = Kind::Model;
let errors = sigs
.resolve(&[
subject("acme.scoring", &["scoring.wasm"]),
subject("acme.broken", &[]),
a,
b,
])
.expect_err("three problems");
assert_eq!(errors.len(), 3, "{errors:#?}");
assert!(
errors
.iter()
.any(|e| e.contains("acme.broken.sig") && e.contains("not base64"))
);
assert!(
errors
.iter()
.any(|e| e.contains("claimed by both 'acme.a' and 'acme.b'"))
);
assert!(
errors
.iter()
.any(|e| e.contains("typo.sig matches no plugin or model")
&& e.contains("acme.scoring.sig"))
);
}
#[test]
fn a_dir_signature_replaces_a_carried_one_and_absence_keeps_it() {
let dir = Scratch::new();
let key = crate::crypto::ed25519::SigningKey::generate();
let sig = key.sign("sha256:00");
dir.write("scoring.wasm.sig", &sig);
let sigs = SignatureDir::open(&dir.0).expect("open");
let mut signed = subject("acme.scoring", &["scoring.wasm"]);
signed.carried = Some("c291cmNl".to_string());
let mut carried = subject("acme.legacy", &["legacy.wasm"]);
carried.carried = Some("c291cmNl".to_string());
let outcomes = sigs
.resolve(&[signed, carried, subject("acme.pairing", &["pairing.wasm"])])
.expect("resolves");
assert!(matches!(
&outcomes[0],
Outcome::Signed { signature, replaced_carried: true, .. } if *signature == sig
));
assert_eq!(outcomes[1], Outcome::Carried);
assert!(matches!(
&outcomes[2],
Outcome::Unsigned { looked_for } if looked_for == &["acme.pairing.sig", "pairing.wasm.sig"]
));
}
#[test]
fn opening_a_missing_directory_says_which() {
let err = SignatureDir::open(Path::new("/definitely/not/here")).expect_err("missing");
assert!(err.contains("/definitely/not/here"), "{err}");
}
}