Skip to main content

cyphr_storage/
export.rs

1//! Export/import utilities for Principal storage.
2//!
3//! These functions bridge the `cyphr` Principal type with the storage layer,
4//! enabling faithful round-trip serialization of identity state.
5
6use crate::{CommitEntry, Entry, KeyEntry, Store};
7use cyphr::Principal;
8
9/// Errors that can occur during export.
10#[derive(Debug, thiserror::Error)]
11pub enum ExportError {
12    /// JSON serialization failed.
13    #[error("serialization error: {0}")]
14    Json(#[from] serde_json::Error),
15
16    /// Entry construction failed.
17    #[error("entry error: {0}")]
18    Entry(#[from] crate::EntryError),
19
20    /// State digest is empty (no algorithm variants).
21    #[error("empty state digest: {0}")]
22    EmptyDigest(#[from] cyphr::Error),
23}
24
25/// Export all entries from a Principal for storage (legacy flat format).
26///
27/// Returns a vector of `Entry` that can be persisted to any `Store`.
28/// The order is: cozies first (in apply order), then actions.
29///
30/// For `key/create` and `key/replace` cozies, the associated key material
31/// is included in the exported entry as a `key` field, matching SPEC §3.1 JSONL format.
32///
33/// **Note**: For commit-based storage, use `export_commits` instead.
34///
35/// # Errors
36///
37/// Returns `ExportError` if serialization or state digest access fails.
38///
39/// # Example
40///
41/// ```ignore
42/// let entries = export_entries(&principal)?;
43/// for entry in entries {
44///     store.append_entry(principal.pg(), &entry)?;
45/// }
46/// ```
47pub fn export_entries(principal: &Principal) -> Result<Vec<Entry>, ExportError> {
48    let mut entries = Vec::new();
49
50    for cz in principal.iter_all_cozies() {
51        // Serialize complete CozJson {pay, sig} — no key embedding
52        let raw = serde_json::to_value(cz.raw())?;
53
54        // Note: from_value serializes, which is fine for export (creating new entries)
55        entries.push(Entry::from_value(&raw)?);
56    }
57
58    for action in principal.actions() {
59        let raw = serde_json::to_value(action.raw())?;
60        entries.push(Entry::from_value(&raw)?);
61    }
62
63    Ok(entries)
64}
65
66/// Export commits from a Principal for commit-based storage.
67///
68/// Returns a vector of `CommitEntry` representing each finalized commit.
69/// Each entry contains:
70/// - `cozies`: Array of coz JSON values (with embedded key material)
71/// - `commit_id`: Commit ID (Merkle root of coz czds, base64url)
72/// - `as`: Auth State (base64url)
73/// - `sr`: State Root (base64url)
74/// - `ps`: Principal State (base64url)
75///
76/// **Note**: Actions are not included in commits; they are stored separately
77/// or handled by the caller.
78///
79/// # Errors
80///
81/// Returns `ExportError` if serialization or state digest access fails.
82///
83/// # Example
84///
85/// ```ignore
86/// // Ignored: requires initialized Principal with commits (external context)
87/// let commits = export_commits(&principal)?;
88/// for commit in commits {
89///     file.write_line(&commit.to_json()?)?;
90/// }
91/// ```
92pub fn export_commits(principal: &Principal) -> Result<Vec<CommitEntry>, ExportError> {
93    use coz::base64ct::{Base64UrlUnpadded, Encoding};
94
95    let mut commit_entries = Vec::new();
96
97    for commit in principal.commits() {
98        let mut cozies = Vec::new();
99        let mut keys = Vec::new();
100
101        for cz in commit.iter_all_cozies() {
102            // Serialize complete CozJson {pay, sig} — no key embedding
103            let raw = serde_json::to_value(cz.raw())?;
104            cozies.push(raw);
105
106            // Collect key material at commit level
107            if let Some(key) = cz.new_key() {
108                keys.push(KeyEntry {
109                    alg: key.alg.clone(),
110                    pub_key: Base64UrlUnpadded::encode_string(&key.pub_key),
111                    tmb: key.tmb.to_b64(),
112                    tag: key.tag.clone(),
113                    now: Some(key.first_seen),
114                });
115            }
116        }
117
118        // Get state digests as algorithm-prefixed strings (alg:digest format)
119        // Use first_variant() for deterministic, fallible access
120        let tr_bytes = commit.tr().0.first_variant()?;
121        let tr_alg = commit
122            .tr()
123            .0
124            .algorithms()
125            .next()
126            .ok_or(cyphr::Error::EmptyMultihash)?;
127        let commit_id = format!("{}:{}", tr_alg, Base64UrlUnpadded::encode_string(tr_bytes));
128
129        let as_bytes = commit.auth_root().as_multihash().first_variant()?;
130        let as_alg = commit
131            .auth_root()
132            .as_multihash()
133            .algorithms()
134            .next()
135            .ok_or(cyphr::Error::EmptyMultihash)?;
136        let auth_root = format!("{}:{}", as_alg, Base64UrlUnpadded::encode_string(as_bytes));
137
138        let sr_bytes = commit.sr().as_multihash().first_variant()?;
139        let sr_alg = commit
140            .sr()
141            .as_multihash()
142            .algorithms()
143            .next()
144            .ok_or(cyphr::Error::EmptyMultihash)?;
145        let sr = format!("{}:{}", sr_alg, Base64UrlUnpadded::encode_string(sr_bytes));
146
147        let ps_bytes = commit.pr().as_multihash().first_variant()?;
148        let ps_alg = commit
149            .pr()
150            .as_multihash()
151            .algorithms()
152            .next()
153            .ok_or(cyphr::Error::EmptyMultihash)?;
154        let ps = format!("{}:{}", ps_alg, Base64UrlUnpadded::encode_string(ps_bytes));
155
156        commit_entries.push(CommitEntry::new(cozies, keys, commit_id, auth_root, sr, ps));
157    }
158
159    Ok(commit_entries)
160}
161
162/// Export entries and persist them to storage.
163///
164/// This is a convenience function that combines export and storage.
165///
166/// # Errors
167///
168/// Returns `NoPrincipalGenesis` if the principal has no PR (Level 1/2).
169pub fn persist_entries<S: Store>(
170    store: &S,
171    principal: &Principal,
172) -> Result<usize, PersistError<S::Error>> {
173    let entries = export_entries(principal).map_err(PersistError::Export)?;
174    let pg = principal.pg().ok_or(PersistError::NoPrincipalGenesis)?;
175    let count = entries.len();
176    for entry in entries {
177        store
178            .append_entry(pg, &entry)
179            .map_err(PersistError::Store)?;
180    }
181    Ok(count)
182}
183
184/// Errors from persist_entries (combines export and store errors).
185#[derive(Debug, thiserror::Error)]
186pub enum PersistError<E: std::error::Error> {
187    /// Export failed.
188    #[error("export: {0}")]
189    Export(#[from] ExportError),
190    /// Store operation failed.
191    #[error("store: {0}")]
192    Store(E),
193    /// Principal has no PrincipalGenesis (Level 1/2 cannot be persisted).
194    #[error("persist_entries requires a Level 3+ principal with PrincipalGenesis")]
195    NoPrincipalGenesis,
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use coz::Thumbprint;
202    use cyphr::Key;
203    use serde_json::json;
204
205    fn make_test_key(id: u8) -> Key {
206        Key {
207            alg: "ES256".to_string(),
208            tmb: Thumbprint::from_bytes(vec![id; 32]),
209            pub_key: vec![id; 64],
210            first_seen: 1000,
211            last_used: None,
212            revocation: None,
213            tag: None,
214        }
215    }
216
217    #[test]
218    fn export_implicit_genesis_no_entries() {
219        // Implicit genesis has no cozies (identity emerges from key possession)
220        let principal = Principal::implicit(make_test_key(0xAA)).unwrap();
221        let entries = export_entries(&principal).unwrap();
222
223        // No cozies for implicit genesis
224        assert_eq!(entries.len(), 0);
225    }
226
227    #[test]
228    fn entry_from_value_extracts_now() {
229        use crate::Entry;
230
231        let raw = json!({
232            "pay": {"now": 12345, "typ": "test"},
233            "sig": "AAAA"
234        });
235
236        let entry = Entry::from_value(&raw).unwrap();
237        assert_eq!(entry.now, 12345);
238    }
239
240    #[test]
241    fn exported_entry_has_pay_and_sig() {
242        // We can't easily create a real coz without signature verification,
243        // but we can verify the CozJson serialization format
244        let coz_json = coz::CozJson {
245            pay: json!({"typ": "test", "now": 1000}),
246            sig: vec![0xDE, 0xAD, 0xBE, 0xEF],
247        };
248
249        let serialized = serde_json::to_value(&coz_json).unwrap();
250
251        // Verify structure has both pay and sig
252        assert!(serialized.get("pay").is_some(), "missing pay field");
253        assert!(serialized.get("sig").is_some(), "missing sig field");
254
255        // Verify sig is base64url encoded
256        let sig_str = serialized["sig"].as_str().unwrap();
257        assert!(!sig_str.is_empty(), "sig should not be empty");
258    }
259}