Skip to main content

cyphr_cli/commands/
io.rs

1//! Import/export commands.
2
3use std::fs::File;
4use std::io::{BufRead, BufReader, BufWriter, Write};
5use std::path::Path;
6
7use cyphr_storage::{CommitEntry, Genesis, load_principal_from_commits};
8
9use super::common::{extract_genesis_from_commits, parse_principal_genesis, parse_store};
10use crate::keystore::JsonKeyStore;
11use crate::{Cli, Error, OutputFormat};
12
13/// Run the export command.
14pub fn export(cli: &Cli, identity: &str, output: &Path) -> crate::Result<()> {
15    let store = parse_store(&cli.store)?;
16    let pr = parse_principal_genesis(identity)?;
17
18    // Get commits from storage
19    let commits = store.get_commits(&pr)?;
20
21    if commits.is_empty() {
22        return Err(Error::Storage(
23            "no commits found for identity (genesis-only state cannot be exported)".into(),
24        ));
25    }
26
27    // Write commits to JSONL file
28    let file = File::create(output)?;
29    let mut writer = BufWriter::new(file);
30
31    for commit in &commits {
32        let line = serde_json::to_string(commit)?;
33        writeln!(writer, "{}", line)?;
34    }
35    writer.flush()?;
36
37    match cli.output {
38        OutputFormat::Json => {
39            let result = serde_json::json!({
40                "identity": identity,
41                "output": output.display().to_string(),
42                "commits": commits.len(),
43            });
44            println!("{}", serde_json::to_string_pretty(&result)?);
45        },
46        OutputFormat::Table => {
47            println!("Exported identity to {}", output.display());
48            println!("  identity: {identity}");
49            println!("  commits: {}", commits.len());
50        },
51    }
52
53    Ok(())
54}
55
56/// Run the import command.
57pub fn import(cli: &Cli, input: &Path) -> crate::Result<()> {
58    let keystore = JsonKeyStore::open(&cli.keystore)?;
59    let store = parse_store(&cli.store)?;
60
61    // Read commits from JSONL file
62    let file = File::open(input)?;
63    let reader = BufReader::new(file);
64    let mut commits: Vec<CommitEntry> = Vec::new();
65
66    for (line_num, line_result) in reader.lines().enumerate() {
67        let line = line_result?;
68        if line.trim().is_empty() {
69            continue;
70        }
71        let commit: CommitEntry = serde_json::from_str(&line)
72            .map_err(|e| Error::Storage(format!("line {}: {}", line_num + 1, e)))?;
73        commits.push(commit);
74    }
75
76    if commits.is_empty() {
77        return Err(Error::Storage("no commits found in file".into()));
78    }
79
80    // Determine genesis from first commit
81    let genesis = extract_genesis_from_commits(&commits, Some(&keystore))?;
82
83    // Verify by loading the principal (this replays and verifies all cozies)
84    let principal = load_principal_from_commits(genesis.clone(), &commits)?;
85    // For Level 2 identities (no PR established), use the genesis thumbprint
86    let pr = match principal.pg() {
87        Some(pr) => pr.clone(),
88        None => match &genesis {
89            Genesis::Implicit(k) => cyphr::PrincipalGenesis::from_bytes(k.tmb.as_bytes().to_vec()),
90            Genesis::Explicit(_) => {
91                return Err(Error::Storage(
92                    "explicit genesis must establish a PR".into(),
93                ));
94            },
95        },
96    };
97
98    // Check if identity already exists in storage
99    let existing = store.get_commits(&pr).unwrap_or_default();
100    if !existing.is_empty() {
101        use base64ct::{Base64UrlUnpadded, Encoding};
102        let pr_b64 = pr
103            .as_multihash()
104            .first_variant()
105            .map(Base64UrlUnpadded::encode_string)
106            .map_err(|e| Error::Storage(format!("PR empty: {e}")))?;
107        return Err(Error::Storage(format!(
108            "identity {} already exists in storage",
109            pr_b64
110        )));
111    }
112
113    // Store commits
114    for commit in &commits {
115        store.append_commit(&pr, commit)?;
116    }
117
118    match cli.output {
119        OutputFormat::Json => {
120            use coz::base64ct::{Base64UrlUnpadded, Encoding};
121            let pr_b64 = pr
122                .as_multihash()
123                .first_variant()
124                .map(Base64UrlUnpadded::encode_string)
125                .map_err(|e| Error::Storage(format!("PR empty: {e}")))?;
126            let result = serde_json::json!({
127                "identity": pr_b64,
128                "input": input.display().to_string(),
129                "commits": commits.len(),
130                "verified": true,
131            });
132            println!("{}", serde_json::to_string_pretty(&result)?);
133        },
134        OutputFormat::Table => {
135            use coz::base64ct::{Base64UrlUnpadded, Encoding};
136            let pr_b64 = pr
137                .as_multihash()
138                .first_variant()
139                .map(Base64UrlUnpadded::encode_string)
140                .map_err(|e| Error::Storage(format!("PR empty: {e}")))?;
141            println!("Imported identity from {}", input.display());
142            println!("  identity: {}", pr_b64);
143            println!("  commits: {}", commits.len());
144            println!("  verified: OK");
145        },
146    }
147
148    Ok(())
149}