use std::path::{Path, PathBuf};
use super::digest::compute_id;
use super::signing::{sign_snapshot, verify_snapshot};
use super::timeline;
use super::types::ContextSnapshotV1;
pub(crate) const SHARED_SUFFIX: &str = "ctxsnapshot.json";
pub(crate) struct PublishOptions {
pub project_root: String,
pub out: Option<PathBuf>,
}
#[derive(Debug)]
pub(crate) struct PublishOutcome {
pub path: PathBuf,
pub public_key: String,
pub newly_signed: bool,
}
#[derive(Debug)]
pub(crate) struct ImportOutcome {
pub snapshot_id: String,
pub signed: bool,
pub verified: bool,
pub already_present: bool,
pub path: PathBuf,
}
pub(crate) fn publish(
snapshot: &ContextSnapshotV1,
opts: &PublishOptions,
) -> Result<PublishOutcome, String> {
let mut snap = snapshot.clone();
let newly_signed = snap.signature.is_none();
if newly_signed {
let (key, _newly_created) = crate::core::context_package::keys::load_or_create()?;
sign_snapshot(&mut snap, &key)?;
}
let public_key = snap
.signature
.as_ref()
.map(|s| s.public_key.clone())
.unwrap_or_default();
let path = match &opts.out {
Some(p) => p.clone(),
None => default_publish_path(&snap.snapshot_id),
};
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|e| format!("create publish dir: {e}"))?;
}
let json =
serde_json::to_string_pretty(&snap).map_err(|e| format!("serialize snapshot: {e}"))?;
crate::config_io::write_atomic(&path, &json)?;
Ok(PublishOutcome {
path,
public_key,
newly_signed,
})
}
pub(crate) fn import(file: &Path, project_root: &str) -> Result<ImportOutcome, String> {
let content =
std::fs::read_to_string(file).map_err(|e| format!("read {}: {e}", file.display()))?;
let snap: ContextSnapshotV1 =
serde_json::from_str(&content).map_err(|e| format!("parse snapshot: {e}"))?;
if compute_id(&snap)? != snap.snapshot_id {
return Err("integrity check failed: snapshot body does not match its id".into());
}
let signed = snap.signature.is_some();
let verified = signed && verify_snapshot(&snap)?;
if signed && !verified {
return Err("signature verification failed — refusing to import".into());
}
let already_present = timeline::load_entries(project_root)
.iter()
.any(|e| e.snapshot_id == snap.snapshot_id);
let path = if already_present {
timeline::snapshots_dir(project_root)?.join(format!("{}.json", snap.snapshot_id))
} else {
timeline::write_snapshot(project_root, &snap)?
};
Ok(ImportOutcome {
snapshot_id: snap.snapshot_id,
signed,
verified,
already_present,
path,
})
}
fn default_publish_path(snapshot_id: &str) -> PathBuf {
let short: String = snapshot_id.chars().take(12).collect();
PathBuf::from(format!("{short}.{SHARED_SUFFIX}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::context_snapshot::digest::finalize_id;
use ed25519_dalek::SigningKey;
fn signed_snapshot() -> ContextSnapshotV1 {
let mut s = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
s.git.commit = Some("abc1234".into());
sign_snapshot(&mut s, &SigningKey::from_bytes(&[5u8; 32])).expect("sign");
s
}
#[test]
fn publish_then_import_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("share.ctxsnapshot.json");
let snap = signed_snapshot();
let outcome = publish(
&snap,
&PublishOptions {
project_root: "/unused-for-explicit-out".into(),
out: Some(out.clone()),
},
)
.expect("publish");
assert!(!outcome.newly_signed);
assert!(out.exists());
let project = dir.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let root = project.to_string_lossy().to_string();
let imported = import(&out, &root).expect("import");
assert_eq!(imported.snapshot_id, snap.snapshot_id);
assert!(imported.signed && imported.verified);
assert!(!imported.already_present);
assert_eq!(timeline::load_entries(&root).len(), 1);
let again = import(&out, &root).expect("re-import");
assert!(again.already_present);
assert_eq!(timeline::load_entries(&root).len(), 1);
}
#[test]
fn import_rejects_a_tampered_body() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("tampered.ctxsnapshot.json");
let mut snap = signed_snapshot();
snap.git.dirty = true;
std::fs::write(&out, serde_json::to_string_pretty(&snap).unwrap()).unwrap();
let project = dir.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let err = import(&out, &project.to_string_lossy()).unwrap_err();
assert!(err.contains("integrity"), "got: {err}");
}
#[test]
fn publish_signs_an_unsigned_snapshot() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fresh.ctxsnapshot.json");
let mut snap = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
finalize_id(&mut snap).expect("finalize");
assert!(snap.signature.is_none());
let outcome = publish(
&snap,
&PublishOptions {
project_root: "/unused".into(),
out: Some(out.clone()),
},
)
.expect("publish");
assert!(outcome.newly_signed);
assert_eq!(outcome.public_key.len(), 64);
let written: ContextSnapshotV1 =
serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
assert_eq!(written.snapshot_id, snap.snapshot_id);
assert!(verify_snapshot(&written).expect("verify"));
}
#[test]
fn default_path_is_short_and_suffixed() {
let p = default_publish_path(&"a".repeat(64));
assert_eq!(p.to_string_lossy(), format!("aaaaaaaaaaaa.{SHARED_SUFFIX}"));
}
}