Skip to main content

auths_id/storage/
layout.rs

1use auths_verifier::types::CanonicalDid;
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/// Constructs the Git reference path for a single TEL event of a credential.
172///
173/// A TEL (Transaction Event Log) is the KERI-native credential-status registry.
174/// Each event is stored under the issuer's namespace, keyed by registry SAID,
175/// credential SAID, and the event's TEL sequence number.
176///
177/// Example: `refs/did/keri/<issuer>/tel/<reg_said>/<cred_said>/<sn>`
178///
179/// Args:
180/// * `issuer`: The issuing AID (the KERI prefix controlling the registry).
181/// * `registry_said`: The registry SAID (`vcp.d`).
182/// * `credential_said`: The credential SAID (`iss.i`).
183/// * `sn`: The TEL event sequence number (`0` for `iss`, `1` for `rev`).
184///
185/// Usage:
186/// ```ignore
187/// let r = keri_tel_event_ref(&issuer, &reg, &cred, 0);
188/// ```
189pub fn keri_tel_event_ref(
190    issuer: &Prefix,
191    registry_said: &Said,
192    credential_said: &Said,
193    sn: u128,
194) -> String {
195    format!(
196        "{}/{}/tel/{}/{}/{}",
197        KERI_DID_REF_NAMESPACE_PREFIX.trim_end_matches('/'),
198        issuer.as_str(),
199        registry_said.as_str(),
200        credential_said.as_str(),
201        sn
202    )
203}
204
205/// Constructs the Git reference path for the registry inception (`vcp`) event.
206///
207/// The registry SAID is self-addressing (`vcp.i == vcp.d`), so the credential
208/// segment reuses the registry SAID and the sequence number is always `0`.
209///
210/// Example: `refs/did/keri/<issuer>/tel/<reg_said>/<reg_said>/0`
211///
212/// Args:
213/// * `issuer`: The issuing AID.
214/// * `registry_said`: The registry SAID (`vcp.d`).
215///
216/// Usage:
217/// ```ignore
218/// let r = keri_tel_registry_ref(&issuer, &reg);
219/// ```
220pub fn keri_tel_registry_ref(issuer: &Prefix, registry_said: &Said) -> String {
221    keri_tel_event_ref(issuer, registry_said, registry_said, 0)
222}
223
224/// Constructs the Git reference path for a credential's ACDC blob.
225///
226/// Example: `refs/did/keri/<issuer>/credentials/<cred_said>`
227///
228/// Args:
229/// * `issuer`: The issuing AID.
230/// * `credential_said`: The credential SAID (`acdc.d`).
231///
232/// Usage:
233/// ```ignore
234/// let r = keri_credential_ref(&issuer, &cred);
235/// ```
236pub fn keri_credential_ref(issuer: &Prefix, credential_said: &Said) -> String {
237    format!(
238        "{}/{}/credentials/{}",
239        KERI_DID_REF_NAMESPACE_PREFIX.trim_end_matches('/'),
240        issuer.as_str(),
241        credential_said.as_str()
242    )
243}
244
245/// Extracts the KERI prefix (AID) from a full `did:keri:` identifier string.
246pub fn did_keri_to_prefix(did: &str) -> Option<Prefix> {
247    did.strip_prefix("did:keri:")
248        .map(|s| Prefix::new_unchecked(s.to_string()))
249}
250
251// --- Configurable Layout (Primarily for did:key Identity & Attestations) ---
252
253/// Configuration defining the Git reference layout for primary identity and device attestation data.
254///
255/// This struct allows consumers of the `auths-id` library to define custom
256/// Git repository layouts.
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258pub struct StorageLayoutConfig {
259    /// The Git reference pointing to the commit containing the primary identity document.
260    /// Default: `"refs/auths/identity"`
261    pub identity_ref: GitRef,
262
263    /// The base Git reference prefix for storing device attestations.
264    /// Default: `"refs/auths/keys"`
265    pub device_attestation_prefix: GitRef,
266
267    /// Standard filename for the blob containing attestation data.
268    /// Default: `"attestation.json"`
269    pub attestation_blob_name: BlobName,
270
271    /// Standard filename for the blob containing identity data.
272    /// Default: `"identity.json"`
273    pub identity_blob_name: BlobName,
274}
275impl Default for StorageLayoutConfig {
276    fn default() -> Self {
277        Self {
278            identity_ref: GitRef::new("refs/auths/identity"),
279            device_attestation_prefix: GitRef::new("refs/auths/keys"),
280            attestation_blob_name: BlobName::new(ATTESTATION_JSON),
281            identity_blob_name: BlobName::new(IDENTITY_JSON),
282        }
283    }
284}
285
286impl StorageLayoutConfig {
287    /// Radicle-compatible layout preset (uses `refs/rad/` namespace).
288    pub fn radicle() -> Self {
289        Self {
290            identity_ref: GitRef::new("refs/rad/id"),
291            device_attestation_prefix: GitRef::new("refs/keys"),
292            attestation_blob_name: BlobName::new("link-attestation.json"),
293            identity_blob_name: BlobName::new("radicle-identity.json"),
294        }
295    }
296
297    /// Gitoxide-compatible layout preset (uses `refs/auths/` namespace).
298    pub fn gitoxide() -> Self {
299        Self {
300            identity_ref: GitRef::new("refs/auths/id"),
301            device_attestation_prefix: GitRef::new("refs/auths/devices"),
302            attestation_blob_name: BlobName::new(ATTESTATION_JSON),
303            identity_blob_name: BlobName::new(IDENTITY_JSON),
304        }
305    }
306
307    // --- Organization Reference Helpers ---
308
309    /// Constructs the full Git reference path for storing an organization member's attestation.
310    pub fn org_member_ref(&self, org_did: &str, member_did: &CanonicalDid) -> String {
311        format!(
312            "refs/auths/org/{}/members/{}",
313            sanitize_did(org_did),
314            member_did.ref_name()
315        )
316    }
317
318    /// Returns the base Git reference prefix for listing all members of an organization.
319    pub fn org_members_prefix(&self, org_did: &str) -> String {
320        format!("refs/auths/org/{}/members", sanitize_did(org_did))
321    }
322
323    /// Returns the Git reference path for storing organization identity/metadata.
324    pub fn org_identity_ref(&self, org_did: &str) -> String {
325        format!("refs/auths/org/{}/identity", sanitize_did(org_did))
326    }
327}
328
329// Use the workspace-canonical sanitizer (`:` → `_`) paired with
330// `unsanitize_did` for lossless round-trip.
331use crate::storage::registry::shard::sanitize_did;
332
333/// Determines the actual repository path from an optional `--repo` argument.
334///
335/// Expands leading `~/` to the user's home directory so that paths like
336/// `~/.auths` work correctly (the shell does not expand tildes when they
337/// arrive via clap default values or programmatic callers).
338#[cfg(feature = "git-storage")]
339#[allow(clippy::disallowed_methods)] // INVARIANT: designated home-dir resolution for repo path
340pub fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf, StorageError> {
341    match repo_arg {
342        Some(pathbuf) if !pathbuf.as_os_str().is_empty() => {
343            auths_utils::path::expand_tilde(&pathbuf)
344                .map_err(|e| StorageError::NotFound(e.to_string()))
345        }
346        _ => {
347            let home = dirs::home_dir()
348                .ok_or_else(|| StorageError::NotFound("Could not find HOME directory".into()))?;
349            Ok(home.join(TOOL_PATH))
350        }
351    }
352}
353
354/// Creates a Git namespace prefix string for a given DID.
355pub fn device_namespace_prefix(did: &str) -> String {
356    format!("refs/namespaces/{}", did_to_nid(did))
357}
358
359/// Sanitizes a DID string into a node ID (NID) suitable for use in Git refs.
360fn did_to_nid(did: &str) -> String {
361    did.replace(':', "-")
362}
363
364/// Gets the primary identity Git reference from the configuration.
365pub fn identity_ref(config: &StorageLayoutConfig) -> &str {
366    &config.identity_ref
367}
368
369/// Gets the standard identity blob filename from the configuration.
370pub fn identity_blob_name(config: &StorageLayoutConfig) -> &str {
371    &config.identity_blob_name
372}
373
374/// Gets the standard attestation blob filename from the configuration.
375pub fn attestation_blob_name(config: &StorageLayoutConfig) -> &str {
376    &config.attestation_blob_name
377}
378
379/// Constructs the full Git reference path for storing a specific device's attestations.
380pub fn attestation_ref_for_device(
381    config: &StorageLayoutConfig,
382    device_did: &CanonicalDid,
383) -> String {
384    format!(
385        "{}/{}/signatures",
386        config
387            .device_attestation_prefix
388            .as_str()
389            .trim_end_matches('/'),
390        device_did.ref_name()
391    )
392}
393
394/// Returns the list of Git reference prefixes to scan when discovering device attestations.
395pub fn default_attestation_prefixes(config: &StorageLayoutConfig) -> Vec<String> {
396    vec![config.device_attestation_prefix.as_str().to_string()]
397}
398
399#[cfg(test)]
400#[allow(clippy::disallowed_methods)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_config_defaults_are_agnostic() {
406        let config = StorageLayoutConfig::default();
407        assert_eq!(config.identity_ref.as_str(), "refs/auths/identity");
408        assert_eq!(config.device_attestation_prefix.as_str(), "refs/auths/keys");
409        assert_eq!(config.attestation_blob_name.as_str(), "attestation.json");
410        assert_eq!(config.identity_blob_name.as_str(), "identity.json");
411    }
412
413    #[test]
414    fn test_attestation_ref_for_device() {
415        let prefix = Prefix::new_unchecked("EABC123".to_string());
416        let expected = "refs/did/keri/EABC123/kel";
417        assert_eq!(keri_kel_ref(&prefix), expected);
418    }
419
420    #[test]
421    fn git_ref_join() {
422        let base = GitRef::new("refs/auths/keys");
423        let joined = base.join("device1");
424        assert_eq!(joined.as_str(), "refs/auths/keys/device1");
425    }
426
427    #[test]
428    fn git_ref_deref() {
429        let r = GitRef::new("refs/auths/id");
430        let s: &str = &r;
431        assert_eq!(s, "refs/auths/id");
432    }
433
434    #[test]
435    fn blob_name_deref() {
436        let b = BlobName::new("attestation.json");
437        let s: &str = &b;
438        assert_eq!(s, "attestation.json");
439    }
440
441    #[test]
442    fn layout_roundtrips() {
443        let config = StorageLayoutConfig::default();
444        let json = serde_json::to_string(&config).unwrap();
445        let parsed: StorageLayoutConfig = serde_json::from_str(&json).unwrap();
446        assert_eq!(config, parsed);
447    }
448
449    #[cfg(feature = "git-storage")]
450    mod repo_path {
451        use super::super::*;
452        use std::path::PathBuf;
453
454        #[test]
455        fn resolve_repo_path_expands_tilde_in_override() {
456            let result = resolve_repo_path(Some(PathBuf::from("~/.auths"))).unwrap();
457            #[allow(clippy::disallowed_methods)]
458            let home = dirs::home_dir().unwrap();
459            assert_eq!(result, home.join(".auths"));
460        }
461
462        #[test]
463        fn resolve_repo_path_defaults_to_home_auths() {
464            let result = resolve_repo_path(None).unwrap();
465            #[allow(clippy::disallowed_methods)]
466            let home = dirs::home_dir().unwrap();
467            assert_eq!(result, home.join(".auths"));
468        }
469
470        #[test]
471        fn resolve_repo_path_preserves_absolute_override() {
472            let result = resolve_repo_path(Some(PathBuf::from("/tmp/custom-auths"))).unwrap();
473            assert_eq!(result, PathBuf::from("/tmp/custom-auths"));
474        }
475    }
476}