use std::path::{Path, PathBuf};
use auths_verifier::IdentityDID;
use crate::ports::{ConfigStore, ConfigStoreError};
const ROOTS_FILE: &str = "roots";
fn roots_path(auths_dir: &Path) -> PathBuf {
auths_dir.join(ROOTS_FILE)
}
pub fn load_pinned_roots(
store: &dyn ConfigStore,
auths_dir: &Path,
) -> Result<Vec<String>, ConfigStoreError> {
match store.read(&roots_path(auths_dir))? {
Some(content) => Ok(parse_roots(&content)),
None => Ok(Vec::new()),
}
}
pub fn parse_roots(content: &str) -> Vec<String> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect()
}
pub fn is_pinned_root(
store: &dyn ConfigStore,
auths_dir: &Path,
did: &str,
) -> Result<bool, ConfigStoreError> {
Ok(load_pinned_roots(store, auths_dir)?
.iter()
.any(|root| root == did))
}
pub fn add_pinned_root(
store: &dyn ConfigStore,
auths_dir: &Path,
did: &str,
) -> Result<(), ConfigStoreError> {
let mut roots = load_pinned_roots(store, auths_dir)?;
if roots.iter().any(|root| root == did) {
return Ok(());
}
roots.push(did.to_string());
store.write(&roots_path(auths_dir), &format!("{}\n", roots.join("\n")))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RootPinOutcome {
PinnedAndStaged,
Staged,
AlreadyTracked,
}
#[derive(Debug, thiserror::Error)]
pub enum PinRootError {
#[error(transparent)]
Store(#[from] ConfigStoreError),
#[error(transparent)]
Index(#[from] crate::ports::git::IndexError),
}
pub fn pin_root_in_repo(
store: &dyn ConfigStore,
index: &dyn crate::ports::git::RepoIndex,
repo_root: &Path,
did: &str,
) -> Result<RootPinOutcome, PinRootError> {
let auths_dir = repo_root.join(".auths");
let pin_file = roots_path(&auths_dir);
let already_pinned = is_pinned_root(store, &auths_dir, did)?;
if already_pinned && index.is_tracked(&pin_file) {
return Ok(RootPinOutcome::AlreadyTracked);
}
if !already_pinned {
add_pinned_root(store, &auths_dir, did)?;
}
index.stage(&pin_file)?;
Ok(if already_pinned {
RootPinOutcome::Staged
} else {
RootPinOutcome::PinnedAndStaged
})
}
#[derive(Debug, thiserror::Error)]
pub enum RootsError {
#[error("could not read roots pin file: {0}")]
Store(#[from] ConfigStoreError),
#[error("roots pin line {line} ({value:?}) is not a valid did:keri: root: {source}")]
MalformedRoot {
line: usize,
value: String,
#[source]
source: auths_verifier::DidParseError,
},
}
pub fn parse_roots_typed(content: &str) -> Result<Vec<IdentityDID>, RootsError> {
content
.lines()
.enumerate()
.map(|(idx, raw)| (idx + 1, raw.trim()))
.filter(|(_, line)| !line.is_empty() && !line.starts_with('#'))
.map(|(line, value)| {
IdentityDID::parse(value).map_err(|source| RootsError::MalformedRoot {
line,
value: value.to_string(),
source,
})
})
.collect()
}
pub fn load_pinned_roots_typed(
store: &dyn ConfigStore,
auths_dir: &Path,
) -> Result<Vec<IdentityDID>, RootsError> {
match store.read(&roots_path(auths_dir))? {
Some(content) => parse_roots_typed(&content),
None => Ok(Vec::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FsStore;
impl ConfigStore for FsStore {
fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
match std::fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ConfigStoreError::Read {
path: path.to_path_buf(),
source: e,
}),
}
}
fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
path: path.to_path_buf(),
source: e,
})?;
}
std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
path: path.to_path_buf(),
source: e,
})
}
}
#[test]
fn parse_roots_ignores_blanks_and_comments() {
let content = "did:keri:Eaaa\n\n# a comment\n did:keri:Ebbb \n";
assert_eq!(
parse_roots(content),
vec!["did:keri:Eaaa".to_string(), "did:keri:Ebbb".to_string()]
);
}
#[test]
fn typed_roots_parse_valid_did_keri() {
let roots = parse_roots_typed("did:keri:Eaaa\n# c\n did:keri:Ebbb \n").expect("parse");
assert_eq!(
roots.iter().map(|r| r.as_str()).collect::<Vec<_>>(),
["did:keri:Eaaa", "did:keri:Ebbb"]
);
}
#[test]
fn typed_roots_reject_malformed_line_fail_closed() {
let err = parse_roots_typed("did:keri:Eaaa\nnot-a-did\n").expect_err("must fail closed");
assert!(
matches!(err, RootsError::MalformedRoot { line: 2, .. }),
"expected MalformedRoot at line 2, got {err:?}"
);
}
#[test]
fn typed_roots_absent_file_is_empty() {
let tmp = tempfile::TempDir::new().expect("temp dir");
assert!(
load_pinned_roots_typed(&FsStore, tmp.path())
.expect("load")
.is_empty()
);
}
#[test]
fn add_and_membership_roundtrip() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let dir = tmp.path();
let store = FsStore;
assert!(load_pinned_roots(&store, dir).expect("load").is_empty());
assert!(!is_pinned_root(&store, dir, "did:keri:Eroot").expect("check"));
add_pinned_root(&store, dir, "did:keri:Eroot").expect("add");
assert!(is_pinned_root(&store, dir, "did:keri:Eroot").expect("check pinned"));
assert!(!is_pinned_root(&store, dir, "did:keri:Eother").expect("check unpinned"));
add_pinned_root(&store, dir, "did:keri:Eroot").expect("add again");
assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 1);
add_pinned_root(&store, dir, "did:keri:Esecond").expect("add second");
assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 2);
}
struct FakeIndex {
staged: std::sync::Mutex<Vec<PathBuf>>,
tracked: Vec<PathBuf>,
}
impl FakeIndex {
fn new() -> Self {
Self {
staged: std::sync::Mutex::new(Vec::new()),
tracked: Vec::new(),
}
}
fn with_tracked(tracked: Vec<PathBuf>) -> Self {
Self {
staged: std::sync::Mutex::new(Vec::new()),
tracked,
}
}
fn staged(&self) -> Vec<PathBuf> {
self.staged.lock().expect("staged lock").clone()
}
}
impl crate::ports::git::RepoIndex for FakeIndex {
fn stage(&self, path: &Path) -> Result<(), crate::ports::git::IndexError> {
self.staged
.lock()
.expect("staged lock")
.push(path.to_path_buf());
Ok(())
}
fn is_tracked(&self, path: &Path) -> bool {
self.tracked.iter().any(|p| p == path)
}
}
#[test]
fn pin_root_in_repo_writes_and_stages_the_pin() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let repo = tmp.path();
let index = FakeIndex::new();
let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
assert_eq!(outcome, RootPinOutcome::PinnedAndStaged);
assert!(is_pinned_root(&FsStore, &repo.join(".auths"), "did:keri:Eroot").expect("pinned"));
assert_eq!(
index.staged(),
vec![repo.join(".auths").join("roots")],
"the pin must be staged, or it never reaches a cloner"
);
}
#[test]
fn pin_root_in_repo_stages_a_previously_written_but_untracked_pin() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let repo = tmp.path();
let auths_dir = repo.join(".auths");
add_pinned_root(&FsStore, &auths_dir, "did:keri:Eroot").expect("pre-write");
let index = FakeIndex::new();
let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
assert_eq!(outcome, RootPinOutcome::Staged);
assert_eq!(
index.staged(),
vec![auths_dir.join("roots")],
"an already-written but untracked pin must still be staged"
);
}
#[test]
fn pin_root_in_repo_is_a_noop_when_already_pinned_and_tracked() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let repo = tmp.path();
let auths_dir = repo.join(".auths");
add_pinned_root(&FsStore, &auths_dir, "did:keri:Eroot").expect("pre-write");
let index = FakeIndex::with_tracked(vec![auths_dir.join("roots")]);
let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
assert_eq!(outcome, RootPinOutcome::AlreadyTracked);
assert!(
index.staged().is_empty(),
"nothing to do; must not touch the index"
);
}
#[test]
fn pin_root_in_repo_appends_a_second_root_without_dropping_the_first() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let repo = tmp.path();
let index = FakeIndex::new();
pin_root_in_repo(&FsStore, &index, repo, "did:keri:Efirst").expect("first");
pin_root_in_repo(&FsStore, &index, repo, "did:keri:Esecond").expect("second");
let roots = load_pinned_roots(&FsStore, &repo.join(".auths")).expect("load");
assert_eq!(roots, vec!["did:keri:Efirst", "did:keri:Esecond"]);
}
}