Skip to main content

lean_ctx/core/context_snapshot/
publish.rs

1//! Publish / import a Context Snapshot for sharing (#1027).
2//!
3//! Phase 4 of the Context Time Machine: take a stored snapshot out of the local
4//! timeline and hand it to someone else — and take theirs in. A snapshot is
5//! already a self-contained, content-addressed (`snapshot_id` = BLAKE3 of the
6//! body) artifact, so sharing is just two guarded file moves:
7//!
8//! - **publish** — write the snapshot to a portable `*.ctxsnapshot.json` file,
9//!   signing it first (reusing the ctxpkg publisher keypair) so the recipient
10//!   can verify provenance. Read-only on local state.
11//! - **import** — read such a file, prove its integrity (the body must still
12//!   hash to its id) and — when signed — its signature, then append it to the
13//!   local timeline so it can be `show`n, `verify`d and `restore`d. Idempotent:
14//!   re-importing the same id is a no-op.
15
16use std::path::{Path, PathBuf};
17
18use super::digest::compute_id;
19use super::signing::{sign_snapshot, verify_snapshot};
20use super::timeline;
21use super::types::ContextSnapshotV1;
22
23/// File suffix for a shared snapshot artifact.
24pub const SHARED_SUFFIX: &str = "ctxsnapshot.json";
25
26/// Where / how to publish.
27pub struct PublishOptions {
28    /// Project the snapshot belongs to (selects the publish directory default).
29    pub project_root: String,
30    /// Explicit output path; defaults to `./<shortid>.ctxsnapshot.json`.
31    pub out: Option<PathBuf>,
32}
33
34/// Result of publishing a snapshot to a shareable file.
35#[derive(Debug)]
36pub struct PublishOutcome {
37    pub path: PathBuf,
38    /// Publisher identity (ed25519 public key hex) the file is signed with.
39    pub public_key: String,
40    /// `true` if the snapshot was unsigned and got signed during publish.
41    pub newly_signed: bool,
42}
43
44/// Result of importing a shared snapshot file into the local timeline.
45#[derive(Debug)]
46pub struct ImportOutcome {
47    pub snapshot_id: String,
48    /// The snapshot carried a signature.
49    pub signed: bool,
50    /// The signature validated (always `false` when unsigned).
51    pub verified: bool,
52    /// The id was already in the local timeline; nothing was appended.
53    pub already_present: bool,
54    /// Where the payload lives in the local timeline.
55    pub path: PathBuf,
56}
57
58/// Publish a stored snapshot as a portable, signed file others can import.
59///
60/// Always ships signed — provenance is the entire point of sharing — but never
61/// mutates the locally stored snapshot: it signs a clone and writes that out.
62pub fn publish(
63    snapshot: &ContextSnapshotV1,
64    opts: &PublishOptions,
65) -> Result<PublishOutcome, String> {
66    let mut snap = snapshot.clone();
67    let newly_signed = snap.signature.is_none();
68    if newly_signed {
69        let (key, _newly_created) = crate::core::context_package::keys::load_or_create()?;
70        // Signing re-finalizes the id; the body is unchanged, so the id is
71        // identical — the recipient imports it under the same id.
72        sign_snapshot(&mut snap, &key)?;
73    }
74    let public_key = snap
75        .signature
76        .as_ref()
77        .map(|s| s.public_key.clone())
78        .unwrap_or_default();
79
80    let path = match &opts.out {
81        Some(p) => p.clone(),
82        None => default_publish_path(&snap.snapshot_id),
83    };
84    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
85        std::fs::create_dir_all(parent).map_err(|e| format!("create publish dir: {e}"))?;
86    }
87    let json =
88        serde_json::to_string_pretty(&snap).map_err(|e| format!("serialize snapshot: {e}"))?;
89    crate::config_io::write_atomic(&path, &json)?;
90
91    Ok(PublishOutcome {
92        path,
93        public_key,
94        newly_signed,
95    })
96}
97
98/// Import a shared snapshot file into the local project timeline.
99///
100/// Integrity is mandatory (the body must hash to its id); a present-but-invalid
101/// signature is fatal. An unsigned-but-intact snapshot imports with a warning
102/// (`signed == false`). Re-importing an id already in the timeline is a no-op.
103pub fn import(file: &Path, project_root: &str) -> Result<ImportOutcome, String> {
104    let content =
105        std::fs::read_to_string(file).map_err(|e| format!("read {}: {e}", file.display()))?;
106    let snap: ContextSnapshotV1 =
107        serde_json::from_str(&content).map_err(|e| format!("parse snapshot: {e}"))?;
108
109    if compute_id(&snap)? != snap.snapshot_id {
110        return Err("integrity check failed: snapshot body does not match its id".into());
111    }
112    let signed = snap.signature.is_some();
113    let verified = signed && verify_snapshot(&snap)?;
114    if signed && !verified {
115        return Err("signature verification failed — refusing to import".into());
116    }
117
118    let already_present = timeline::load_entries(project_root)
119        .iter()
120        .any(|e| e.snapshot_id == snap.snapshot_id);
121    let path = if already_present {
122        timeline::snapshots_dir(project_root)?.join(format!("{}.json", snap.snapshot_id))
123    } else {
124        timeline::write_snapshot(project_root, &snap)?
125    };
126
127    Ok(ImportOutcome {
128        snapshot_id: snap.snapshot_id,
129        signed,
130        verified,
131        already_present,
132        path,
133    })
134}
135
136/// `./<shortid>.ctxsnapshot.json` — share-ready in the current directory.
137fn default_publish_path(snapshot_id: &str) -> PathBuf {
138    let short: String = snapshot_id.chars().take(12).collect();
139    PathBuf::from(format!("{short}.{SHARED_SUFFIX}"))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::core::context_snapshot::digest::finalize_id;
146    use ed25519_dalek::SigningKey;
147
148    fn signed_snapshot() -> ContextSnapshotV1 {
149        let mut s = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
150        s.git.commit = Some("abc1234".into());
151        sign_snapshot(&mut s, &SigningKey::from_bytes(&[5u8; 32])).expect("sign");
152        s
153    }
154
155    #[test]
156    fn publish_then_import_roundtrips() {
157        let dir = tempfile::tempdir().unwrap();
158        let out = dir.path().join("share.ctxsnapshot.json");
159        let snap = signed_snapshot();
160
161        // Publish an already-signed snapshot: file is written, nothing re-signed.
162        let outcome = publish(
163            &snap,
164            &PublishOptions {
165                project_root: "/unused-for-explicit-out".into(),
166                out: Some(out.clone()),
167            },
168        )
169        .expect("publish");
170        assert!(!outcome.newly_signed);
171        assert!(out.exists());
172
173        // Import into a fresh project timeline rooted at the tempdir.
174        let project = dir.path().join("proj");
175        std::fs::create_dir_all(&project).unwrap();
176        let root = project.to_string_lossy().to_string();
177        let imported = import(&out, &root).expect("import");
178        assert_eq!(imported.snapshot_id, snap.snapshot_id);
179        assert!(imported.signed && imported.verified);
180        assert!(!imported.already_present);
181        assert_eq!(timeline::load_entries(&root).len(), 1);
182
183        // Re-import is idempotent — no duplicate timeline entry.
184        let again = import(&out, &root).expect("re-import");
185        assert!(again.already_present);
186        assert_eq!(timeline::load_entries(&root).len(), 1);
187    }
188
189    #[test]
190    fn import_rejects_a_tampered_body() {
191        let dir = tempfile::tempdir().unwrap();
192        let out = dir.path().join("tampered.ctxsnapshot.json");
193        let mut snap = signed_snapshot();
194        // Mutate the body after signing without re-hashing: id no longer matches.
195        snap.git.dirty = true;
196        std::fs::write(&out, serde_json::to_string_pretty(&snap).unwrap()).unwrap();
197
198        let project = dir.path().join("proj");
199        std::fs::create_dir_all(&project).unwrap();
200        let err = import(&out, &project.to_string_lossy()).unwrap_err();
201        assert!(err.contains("integrity"), "got: {err}");
202    }
203
204    #[test]
205    fn publish_signs_an_unsigned_snapshot() {
206        let dir = tempfile::tempdir().unwrap();
207        let out = dir.path().join("fresh.ctxsnapshot.json");
208        let mut snap = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
209        finalize_id(&mut snap).expect("finalize");
210        assert!(snap.signature.is_none());
211
212        let outcome = publish(
213            &snap,
214            &PublishOptions {
215                project_root: "/unused".into(),
216                out: Some(out.clone()),
217            },
218        )
219        .expect("publish");
220        assert!(outcome.newly_signed);
221        assert_eq!(outcome.public_key.len(), 64);
222
223        // The published file verifies and keeps the same id (body unchanged).
224        let written: ContextSnapshotV1 =
225            serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
226        assert_eq!(written.snapshot_id, snap.snapshot_id);
227        assert!(verify_snapshot(&written).expect("verify"));
228    }
229
230    #[test]
231    fn default_path_is_short_and_suffixed() {
232        let p = default_publish_path(&"a".repeat(64));
233        assert_eq!(p.to_string_lossy(), format!("aaaaaaaaaaaa.{SHARED_SUFFIX}"));
234    }
235}