lean_ctx/core/context_snapshot/
publish.rs1use 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
23pub const SHARED_SUFFIX: &str = "ctxsnapshot.json";
25
26pub struct PublishOptions {
28 pub project_root: String,
30 pub out: Option<PathBuf>,
32}
33
34#[derive(Debug)]
36pub struct PublishOutcome {
37 pub path: PathBuf,
38 pub public_key: String,
40 pub newly_signed: bool,
42}
43
44#[derive(Debug)]
46pub struct ImportOutcome {
47 pub snapshot_id: String,
48 pub signed: bool,
50 pub verified: bool,
52 pub already_present: bool,
54 pub path: PathBuf,
56}
57
58pub 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 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
98pub 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
136fn 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 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 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 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 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 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}