use std::path::Path;
use std::path::PathBuf;
use path_clean::PathClean;
use thiserror::Error;
use toml_spanner::Toml;
use toml_spanner::helper::display;
use toml_spanner::helper::parse_string;
use crate::dependency::DependencyName;
use crate::signing::VerifyingKey;
#[derive(Debug, Error)]
pub enum TrustStoreError {
#[error("i/o error at `{path}`")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("trust store at `{path}` is not valid UTF-8")]
NonUtf8 {
path: PathBuf,
},
#[error("trust store at `{path}` is not valid TOML")]
Parse {
path: PathBuf,
#[source]
source: toml_spanner::FromTomlError,
},
#[error("failed to serialize trust store for `{path}`")]
Serialize {
path: PathBuf,
#[source]
source: toml_spanner::ToTomlError,
},
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Toml)]
#[toml(Toml)]
pub struct TrustStore {
#[toml(default, style = Header, rename = "trust", skip_if = Vec::is_empty)]
pub entries: Vec<TrustEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq, Toml)]
#[toml(Toml)]
pub struct TrustEntry {
#[toml(FromToml with = parse_string, ToToml with = display)]
pub dep: DependencyName,
pub source: String,
#[toml(skip_if = Option::is_none)]
pub path: Option<String>,
#[toml(FromToml with = parse_string, ToToml with = display)]
pub key: VerifyingKey,
}
impl TrustStore {
pub fn load_or_default(path: &Path) -> Result<Self, TrustStoreError> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(source) => {
return Err(TrustStoreError::Io {
path: path.to_path_buf(),
source,
});
}
};
let s = std::str::from_utf8(&bytes).map_err(|_| TrustStoreError::NonUtf8 {
path: path.to_path_buf(),
})?;
toml_spanner::from_str(s).map_err(|source| TrustStoreError::Parse {
path: path.to_path_buf(),
source,
})
}
pub fn save(&self, path: &Path) -> Result<(), TrustStoreError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| TrustStoreError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let s = toml_spanner::to_string(self).map_err(|source| TrustStoreError::Serialize {
path: path.to_path_buf(),
source,
})?;
std::fs::write(path, s).map_err(|source| TrustStoreError::Io {
path: path.to_path_buf(),
source,
})
}
pub fn lookup(
&self,
dep: &DependencyName,
source_url: &str,
path: Option<&str>,
) -> Option<&VerifyingKey> {
self.entries
.iter()
.find(|e| {
e.dep == *dep && sources_match(&e.source, source_url) && e.path.as_deref() == path
})
.map(|e| &e.key)
}
}
fn sources_match(expected: &str, observed: &str) -> bool {
if expected == observed {
return true;
}
matches!(
(local_path_source(expected), local_path_source(observed)),
(Some(expected), Some(observed)) if expected == observed
)
}
fn local_path_source(source: &str) -> Option<String> {
if source.contains("://") {
return None;
}
Some(
Path::new(source)
.clean()
.display()
.to_string()
.replace('\\', "/"),
)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn test_key() -> VerifyingKey {
crate::signing::test_utils::signing_key_from_seed(0xA7).verifying_key()
}
#[test]
fn parses_empty_file() {
let store: TrustStore = toml_spanner::from_str("").unwrap();
assert!(store.entries.is_empty());
}
const TEST_SOURCE: &str = "https://github.com/openwdl/tasks";
#[test]
fn round_trips_via_toml() {
let dep: DependencyName = "openwdl".parse().unwrap();
let key = test_key();
let store = TrustStore {
entries: vec![TrustEntry {
dep: dep.clone(),
source: TEST_SOURCE.to_string(),
path: None,
key,
}],
};
let s = toml_spanner::to_string(&store).unwrap();
let parsed: TrustStore = toml_spanner::from_str(&s).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert!(parsed.lookup(&dep, TEST_SOURCE, None).is_some());
}
#[test]
fn loads_default_when_missing() {
let dir = tempdir().unwrap();
let path = dir.path().join("trust.toml");
let store = TrustStore::load_or_default(&path).unwrap();
assert!(store.entries.is_empty());
}
#[test]
fn save_and_reload_round_trips() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("trust.toml");
let dep: DependencyName = "openwdl".parse().unwrap();
let key = test_key();
let store = TrustStore {
entries: vec![TrustEntry {
dep: dep.clone(),
source: TEST_SOURCE.to_string(),
path: None,
key,
}],
};
store.save(&path).unwrap();
assert!(path.exists());
let reloaded = TrustStore::load_or_default(&path).unwrap();
assert_eq!(
reloaded, store,
"reloaded store should exactly match the original"
);
}
#[test]
fn lookup_requires_matching_source() {
let dep: DependencyName = "openwdl".parse().unwrap();
let store = TrustStore {
entries: vec![TrustEntry {
dep: dep.clone(),
source: TEST_SOURCE.to_string(),
path: None,
key: test_key(),
}],
};
assert!(store.lookup(&dep, TEST_SOURCE, None).is_some());
assert!(
store
.lookup(&dep, "https://example.com/other", None)
.is_none(),
"trust pin for one source should not match a different source"
);
}
#[test]
fn lookup_distinguishes_paths_within_same_source() {
let dep: DependencyName = "dep".parse().unwrap();
let store = TrustStore {
entries: vec![TrustEntry {
dep: dep.clone(),
source: TEST_SOURCE.to_string(),
path: Some("csvcut".to_string()),
key: test_key(),
}],
};
assert!(
store.lookup(&dep, TEST_SOURCE, Some("csvcut")).is_some(),
"exact path match should succeed"
);
assert!(
store.lookup(&dep, TEST_SOURCE, Some("csvgrep")).is_none(),
"trust pin for `csvcut` should not match `csvgrep` in the same repo"
);
assert!(
store.lookup(&dep, TEST_SOURCE, None).is_none(),
"trust pin for `csvcut` should not match root-level module in the same repo"
);
}
#[test]
fn lookup_matches_local_path_source() {
let dep: DependencyName = "utils".parse().unwrap();
let local_source = "/home/user/projects/shared/utils";
let store = TrustStore {
entries: vec![TrustEntry {
dep: dep.clone(),
source: local_source.to_string(),
path: None,
key: test_key(),
}],
};
assert!(
store.lookup(&dep, local_source, None).is_some(),
"local-path trust entry should match"
);
assert!(
store
.lookup(&dep, "/home/user/projects/other/utils", None)
.is_none(),
"different local path should not match"
);
}
#[test]
fn lookup_normalizes_local_path_separators() {
let dep: DependencyName = "utils".parse().unwrap();
let store = TrustStore {
entries: vec![TrustEntry {
dep: dep.clone(),
source: "C:/Users/me/projects/shared/utils".to_string(),
path: None,
key: test_key(),
}],
};
assert!(
store
.lookup(&dep, r"C:\Users\me\projects\shared\utils", None)
.is_some(),
"local-path trust entry should match native separators"
);
}
#[test]
fn parse_error_names_path() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.toml");
fs::write(&path, b"not valid toml [[[ {").unwrap();
let err = TrustStore::load_or_default(&path).unwrap_err();
assert!(err.to_string().contains(path.to_str().unwrap()));
}
}