1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use crate::core::handoff_ledger::HandoffLedgerV1;
6
7const MAX_BUNDLE_BYTES: usize = 350_000;
8const MAX_PROOF_FILES: usize = 50;
9const MAX_ARTIFACT_ITEMS: usize = 80;
10const MAX_LEDGER_SNAPSHOT_CHARS: usize = 80_000;
11const MAX_CURATED_REF_CHARS: usize = 20_000;
12const MAX_DECISION_CHARS: usize = 2_000;
13const MAX_FINDING_CHARS: usize = 2_000;
14const MAX_NEXT_STEP_CHARS: usize = 1_000;
15const MAX_TASK_CHARS: usize = 4_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BundlePrivacyV1 {
19 Redacted,
20 Full,
21}
22
23impl BundlePrivacyV1 {
24 pub fn parse(s: Option<&str>) -> Self {
25 match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
26 "full" => Self::Full,
27 _ => Self::Redacted,
28 }
29 }
30
31 pub fn as_str(&self) -> &'static str {
32 match self {
33 Self::Redacted => "redacted",
34 Self::Full => "full",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct HandoffTransferBundleV1 {
41 pub schema_version: u32,
42 pub exported_at: DateTime<Utc>,
43 pub privacy: String,
44 pub project: ProjectIdentityV1,
45 pub ledger: HandoffLedgerV1,
46 pub artifacts: ArtifactsExcerptV1,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProjectIdentityV1 {
51 pub project_root_hash: Option<String>,
52 pub project_identity_hash: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct ArtifactsExcerptV1 {
57 pub resolved: Vec<crate::core::artifacts::ResolvedArtifact>,
58 pub proof_files: Vec<ProofFileV1>,
59 pub warnings: Vec<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ProofFileV1 {
64 pub name: String,
65 pub md5: String,
66 pub bytes: u64,
67}
68
69pub fn build_bundle_v1(
70 mut ledger: HandoffLedgerV1,
71 project_root: Option<&str>,
72 privacy: BundlePrivacyV1,
73) -> HandoffTransferBundleV1 {
74 let role_name = crate::core::roles::active_role_name();
75 let effective_privacy = match privacy {
76 BundlePrivacyV1::Full
77 if role_name == "admin"
78 && !crate::core::redaction::redaction_enabled_for_active_role() =>
79 {
80 BundlePrivacyV1::Full
81 }
82 _ => BundlePrivacyV1::Redacted,
83 };
84
85 let (project_root_hash, project_identity_hash) = project_root.map_or((None, None), |root| {
86 let root_hash = crate::core::project_hash::hash_project_root(root);
87 let identity = crate::core::project_hash::project_identity(root);
88 let identity_hash = identity.as_deref().map(md5_hex);
89 (Some(root_hash), identity_hash)
90 });
91
92 cap_ledger_in_place(&mut ledger);
93
94 match effective_privacy {
95 BundlePrivacyV1::Full => {}
96 BundlePrivacyV1::Redacted => {
97 redact_ledger_in_place(&mut ledger);
98 }
99 }
100
101 ledger.content_md5 = crate::core::handoff_ledger::compute_content_md5_for_ledger(&ledger);
103
104 let artifacts = project_root
105 .map(Path::new)
106 .map(build_artifacts_excerpt_v1)
107 .unwrap_or_default();
108
109 HandoffTransferBundleV1 {
110 schema_version: crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION,
111 exported_at: Utc::now(),
112 privacy: effective_privacy.as_str().to_string(),
113 project: ProjectIdentityV1 {
114 project_root_hash,
115 project_identity_hash,
116 },
117 ledger,
118 artifacts,
119 }
120}
121
122pub fn serialize_bundle_v1_pretty(bundle: &HandoffTransferBundleV1) -> Result<String, String> {
123 let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
124 if json.len() > MAX_BUNDLE_BYTES {
125 return Err(format!(
126 "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce curated refs.",
127 json.len(),
128 MAX_BUNDLE_BYTES
129 ));
130 }
131 Ok(json)
132}
133
134pub fn parse_bundle_v1(json: &str) -> Result<HandoffTransferBundleV1, String> {
135 let b: HandoffTransferBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
136 if b.schema_version != crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION {
137 return Err(format!(
138 "ERROR: unsupported schema_version {} (expected {})",
139 b.schema_version,
140 crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION
141 ));
142 }
143 Ok(b)
144}
145
146pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
147 let parent = path
148 .parent()
149 .ok_or_else(|| "ERROR: invalid path".to_string())?;
150 if !parent.exists() {
151 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
152 }
153 let tmp = parent.join(format!(
154 ".{}.tmp",
155 path.file_name()
156 .and_then(|s| s.to_str())
157 .unwrap_or("bundle")
158 ));
159 std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
160 std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
161 Ok(())
162}
163
164pub fn read_bundle_v1(path: &Path) -> Result<HandoffTransferBundleV1, String> {
165 let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
166 if json.len() > MAX_BUNDLE_BYTES {
167 return Err(format!(
168 "ERROR: bundle file too large ({} bytes > max {})",
169 json.len(),
170 MAX_BUNDLE_BYTES
171 ));
172 }
173 parse_bundle_v1(&json)
174}
175
176pub fn project_identity_warning(
177 bundle: &HandoffTransferBundleV1,
178 project_root: &str,
179) -> Option<String> {
180 let current_root_hash = crate::core::project_hash::hash_project_root(project_root);
181 let current_identity_hash = crate::core::project_hash::project_identity(project_root)
182 .as_deref()
183 .map(md5_hex);
184
185 if let Some(ref exported) = bundle.project.project_root_hash {
186 if exported != ¤t_root_hash {
187 return Some(
188 "WARNING: project_root_hash mismatch (importing into different project root)."
189 .to_string(),
190 );
191 }
192 }
193
194 if let (Some(exported), Some(current)) = (
195 bundle.project.project_identity_hash.as_ref(),
196 current_identity_hash.as_ref(),
197 ) {
198 if exported != current {
199 return Some(
200 "WARNING: project_identity_hash mismatch (importing into different project identity)."
201 .to_string(),
202 );
203 }
204 }
205
206 None
207}
208
209fn build_artifacts_excerpt_v1(project_root: &Path) -> ArtifactsExcerptV1 {
210 let mut out = ArtifactsExcerptV1::default();
211
212 let resolved = crate::core::artifacts::load_resolved(project_root);
213 out.warnings.extend(resolved.warnings);
214 out.resolved = resolved
215 .artifacts
216 .into_iter()
217 .take(MAX_ARTIFACT_ITEMS)
218 .collect();
219
220 let proofs_dir = project_root.join(".lean-ctx").join("proofs");
221 if let Ok(rd) = std::fs::read_dir(&proofs_dir) {
222 let mut files = Vec::new();
223 for e in rd.flatten() {
224 let p = e.path();
225 if !p.is_file() {
226 continue;
227 }
228 let name = p
229 .file_name()
230 .map(|n| n.to_string_lossy().to_string())
231 .unwrap_or_default();
232 if name.is_empty() {
233 continue;
234 }
235 let bytes = p.metadata().map_or(0, |m| m.len());
236 let md5 = match std::fs::read(&p) {
237 Ok(b) => md5_hex_bytes(&b),
238 Err(e) => {
239 out.warnings
240 .push(format!("proof read failed: {} ({e})", p.display()));
241 continue;
242 }
243 };
244 files.push(ProofFileV1 { name, md5, bytes });
245 }
246 files.sort_by(|a, b| a.name.cmp(&b.name));
247 out.proof_files = files.into_iter().take(MAX_PROOF_FILES).collect();
248 }
249
250 out
251}
252
253fn cap_ledger_in_place(ledger: &mut HandoffLedgerV1) {
254 if ledger.session_snapshot.len() > MAX_LEDGER_SNAPSHOT_CHARS {
255 ledger.session_snapshot =
256 truncate_chars(&ledger.session_snapshot, MAX_LEDGER_SNAPSHOT_CHARS);
257 }
258
259 if let Some(ref mut task) = ledger.session.task {
260 *task = truncate_chars(task, MAX_TASK_CHARS);
261 }
262
263 for d in &mut ledger.session.decisions {
264 *d = truncate_chars(d, MAX_DECISION_CHARS);
265 }
266 for f in &mut ledger.session.findings {
267 *f = truncate_chars(f, MAX_FINDING_CHARS);
268 }
269 for s in &mut ledger.session.next_steps {
270 *s = truncate_chars(s, MAX_NEXT_STEP_CHARS);
271 }
272
273 for r in &mut ledger.curated_refs {
274 if r.content.len() > MAX_CURATED_REF_CHARS {
275 r.content = truncate_chars(&r.content, MAX_CURATED_REF_CHARS);
276 }
277 }
278}
279
280fn redact_ledger_in_place(ledger: &mut HandoffLedgerV1) {
281 ledger.project_root = None;
282 ledger.session_snapshot.clear();
283
284 if let Some(ref mut task) = ledger.session.task {
285 *task = crate::core::redaction::redact_text(task);
286 }
287 for d in &mut ledger.session.decisions {
288 *d = crate::core::redaction::redact_text(d);
289 }
290 for f in &mut ledger.session.findings {
291 *f = crate::core::redaction::redact_text(f);
292 }
293 for s in &mut ledger.session.next_steps {
294 *s = crate::core::redaction::redact_text(s);
295 }
296
297 for fact in &mut ledger.knowledge.facts {
298 fact.value = crate::core::redaction::redact_text(&fact.value);
299 }
300
301 for r in &mut ledger.curated_refs {
302 r.content = crate::core::redaction::redact_text(&r.content);
303 }
304}
305
306fn truncate_chars(s: &str, max: usize) -> String {
307 if s.chars().count() <= max {
308 return s.to_string();
309 }
310 s.chars().take(max).collect::<String>()
311}
312
313fn md5_hex(text: &str) -> String {
314 use md5::{Digest, Md5};
315 let mut hasher = Md5::new();
316 hasher.update(text.as_bytes());
317 format!("{:x}", hasher.finalize())
318}
319
320fn md5_hex_bytes(bytes: &[u8]) -> String {
321 use md5::{Digest, Md5};
322 let mut hasher = Md5::new();
323 hasher.update(bytes);
324 format!("{:x}", hasher.finalize())
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn sample_ledger() -> HandoffLedgerV1 {
332 HandoffLedgerV1 {
333 schema_version: crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION,
334 created_at: "20260503T000000Z".to_string(),
335 content_md5: "old".to_string(),
336 manifest_md5: "m".to_string(),
337 project_root: Some("/abs/project".to_string()),
338 agent_id: Some("a".to_string()),
339 client_name: Some("cursor".to_string()),
340 workflow: None,
341 session_snapshot: "snapshot".to_string(),
342 session: crate::core::handoff_ledger::SessionExcerpt {
343 id: "s".to_string(),
344 task: Some("task".to_string()),
345 decisions: vec!["d1".to_string()],
346 findings: vec!["f1".to_string()],
347 next_steps: vec!["n1".to_string()],
348 },
349 tool_calls: crate::core::handoff_ledger::ToolCallsSummary::default(),
350 evidence_keys: vec!["tool:ctx_read".to_string()],
351 knowledge: crate::core::handoff_ledger::KnowledgeExcerpt {
352 project_hash: None,
353 facts: vec![crate::core::handoff_ledger::KnowledgeFactMini {
354 category: "c".to_string(),
355 key: "k".to_string(),
356 value: "secret=abcdef0123456789abcdef0123456789".to_string(),
357 confidence: 0.9,
358 }],
359 },
360 curated_refs: vec![crate::core::handoff_ledger::CuratedRef {
361 path: "src/lib.rs".to_string(),
362 mode: "signatures".to_string(),
363 content_md5: "x".to_string(),
364 content: "fn a() {}".to_string(),
365 }],
366 active_overlays: Vec::new(),
367 }
368 }
369
370 #[test]
371 fn redacted_bundle_removes_sensitive_fields() {
372 let ledger = sample_ledger();
373 let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
374 assert_eq!(b.privacy, "redacted");
375 assert!(b.ledger.project_root.is_none());
376 assert!(b.ledger.session_snapshot.is_empty());
377 }
378
379 #[test]
380 fn serialize_parse_roundtrip() {
381 let ledger = sample_ledger();
382 let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
383 let json = serialize_bundle_v1_pretty(&b).expect("json");
384 assert!(json.len() < MAX_BUNDLE_BYTES);
385 let parsed = parse_bundle_v1(&json).expect("parse");
386 assert_eq!(parsed.schema_version, b.schema_version);
387 assert_eq!(parsed.privacy, "redacted");
388 }
389}