Skip to main content

auths_id/storage/
layout.rs

1use auths_verifier::types::DeviceDID;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::ops::Deref;
5use std::path::PathBuf;
6
7use crate::error::StorageError;
8
9use crate::keri::{Prefix, Said};
10
11// --- General Constants ---
12
13/// Default directory name within the user's home directory for storing repositories.
14pub const TOOL_PATH: &str = ".auths";
15/// Default filename for storing attestation data within Git commits.
16pub const ATTESTATION_JSON: &str = "attestation.json";
17/// Default filename for storing identity data within Git commits.
18pub const IDENTITY_JSON: &str = "identity.json";
19
20// --- Typed Git ref and blob name newtypes ---
21
22/// A Git reference path (e.g. `refs/auths/identity`).
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(transparent)]
25pub struct GitRef(String);
26
27impl GitRef {
28    pub fn new(s: impl Into<String>) -> Self {
29        Self(s.into())
30    }
31
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// Join a path segment to this ref, separated by `/`.
37    pub fn join(&self, segment: &str) -> GitRef {
38        GitRef(format!("{}/{}", self.0.trim_end_matches('/'), segment))
39    }
40}
41
42impl fmt::Display for GitRef {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl Deref for GitRef {
49    type Target = str;
50    fn deref(&self) -> &str {
51        &self.0
52    }
53}
54
55impl PartialEq<str> for GitRef {
56    fn eq(&self, other: &str) -> bool {
57        self.0 == other
58    }
59}
60
61impl PartialEq<&str> for GitRef {
62    fn eq(&self, other: &&str) -> bool {
63        self.0 == *other
64    }
65}
66
67impl From<String> for GitRef {
68    fn from(s: String) -> Self {
69        Self(s)
70    }
71}
72
73/// A blob filename within a Git tree (e.g. `attestation.json`).
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(transparent)]
76pub struct BlobName(String);
77
78impl BlobName {
79    pub fn new(s: impl Into<String>) -> Self {
80        Self(s.into())
81    }
82
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86}
87
88impl fmt::Display for BlobName {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(&self.0)
91    }
92}
93
94impl Deref for BlobName {
95    type Target = str;
96    fn deref(&self) -> &str {
97        &self.0
98    }
99}
100
101impl PartialEq<str> for BlobName {
102    fn eq(&self, other: &str) -> bool {
103        self.0 == other
104    }
105}
106
107impl PartialEq<&str> for BlobName {
108    fn eq(&self, other: &&str) -> bool {
109        self.0 == *other
110    }
111}
112
113impl From<String> for BlobName {
114    fn from(s: String) -> Self {
115        Self(s)
116    }
117}
118
119// --- KERI Specific Constants & Layout  ---
120
121/// The base Git reference namespace prefix for storing KERI DID information.
122pub const KERI_DID_REF_NAMESPACE_PREFIX: &str = "refs/did/keri";
123
124/// Constructs the full Git reference path for a KERI Key Event Log (KEL)
125/// based on the DID prefix (AID).
126///
127/// Example: `refs/did/keri/<did_prefix>/kel`
128pub fn keri_kel_ref(did_prefix: &Prefix) -> String {
129    format!(
130        "{}/{}/kel",
131        KERI_DID_REF_NAMESPACE_PREFIX.trim_end_matches('/'),
132        did_prefix.as_str()
133    )
134}
135
136/// Constructs the Git reference path for storing receipts for a specific event.
137///
138/// Example: `refs/did/keri/<did_prefix>/receipts/<event_said>`
139pub fn keri_receipts_ref(did_prefix: &Prefix, event_said: &Said) -> String {
140    format!(
141        "{}/{}/receipts/{}",
142        KERI_DID_REF_NAMESPACE_PREFIX.trim_end_matches('/'),
143        did_prefix.as_str(),
144        event_said.as_str()
145    )
146}
147
148/// Returns the base Git reference prefix for all receipts of an identity.
149///
150/// Example: `refs/did/keri/<did_prefix>/receipts`
151pub fn keri_receipts_prefix(did_prefix: &Prefix) -> String {
152    format!(
153        "{}/{}/receipts",
154        KERI_DID_REF_NAMESPACE_PREFIX.trim_end_matches('/'),
155        did_prefix.as_str()
156    )
157}
158
159/// (Optional) Constructs the full Git reference path for a cached KERI DID Document
160/// based on the DID prefix (AID).
161///
162/// Example: `refs/did/keri/<did_prefix>/document`
163pub fn keri_document_ref(did_prefix: &Prefix) -> String {
164    format!(
165        "{}/{}/document",
166        KERI_DID_REF_NAMESPACE_PREFIX.trim_end_matches('/'),
167        did_prefix.as_str()
168    )
169}
170
171/// Extracts the KERI prefix (AID) from a full `did:keri:` identifier string.
172pub fn did_keri_to_prefix(did: &str) -> Option<Prefix> {
173    did.strip_prefix("did:keri:")
174        .map(|s| Prefix::new_unchecked(s.to_string()))
175}
176
177// --- Configurable Layout (Primarily for did:key Identity & Attestations) ---
178
179/// Configuration defining the Git reference layout for primary identity and device attestation data.
180///
181/// This struct allows consumers of the `auths-id` library to define custom
182/// Git repository layouts.
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184pub struct StorageLayoutConfig {
185    /// The Git reference pointing to the commit containing the primary identity document.
186    /// Default: `"refs/auths/identity"`
187    pub identity_ref: GitRef,
188
189    /// The base Git reference prefix for storing device attestations.
190    /// Default: `"refs/auths/keys"`
191    pub device_attestation_prefix: GitRef,
192
193    /// Standard filename for the blob containing attestation data.
194    /// Default: `"attestation.json"`
195    pub attestation_blob_name: BlobName,
196
197    /// Standard filename for the blob containing identity data.
198    /// Default: `"identity.json"`
199    pub identity_blob_name: BlobName,
200}
201impl Default for StorageLayoutConfig {
202    fn default() -> Self {
203        Self {
204            identity_ref: GitRef::new("refs/auths/identity"),
205            device_attestation_prefix: GitRef::new("refs/auths/keys"),
206            attestation_blob_name: BlobName::new(ATTESTATION_JSON),
207            identity_blob_name: BlobName::new(IDENTITY_JSON),
208        }
209    }
210}
211
212impl StorageLayoutConfig {
213    /// Radicle-compatible layout preset (uses `refs/rad/` namespace).
214    pub fn radicle() -> Self {
215        Self {
216            identity_ref: GitRef::new("refs/rad/id"),
217            device_attestation_prefix: GitRef::new("refs/keys"),
218            attestation_blob_name: BlobName::new("link-attestation.json"),
219            identity_blob_name: BlobName::new("radicle-identity.json"),
220        }
221    }
222
223    /// Gitoxide-compatible layout preset (uses `refs/auths/` namespace).
224    pub fn gitoxide() -> Self {
225        Self {
226            identity_ref: GitRef::new("refs/auths/id"),
227            device_attestation_prefix: GitRef::new("refs/auths/devices"),
228            attestation_blob_name: BlobName::new(ATTESTATION_JSON),
229            identity_blob_name: BlobName::new(IDENTITY_JSON),
230        }
231    }
232
233    // --- Organization Reference Helpers ---
234
235    /// Constructs the full Git reference path for storing an organization member's attestation.
236    pub fn org_member_ref(&self, org_did: &str, member_did: &DeviceDID) -> String {
237        format!(
238            "refs/auths/org/{}/members/{}",
239            sanitize_did_for_ref(org_did),
240            member_did.ref_name()
241        )
242    }
243
244    /// Returns the base Git reference prefix for listing all members of an organization.
245    pub fn org_members_prefix(&self, org_did: &str) -> String {
246        format!("refs/auths/org/{}/members", sanitize_did_for_ref(org_did))
247    }
248
249    /// Returns the Git reference path for storing organization identity/metadata.
250    pub fn org_identity_ref(&self, org_did: &str) -> String {
251        format!("refs/auths/org/{}/identity", sanitize_did_for_ref(org_did))
252    }
253}
254
255/// Sanitizes a DID string for use in Git reference paths.
256pub fn sanitize_did_for_ref(did: &str) -> String {
257    did.chars()
258        .map(|c| if c.is_alphanumeric() { c } else { '_' })
259        .collect()
260}
261
262/// Determines the actual repository path from an optional `--repo` argument.
263///
264/// Expands leading `~/` to the user's home directory so that paths like
265/// `~/.auths` work correctly (the shell does not expand tildes when they
266/// arrive via clap default values or programmatic callers).
267#[cfg(feature = "git-storage")]
268#[allow(clippy::disallowed_methods)] // INVARIANT: designated home-dir resolution for repo path
269pub fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf, StorageError> {
270    match repo_arg {
271        Some(pathbuf) if !pathbuf.as_os_str().is_empty() => {
272            auths_utils::path::expand_tilde(&pathbuf)
273                .map_err(|e| StorageError::NotFound(e.to_string()))
274        }
275        _ => {
276            let home = dirs::home_dir()
277                .ok_or_else(|| StorageError::NotFound("Could not find HOME directory".into()))?;
278            Ok(home.join(TOOL_PATH))
279        }
280    }
281}
282
283/// Creates a Git namespace prefix string for a given DID.
284pub fn device_namespace_prefix(did: &str) -> String {
285    format!("refs/namespaces/{}", did_to_nid(did))
286}
287
288/// Sanitizes a DID string into a node ID (NID) suitable for use in Git refs.
289fn did_to_nid(did: &str) -> String {
290    did.replace(':', "-")
291}
292
293/// Gets the primary identity Git reference from the configuration.
294pub fn identity_ref(config: &StorageLayoutConfig) -> &str {
295    &config.identity_ref
296}
297
298/// Gets the standard identity blob filename from the configuration.
299pub fn identity_blob_name(config: &StorageLayoutConfig) -> &str {
300    &config.identity_blob_name
301}
302
303/// Gets the standard attestation blob filename from the configuration.
304pub fn attestation_blob_name(config: &StorageLayoutConfig) -> &str {
305    &config.attestation_blob_name
306}
307
308/// Constructs the full Git reference path for storing a specific device's attestations.
309pub fn attestation_ref_for_device(config: &StorageLayoutConfig, device_did: &DeviceDID) -> String {
310    format!(
311        "{}/{}/signatures",
312        config
313            .device_attestation_prefix
314            .as_str()
315            .trim_end_matches('/'),
316        device_did.ref_name()
317    )
318}
319
320/// Returns the list of Git reference prefixes to scan when discovering device attestations.
321pub fn default_attestation_prefixes(config: &StorageLayoutConfig) -> Vec<String> {
322    vec![config.device_attestation_prefix.as_str().to_string()]
323}
324
325#[cfg(test)]
326#[allow(clippy::disallowed_methods)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_config_defaults_are_agnostic() {
332        let config = StorageLayoutConfig::default();
333        assert_eq!(config.identity_ref.as_str(), "refs/auths/identity");
334        assert_eq!(config.device_attestation_prefix.as_str(), "refs/auths/keys");
335        assert_eq!(config.attestation_blob_name.as_str(), "attestation.json");
336        assert_eq!(config.identity_blob_name.as_str(), "identity.json");
337    }
338
339    #[test]
340    fn test_attestation_ref_for_device() {
341        let prefix = Prefix::new_unchecked("EABC123".to_string());
342        let expected = "refs/did/keri/EABC123/kel";
343        assert_eq!(keri_kel_ref(&prefix), expected);
344    }
345
346    #[test]
347    fn git_ref_join() {
348        let base = GitRef::new("refs/auths/keys");
349        let joined = base.join("device1");
350        assert_eq!(joined.as_str(), "refs/auths/keys/device1");
351    }
352
353    #[test]
354    fn git_ref_deref() {
355        let r = GitRef::new("refs/auths/id");
356        let s: &str = &r;
357        assert_eq!(s, "refs/auths/id");
358    }
359
360    #[test]
361    fn blob_name_deref() {
362        let b = BlobName::new("attestation.json");
363        let s: &str = &b;
364        assert_eq!(s, "attestation.json");
365    }
366
367    #[test]
368    fn layout_roundtrips() {
369        let config = StorageLayoutConfig::default();
370        let json = serde_json::to_string(&config).unwrap();
371        let parsed: StorageLayoutConfig = serde_json::from_str(&json).unwrap();
372        assert_eq!(config, parsed);
373    }
374
375    #[cfg(feature = "git-storage")]
376    mod repo_path {
377        use super::super::*;
378        use std::path::PathBuf;
379
380        #[test]
381        fn resolve_repo_path_expands_tilde_in_override() {
382            let result = resolve_repo_path(Some(PathBuf::from("~/.auths"))).unwrap();
383            #[allow(clippy::disallowed_methods)]
384            let home = dirs::home_dir().unwrap();
385            assert_eq!(result, home.join(".auths"));
386        }
387
388        #[test]
389        fn resolve_repo_path_defaults_to_home_auths() {
390            let result = resolve_repo_path(None).unwrap();
391            #[allow(clippy::disallowed_methods)]
392            let home = dirs::home_dir().unwrap();
393            assert_eq!(result, home.join(".auths"));
394        }
395
396        #[test]
397        fn resolve_repo_path_preserves_absolute_override() {
398            let result = resolve_repo_path(Some(PathBuf::from("/tmp/custom-auths"))).unwrap();
399            assert_eq!(result, PathBuf::from("/tmp/custom-auths"));
400        }
401    }
402}