1use std::path::{Path, PathBuf};
13
14use auths_verifier::IdentityDID;
15
16use crate::ports::{ConfigStore, ConfigStoreError};
17
18const ROOTS_FILE: &str = "roots";
19
20fn roots_path(auths_dir: &Path) -> PathBuf {
22 auths_dir.join(ROOTS_FILE)
23}
24
25pub fn load_pinned_roots(
38 store: &dyn ConfigStore,
39 auths_dir: &Path,
40) -> Result<Vec<String>, ConfigStoreError> {
41 match store.read(&roots_path(auths_dir))? {
42 Some(content) => Ok(parse_roots(&content)),
43 None => Ok(Vec::new()),
44 }
45}
46
47pub fn parse_roots(content: &str) -> Vec<String> {
58 content
59 .lines()
60 .map(str::trim)
61 .filter(|line| !line.is_empty() && !line.starts_with('#'))
62 .map(str::to_string)
63 .collect()
64}
65
66pub fn is_pinned_root(
78 store: &dyn ConfigStore,
79 auths_dir: &Path,
80 did: &str,
81) -> Result<bool, ConfigStoreError> {
82 Ok(load_pinned_roots(store, auths_dir)?
83 .iter()
84 .any(|root| root == did))
85}
86
87pub fn add_pinned_root(
102 store: &dyn ConfigStore,
103 auths_dir: &Path,
104 did: &str,
105) -> Result<(), ConfigStoreError> {
106 let mut roots = load_pinned_roots(store, auths_dir)?;
107 if roots.iter().any(|root| root == did) {
108 return Ok(());
109 }
110 roots.push(did.to_string());
111 store.write(&roots_path(auths_dir), &format!("{}\n", roots.join("\n")))
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum RootPinOutcome {
117 PinnedAndStaged,
119 Staged,
121 AlreadyTracked,
123}
124
125#[derive(Debug, thiserror::Error)]
127pub enum PinRootError {
128 #[error(transparent)]
130 Store(#[from] ConfigStoreError),
131 #[error(transparent)]
133 Index(#[from] crate::ports::git::IndexError),
134}
135
136pub fn pin_root_in_repo(
164 store: &dyn ConfigStore,
165 index: &dyn crate::ports::git::RepoIndex,
166 repo_root: &Path,
167 did: &str,
168) -> Result<RootPinOutcome, PinRootError> {
169 let auths_dir = repo_root.join(".auths");
170 let pin_file = roots_path(&auths_dir);
171
172 let already_pinned = is_pinned_root(store, &auths_dir, did)?;
173 if already_pinned && index.is_tracked(&pin_file) {
174 return Ok(RootPinOutcome::AlreadyTracked);
175 }
176
177 if !already_pinned {
178 add_pinned_root(store, &auths_dir, did)?;
179 }
180 index.stage(&pin_file)?;
181
182 Ok(if already_pinned {
183 RootPinOutcome::Staged
184 } else {
185 RootPinOutcome::PinnedAndStaged
186 })
187}
188
189#[derive(Debug, thiserror::Error)]
192pub enum RootsError {
193 #[error("could not read roots pin file: {0}")]
195 Store(#[from] ConfigStoreError),
196
197 #[error("roots pin line {line} ({value:?}) is not a valid did:keri: root: {source}")]
199 MalformedRoot {
200 line: usize,
202 value: String,
204 #[source]
206 source: auths_verifier::DidParseError,
207 },
208}
209
210pub fn parse_roots_typed(content: &str) -> Result<Vec<IdentityDID>, RootsError> {
225 content
226 .lines()
227 .enumerate()
228 .map(|(idx, raw)| (idx + 1, raw.trim()))
229 .filter(|(_, line)| !line.is_empty() && !line.starts_with('#'))
230 .map(|(line, value)| {
231 IdentityDID::parse(value).map_err(|source| RootsError::MalformedRoot {
232 line,
233 value: value.to_string(),
234 source,
235 })
236 })
237 .collect()
238}
239
240pub fn load_pinned_roots_typed(
256 store: &dyn ConfigStore,
257 auths_dir: &Path,
258) -> Result<Vec<IdentityDID>, RootsError> {
259 match store.read(&roots_path(auths_dir))? {
260 Some(content) => parse_roots_typed(&content),
261 None => Ok(Vec::new()),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 struct FsStore;
270
271 impl ConfigStore for FsStore {
272 fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
273 match std::fs::read_to_string(path) {
274 Ok(content) => Ok(Some(content)),
275 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
276 Err(e) => Err(ConfigStoreError::Read {
277 path: path.to_path_buf(),
278 source: e,
279 }),
280 }
281 }
282
283 fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
284 if let Some(parent) = path.parent() {
285 std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
286 path: path.to_path_buf(),
287 source: e,
288 })?;
289 }
290 std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
291 path: path.to_path_buf(),
292 source: e,
293 })
294 }
295 }
296
297 #[test]
298 fn parse_roots_ignores_blanks_and_comments() {
299 let content = "did:keri:Eaaa\n\n# a comment\n did:keri:Ebbb \n";
300 assert_eq!(
301 parse_roots(content),
302 vec!["did:keri:Eaaa".to_string(), "did:keri:Ebbb".to_string()]
303 );
304 }
305
306 #[test]
307 fn typed_roots_parse_valid_did_keri() {
308 let roots = parse_roots_typed("did:keri:Eaaa\n# c\n did:keri:Ebbb \n").expect("parse");
309 assert_eq!(
310 roots.iter().map(|r| r.as_str()).collect::<Vec<_>>(),
311 ["did:keri:Eaaa", "did:keri:Ebbb"]
312 );
313 }
314
315 #[test]
316 fn typed_roots_reject_malformed_line_fail_closed() {
317 let err = parse_roots_typed("did:keri:Eaaa\nnot-a-did\n").expect_err("must fail closed");
319 assert!(
320 matches!(err, RootsError::MalformedRoot { line: 2, .. }),
321 "expected MalformedRoot at line 2, got {err:?}"
322 );
323 }
324
325 #[test]
326 fn typed_roots_absent_file_is_empty() {
327 let tmp = tempfile::TempDir::new().expect("temp dir");
328 assert!(
329 load_pinned_roots_typed(&FsStore, tmp.path())
330 .expect("load")
331 .is_empty()
332 );
333 }
334
335 #[test]
336 fn add_and_membership_roundtrip() {
337 let tmp = tempfile::TempDir::new().expect("temp dir");
338 let dir = tmp.path();
339 let store = FsStore;
340
341 assert!(load_pinned_roots(&store, dir).expect("load").is_empty());
343 assert!(!is_pinned_root(&store, dir, "did:keri:Eroot").expect("check"));
344
345 add_pinned_root(&store, dir, "did:keri:Eroot").expect("add");
346 assert!(is_pinned_root(&store, dir, "did:keri:Eroot").expect("check pinned"));
347 assert!(!is_pinned_root(&store, dir, "did:keri:Eother").expect("check unpinned"));
348
349 add_pinned_root(&store, dir, "did:keri:Eroot").expect("add again");
351 assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 1);
352
353 add_pinned_root(&store, dir, "did:keri:Esecond").expect("add second");
355 assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 2);
356 }
357
358 struct FakeIndex {
360 staged: std::sync::Mutex<Vec<PathBuf>>,
361 tracked: Vec<PathBuf>,
362 }
363
364 impl FakeIndex {
365 fn new() -> Self {
366 Self {
367 staged: std::sync::Mutex::new(Vec::new()),
368 tracked: Vec::new(),
369 }
370 }
371
372 fn with_tracked(tracked: Vec<PathBuf>) -> Self {
373 Self {
374 staged: std::sync::Mutex::new(Vec::new()),
375 tracked,
376 }
377 }
378
379 fn staged(&self) -> Vec<PathBuf> {
380 self.staged.lock().expect("staged lock").clone()
381 }
382 }
383
384 impl crate::ports::git::RepoIndex for FakeIndex {
385 fn stage(&self, path: &Path) -> Result<(), crate::ports::git::IndexError> {
386 self.staged
387 .lock()
388 .expect("staged lock")
389 .push(path.to_path_buf());
390 Ok(())
391 }
392
393 fn is_tracked(&self, path: &Path) -> bool {
394 self.tracked.iter().any(|p| p == path)
395 }
396 }
397
398 #[test]
399 fn pin_root_in_repo_writes_and_stages_the_pin() {
400 let tmp = tempfile::TempDir::new().expect("temp dir");
401 let repo = tmp.path();
402 let index = FakeIndex::new();
403
404 let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
405
406 assert_eq!(outcome, RootPinOutcome::PinnedAndStaged);
407 assert!(is_pinned_root(&FsStore, &repo.join(".auths"), "did:keri:Eroot").expect("pinned"));
408 assert_eq!(
409 index.staged(),
410 vec![repo.join(".auths").join("roots")],
411 "the pin must be staged, or it never reaches a cloner"
412 );
413 }
414
415 #[test]
420 fn pin_root_in_repo_stages_a_previously_written_but_untracked_pin() {
421 let tmp = tempfile::TempDir::new().expect("temp dir");
422 let repo = tmp.path();
423 let auths_dir = repo.join(".auths");
424
425 add_pinned_root(&FsStore, &auths_dir, "did:keri:Eroot").expect("pre-write");
427 let index = FakeIndex::new(); let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
430
431 assert_eq!(outcome, RootPinOutcome::Staged);
432 assert_eq!(
433 index.staged(),
434 vec![auths_dir.join("roots")],
435 "an already-written but untracked pin must still be staged"
436 );
437 }
438
439 #[test]
440 fn pin_root_in_repo_is_a_noop_when_already_pinned_and_tracked() {
441 let tmp = tempfile::TempDir::new().expect("temp dir");
442 let repo = tmp.path();
443 let auths_dir = repo.join(".auths");
444 add_pinned_root(&FsStore, &auths_dir, "did:keri:Eroot").expect("pre-write");
445 let index = FakeIndex::with_tracked(vec![auths_dir.join("roots")]);
446
447 let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
448
449 assert_eq!(outcome, RootPinOutcome::AlreadyTracked);
450 assert!(
451 index.staged().is_empty(),
452 "nothing to do; must not touch the index"
453 );
454 }
455
456 #[test]
457 fn pin_root_in_repo_appends_a_second_root_without_dropping_the_first() {
458 let tmp = tempfile::TempDir::new().expect("temp dir");
459 let repo = tmp.path();
460 let index = FakeIndex::new();
461
462 pin_root_in_repo(&FsStore, &index, repo, "did:keri:Efirst").expect("first");
463 pin_root_in_repo(&FsStore, &index, repo, "did:keri:Esecond").expect("second");
464
465 let roots = load_pinned_roots(&FsStore, &repo.join(".auths")).expect("load");
466 assert_eq!(roots, vec!["did:keri:Efirst", "did:keri:Esecond"]);
467 }
468}