1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::core::session::{
6 Decision, EvidenceRecord, FileTouched, Finding, ProgressEntry, SessionState, SessionStats,
7 TaskInfo, TestSnapshot,
8};
9
10const MAX_BUNDLE_BYTES: usize = 250_000;
11const MAX_NEXT_STEPS: usize = 25;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BundlePrivacyV1 {
15 Redacted,
16 Full,
17}
18
19impl BundlePrivacyV1 {
20 pub fn parse(s: Option<&str>) -> Self {
21 match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
22 "full" => Self::Full,
23 _ => Self::Redacted,
24 }
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CcpSessionBundleV1 {
30 pub schema_version: u32,
31 pub exported_at: DateTime<Utc>,
32 pub project: ProjectIdentityV1,
33 pub role: PolicyIdentityV1,
34 pub profile: PolicyIdentityV1,
35 pub session: SessionExcerptV1,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProjectIdentityV1 {
40 pub project_root_hash: Option<String>,
41 pub project_identity_hash: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PolicyIdentityV1 {
46 pub name: String,
47 pub policy_md5: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionExcerptV1 {
52 pub id: String,
53 pub version: u32,
54 pub started_at: DateTime<Utc>,
55 pub updated_at: DateTime<Utc>,
56 pub project_root: Option<String>,
57 pub shell_cwd: Option<String>,
58 pub task: Option<TaskInfo>,
59 pub findings: Vec<Finding>,
60 pub decisions: Vec<Decision>,
61 pub files_touched: Vec<FileTouched>,
62 pub test_results: Option<TestSnapshot>,
63 pub progress: Vec<ProgressEntry>,
64 pub next_steps: Vec<String>,
65 pub evidence: Vec<EvidenceRecord>,
66 pub stats: SessionStats,
67 #[serde(default)]
68 pub terse_mode: bool,
69 #[serde(default)]
70 pub compression_level: String,
71}
72
73pub fn build_bundle_v1(session: &SessionState, privacy: BundlePrivacyV1) -> CcpSessionBundleV1 {
74 let role_name = crate::core::roles::active_role_name();
75 let role = crate::core::roles::active_role();
76 let profile_name = crate::core::profiles::active_profile_name();
77 let profile = crate::core::profiles::active_profile();
78
79 let role_policy_md5 =
80 crate::core::hasher::hash_str(&serde_json::to_string(&role).unwrap_or_default());
81 let profile_policy_md5 =
82 crate::core::hasher::hash_str(&serde_json::to_string(&profile).unwrap_or_default());
83
84 let (project_root_hash, project_identity_hash) =
85 session
86 .project_root
87 .as_deref()
88 .map_or((None, None), |root| {
89 let root_hash = crate::core::project_hash::hash_project_root(root);
90 let identity = crate::core::project_hash::project_identity(root);
91 let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
92 (Some(root_hash), identity_hash)
93 });
94
95 let mut excerpt = SessionExcerptV1 {
96 id: session.id.clone(),
97 version: session.version,
98 started_at: session.started_at,
99 updated_at: session.updated_at,
100 project_root: session.project_root.clone(),
101 shell_cwd: session.shell_cwd.clone(),
102 task: session.task.clone(),
103 findings: session.findings.clone(),
104 decisions: session.decisions.clone(),
105 files_touched: session.files_touched.clone(),
106 test_results: session.test_results.clone(),
107 progress: session.progress.clone(),
108 next_steps: session
109 .next_steps
110 .iter()
111 .take(MAX_NEXT_STEPS)
112 .cloned()
113 .collect(),
114 evidence: session.evidence.clone(),
115 stats: session.stats.clone(),
116 terse_mode: session.terse_mode,
117 compression_level: session.compression_level.clone(),
118 };
119
120 let root = excerpt.project_root.clone().unwrap_or_default();
122 if !root.is_empty() {
123 for f in &mut excerpt.files_touched {
124 if let Some(rel) = strip_root_prefix(&root, &f.path) {
125 f.path = rel;
126 }
127 }
128 for finding in &mut excerpt.findings {
129 if let Some(ref file) = finding.file.clone() {
130 if let Some(rel) = strip_root_prefix(&root, file) {
131 finding.file = Some(rel);
132 }
133 }
134 }
135 }
136
137 match privacy {
138 BundlePrivacyV1::Full => {
139 if role_name != "admin" {
141 redact_excerpt_in_place(&mut excerpt);
142 } else if crate::core::redaction::redaction_enabled_for_active_role() {
143 redact_excerpt_in_place(&mut excerpt);
145 }
146 }
147 BundlePrivacyV1::Redacted => {
148 redact_excerpt_in_place(&mut excerpt);
149 }
150 }
151
152 CcpSessionBundleV1 {
153 schema_version: crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION,
154 exported_at: Utc::now(),
155 project: ProjectIdentityV1 {
156 project_root_hash,
157 project_identity_hash,
158 },
159 role: PolicyIdentityV1 {
160 name: role_name,
161 policy_md5: role_policy_md5,
162 },
163 profile: PolicyIdentityV1 {
164 name: profile_name,
165 policy_md5: profile_policy_md5,
166 },
167 session: excerpt,
168 }
169}
170
171pub fn serialize_bundle_v1_pretty(bundle: &CcpSessionBundleV1) -> Result<String, String> {
172 let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
173 if json.len() > MAX_BUNDLE_BYTES {
174 return Err(format!(
175 "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce session evidence.",
176 json.len(),
177 MAX_BUNDLE_BYTES
178 ));
179 }
180 Ok(json)
181}
182
183pub fn parse_bundle_v1(json: &str) -> Result<CcpSessionBundleV1, String> {
184 let b: CcpSessionBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
185 if b.schema_version != crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION {
186 return Err(format!(
187 "ERROR: unsupported schema_version {} (expected {})",
188 b.schema_version,
189 crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION
190 ));
191 }
192 Ok(b)
193}
194
195pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
196 let parent = path
197 .parent()
198 .ok_or_else(|| "ERROR: invalid path".to_string())?;
199 if !parent.exists() {
200 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
201 }
202 let tmp = parent.join(format!(
203 ".{}.tmp",
204 path.file_name()
205 .and_then(|s| s.to_str())
206 .unwrap_or("bundle")
207 ));
208 std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
209 std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
210 Ok(())
211}
212
213pub fn read_bundle_v1(path: &Path) -> Result<CcpSessionBundleV1, String> {
214 let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
215 if json.len() > MAX_BUNDLE_BYTES {
216 return Err(format!(
217 "ERROR: bundle file too large ({} bytes > max {})",
218 json.len(),
219 MAX_BUNDLE_BYTES
220 ));
221 }
222 parse_bundle_v1(&json)
223}
224
225pub fn import_bundle_v1_into_session(
226 session: &mut SessionState,
227 bundle: &CcpSessionBundleV1,
228 current_project_root: Option<&str>,
229) -> ImportReportV1 {
230 let mut imported = bundle.session.clone();
231
232 if let Some(root) = current_project_root {
234 imported.project_root = Some(root.to_string());
235 }
236
237 let jail_root = imported.project_root.clone().unwrap_or_else(|| {
239 std::env::current_dir()
240 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
241 });
242 let jail_root_path = PathBuf::from(&jail_root);
243
244 let mut stale = 0u32;
245 for f in &mut imported.files_touched {
246 let candidate = candidate_path(&jail_root_path, &f.path);
247 if let Ok((jailed, _warning)) = crate::core::io_boundary::jail_and_check_path(
248 "ctx_session.import",
249 candidate.as_path(),
250 jail_root_path.as_path(),
251 ) {
252 if jailed.exists() {
253 f.stale = false;
254 } else {
255 f.stale = true;
256 stale += 1;
257 }
258 } else {
259 f.stale = true;
260 stale += 1;
261 }
262 }
263
264 *session = SessionState {
265 id: imported.id.clone(),
266 version: imported.version,
267 started_at: imported.started_at,
268 updated_at: imported.updated_at,
269 project_root: imported.project_root.clone(),
270 shell_cwd: imported.shell_cwd.clone(),
271 task: imported.task.clone(),
272 findings: imported.findings.clone(),
273 decisions: imported.decisions.clone(),
274 files_touched: imported.files_touched.clone(),
275 test_results: imported.test_results.clone(),
276 progress: imported.progress.clone(),
277 next_steps: imported.next_steps.clone(),
278 evidence: imported.evidence.clone(),
279 intents: Vec::new(),
280 active_structured_intent: None,
281 stats: imported.stats.clone(),
282 terse_mode: imported.terse_mode,
283 compression_level: imported.compression_level.clone(),
284 last_consolidate_ts: None,
285 extra_roots: Vec::new(),
286 };
287
288 ImportReportV1 {
289 session_id: session.id.clone(),
290 version: session.version,
291 files_touched: session.files_touched.len() as u32,
292 stale_files: stale,
293 }
294}
295
296#[derive(Debug, Clone)]
297pub struct ImportReportV1 {
298 pub session_id: String,
299 pub version: u32,
300 pub files_touched: u32,
301 pub stale_files: u32,
302}
303
304fn redact_excerpt_in_place(ex: &mut SessionExcerptV1) {
305 ex.shell_cwd = None;
306 ex.project_root = None;
308
309 if let Some(ref mut t) = ex.task {
310 t.description = crate::core::redaction::redact_text(&t.description);
311 if let Some(ref mut intent) = t.intent {
312 *intent = crate::core::redaction::redact_text(intent);
313 }
314 }
315 for f in &mut ex.findings {
316 f.summary = crate::core::redaction::redact_text(&f.summary);
317 if let Some(ref mut file) = f.file {
318 *file = crate::core::redaction::redact_text(file);
319 }
320 }
321 for d in &mut ex.decisions {
322 d.summary = crate::core::redaction::redact_text(&d.summary);
323 if let Some(ref mut r) = d.rationale {
324 *r = crate::core::redaction::redact_text(r);
325 }
326 }
327 for p in &mut ex.progress {
328 p.action = crate::core::redaction::redact_text(&p.action);
329 if let Some(ref mut detail) = p.detail {
330 *detail = crate::core::redaction::redact_text(detail);
331 }
332 }
333 for s in &mut ex.next_steps {
334 *s = crate::core::redaction::redact_text(s);
335 }
336 for ev in &mut ex.evidence {
337 ev.value = None;
338 }
339}
340
341fn strip_root_prefix(root: &str, path: &str) -> Option<String> {
342 let root = root.trim_end_matches(std::path::MAIN_SEPARATOR);
343 let root_prefix = format!("{root}{}", std::path::MAIN_SEPARATOR);
344 if path.starts_with(&root_prefix) {
345 Some(path.trim_start_matches(&root_prefix).to_string())
346 } else {
347 None
348 }
349}
350
351fn candidate_path(jail_root: &Path, stored_path: &str) -> PathBuf {
352 let p = PathBuf::from(stored_path);
353 if p.is_absolute() {
354 p
355 } else {
356 jail_root.join(p)
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn redacted_export_drops_evidence_values() {
366 let mut s = SessionState::new();
367 s.record_manual_evidence("k", Some("secret=abcdef0123456789abcdef0123456789"));
368 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
369 assert!(b.session.evidence.iter().all(|e| e.value.is_none()));
370 }
371
372 #[test]
373 fn serialize_respects_size_cap() {
374 let s = SessionState::new();
375 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
376 let json = serialize_bundle_v1_pretty(&b).expect("json");
377 assert!(json.len() < MAX_BUNDLE_BYTES);
378 let parsed = parse_bundle_v1(&json).expect("parse");
379 assert_eq!(parsed.schema_version, b.schema_version);
380 }
381
382 #[test]
383 fn import_marks_missing_files_stale() {
384 let mut s = SessionState::new();
385 s.project_root = Some(
386 std::env::current_dir()
387 .unwrap()
388 .to_string_lossy()
389 .to_string(),
390 );
391 s.touch_file("does-not-exist-xyz.txt", None, "full", 10);
392 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
393
394 let root = std::env::current_dir()
395 .unwrap()
396 .to_string_lossy()
397 .to_string();
398 let mut target = SessionState::new();
399 let report = import_bundle_v1_into_session(&mut target, &b, Some(&root));
400 assert_eq!(report.files_touched, 1);
401 assert_eq!(report.stale_files, 1);
402 assert!(target.files_touched[0].stale);
403 }
404}