auths_sdk/workflows/
roots.rs1use 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, thiserror::Error)]
117pub enum RootsError {
118 #[error("could not read roots pin file: {0}")]
120 Store(#[from] ConfigStoreError),
121
122 #[error("roots pin line {line} ({value:?}) is not a valid did:keri: root: {source}")]
124 MalformedRoot {
125 line: usize,
127 value: String,
129 #[source]
131 source: auths_verifier::DidParseError,
132 },
133}
134
135pub fn parse_roots_typed(content: &str) -> Result<Vec<IdentityDID>, RootsError> {
150 content
151 .lines()
152 .enumerate()
153 .map(|(idx, raw)| (idx + 1, raw.trim()))
154 .filter(|(_, line)| !line.is_empty() && !line.starts_with('#'))
155 .map(|(line, value)| {
156 IdentityDID::parse(value).map_err(|source| RootsError::MalformedRoot {
157 line,
158 value: value.to_string(),
159 source,
160 })
161 })
162 .collect()
163}
164
165pub fn load_pinned_roots_typed(
181 store: &dyn ConfigStore,
182 auths_dir: &Path,
183) -> Result<Vec<IdentityDID>, RootsError> {
184 match store.read(&roots_path(auths_dir))? {
185 Some(content) => parse_roots_typed(&content),
186 None => Ok(Vec::new()),
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 struct FsStore;
195
196 impl ConfigStore for FsStore {
197 fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
198 match std::fs::read_to_string(path) {
199 Ok(content) => Ok(Some(content)),
200 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
201 Err(e) => Err(ConfigStoreError::Read {
202 path: path.to_path_buf(),
203 source: e,
204 }),
205 }
206 }
207
208 fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
209 if let Some(parent) = path.parent() {
210 std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
211 path: path.to_path_buf(),
212 source: e,
213 })?;
214 }
215 std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
216 path: path.to_path_buf(),
217 source: e,
218 })
219 }
220 }
221
222 #[test]
223 fn parse_roots_ignores_blanks_and_comments() {
224 let content = "did:keri:Eaaa\n\n# a comment\n did:keri:Ebbb \n";
225 assert_eq!(
226 parse_roots(content),
227 vec!["did:keri:Eaaa".to_string(), "did:keri:Ebbb".to_string()]
228 );
229 }
230
231 #[test]
232 fn typed_roots_parse_valid_did_keri() {
233 let roots = parse_roots_typed("did:keri:Eaaa\n# c\n did:keri:Ebbb \n").expect("parse");
234 assert_eq!(
235 roots.iter().map(|r| r.as_str()).collect::<Vec<_>>(),
236 ["did:keri:Eaaa", "did:keri:Ebbb"]
237 );
238 }
239
240 #[test]
241 fn typed_roots_reject_malformed_line_fail_closed() {
242 let err = parse_roots_typed("did:keri:Eaaa\nnot-a-did\n").expect_err("must fail closed");
244 assert!(
245 matches!(err, RootsError::MalformedRoot { line: 2, .. }),
246 "expected MalformedRoot at line 2, got {err:?}"
247 );
248 }
249
250 #[test]
251 fn typed_roots_absent_file_is_empty() {
252 let tmp = tempfile::TempDir::new().expect("temp dir");
253 assert!(
254 load_pinned_roots_typed(&FsStore, tmp.path())
255 .expect("load")
256 .is_empty()
257 );
258 }
259
260 #[test]
261 fn add_and_membership_roundtrip() {
262 let tmp = tempfile::TempDir::new().expect("temp dir");
263 let dir = tmp.path();
264 let store = FsStore;
265
266 assert!(load_pinned_roots(&store, dir).expect("load").is_empty());
268 assert!(!is_pinned_root(&store, dir, "did:keri:Eroot").expect("check"));
269
270 add_pinned_root(&store, dir, "did:keri:Eroot").expect("add");
271 assert!(is_pinned_root(&store, dir, "did:keri:Eroot").expect("check pinned"));
272 assert!(!is_pinned_root(&store, dir, "did:keri:Eother").expect("check unpinned"));
273
274 add_pinned_root(&store, dir, "did:keri:Eroot").expect("add again");
276 assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 1);
277
278 add_pinned_root(&store, dir, "did:keri:Esecond").expect("add second");
280 assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 2);
281 }
282}