use serde::{Deserialize, Serialize};
use super::cursor_transcript::{parse_cursor_transcript, ValidatedCursorTranscript};
pub(crate) const CURSOR_TRANSCRIPT_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub(crate) const CURSOR_FIDELITY_FULL: &str = "full";
pub(crate) const CURSOR_FIDELITY_DEGRADED: &str = "degraded";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CursorCaptureMarker {
pub(crate) fidelity: String,
pub(crate) reason_code: Option<String>,
pub(crate) status: String,
pub(crate) generation_id: String,
pub(crate) loop_count: u64,
pub(crate) stop_key: String,
pub(crate) snapshot: Option<CursorSnapshotEvidence>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CursorSnapshotEvidence {
pub(crate) snapshot_hash: String,
pub(crate) snapshot_byte_len: u64,
pub(crate) record_count: u64,
pub(crate) messages: Vec<CursorSnapshotMessage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CursorSnapshotMessage {
pub(crate) ordinal: u64,
pub(crate) role: String,
pub(crate) text: String,
}
#[derive(Debug)]
pub(crate) enum CursorSnapshotOutcome {
Full {
evidence: CursorSnapshotEvidence,
last_assistant_message: Option<String>,
},
Degraded {
reason: &'static str,
},
}
impl CursorSnapshotOutcome {
pub(crate) fn reason_code(&self) -> Option<&'static str> {
match self {
CursorSnapshotOutcome::Full { .. } => None,
CursorSnapshotOutcome::Degraded { reason } => Some(reason),
}
}
}
pub(crate) const REASON_PATH_ABSENT: &str = "path_absent";
pub(crate) const REASON_PATH_BLANK: &str = "path_blank";
pub(crate) const REASON_PATH_UNTRUSTED: &str = "path_untrusted";
pub(crate) const REASON_READ_FAILED: &str = "read_failed";
pub(crate) const REASON_SNAPSHOT_CHANGED: &str = "snapshot_changed";
pub(crate) const REASON_SNAPSHOT_TOO_LARGE: &str = "snapshot_too_large";
pub(crate) const REASON_FORMAT_INVALID: &str = "format_invalid";
pub(crate) const REASON_TRANSCRIPT_EMPTY: &str = "transcript_empty";
pub(crate) fn capture_stop_snapshot(transcript_path: Option<&str>) -> CursorSnapshotOutcome {
let Some(raw_path) = transcript_path else {
return CursorSnapshotOutcome::Degraded {
reason: REASON_PATH_ABSENT,
};
};
if raw_path.trim().is_empty() {
return CursorSnapshotOutcome::Degraded {
reason: REASON_PATH_BLANK,
};
}
if !cursor_transcript_path_is_trusted(raw_path) {
return CursorSnapshotOutcome::Degraded {
reason: REASON_PATH_UNTRUSTED,
};
}
let metadata = match std::fs::symlink_metadata(raw_path) {
Ok(metadata) => metadata,
Err(_) => {
return CursorSnapshotOutcome::Degraded {
reason: REASON_READ_FAILED,
}
}
};
if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
return CursorSnapshotOutcome::Degraded {
reason: REASON_PATH_UNTRUSTED,
};
}
if metadata.len() > CURSOR_TRANSCRIPT_MAX_BYTES {
return CursorSnapshotOutcome::Degraded {
reason: REASON_SNAPSHOT_TOO_LARGE,
};
}
let bytes = match std::fs::read(raw_path) {
Ok(bytes) => bytes,
Err(_) => {
return CursorSnapshotOutcome::Degraded {
reason: REASON_READ_FAILED,
}
}
};
if bytes.len() as u64 > CURSOR_TRANSCRIPT_MAX_BYTES {
return CursorSnapshotOutcome::Degraded {
reason: REASON_SNAPSHOT_TOO_LARGE,
};
}
if bytes.len() as u64 != metadata.len() {
return CursorSnapshotOutcome::Degraded {
reason: REASON_SNAPSHOT_CHANGED,
};
}
let transcript = match parse_cursor_transcript(&bytes) {
Ok(transcript) => transcript,
Err(error) => {
crate::log::error(
"summarize",
&format!(
"cursor transcript full validation failed; degrading to payload-only: {error:#}"
),
);
return CursorSnapshotOutcome::Degraded {
reason: REASON_FORMAT_INVALID,
};
}
};
build_full_outcome(&transcript)
}
fn build_full_outcome(transcript: &ValidatedCursorTranscript) -> CursorSnapshotOutcome {
let messages: Vec<CursorSnapshotMessage> = transcript
.usable_messages()
.map(|(ordinal, role, text)| CursorSnapshotMessage {
ordinal,
role: role.as_str().to_string(),
text: crate::adapter::common::redact_sensitive_text(text),
})
.collect();
if messages.is_empty() {
return CursorSnapshotOutcome::Degraded {
reason: REASON_TRANSCRIPT_EMPTY,
};
}
let last_assistant_message = transcript
.last_assistant_text()
.map(crate::adapter::common::redact_sensitive_text);
CursorSnapshotOutcome::Full {
evidence: CursorSnapshotEvidence {
snapshot_hash: transcript.snapshot_hash.clone(),
snapshot_byte_len: transcript.snapshot_byte_len,
record_count: transcript.records.len() as u64,
messages,
},
last_assistant_message,
}
}
fn cursor_transcript_path_is_trusted(raw_path: &str) -> bool {
if raw_path.contains('\\') || !raw_path.starts_with('/') || raw_path.starts_with("//") {
return false;
}
let mut chars = raw_path.chars();
let _slash = chars.next();
if let (Some(first), Some(second)) = (chars.next(), chars.next()) {
if first.is_ascii_alphabetic() && second == ':' {
return false;
}
}
std::path::Path::new(raw_path)
.components()
.all(|component| {
matches!(
component,
std::path::Component::RootDir | std::path::Component::Normal(_)
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_transcript(name: &str, contents: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"remem-gh825-{name}-{}-{}.jsonl",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
std::fs::write(&path, contents).expect("write cursor transcript fixture");
path
}
const VALID: &str = concat!(
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"question\"}]}}\n",
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"answer\"}]}}\n",
"{\"type\":\"turn_ended\",\"status\":\"success\"}\n",
);
#[test]
fn missing_and_blank_paths_degrade_without_dropping_the_stop() {
assert_eq!(
capture_stop_snapshot(None).reason_code(),
Some(REASON_PATH_ABSENT)
);
assert_eq!(
capture_stop_snapshot(Some(" ")).reason_code(),
Some(REASON_PATH_BLANK)
);
}
#[test]
fn untrusted_path_shapes_are_never_read() {
for path in [
"relative/transcript.jsonl",
"/tmp/../etc/passwd",
"//server/share/t.jsonl",
"/c:/Users/t.jsonl",
"C:\\Users\\t.jsonl",
] {
assert_eq!(
capture_stop_snapshot(Some(path)).reason_code(),
Some(REASON_PATH_UNTRUSTED),
"path must be untrusted: {path}"
);
}
}
#[test]
fn missing_file_and_symlink_degrade_explicitly() {
assert_eq!(
capture_stop_snapshot(Some("/nonexistent/remem-gh825/transcript.jsonl")).reason_code(),
Some(REASON_READ_FAILED)
);
let target = temp_transcript("symlink-target", VALID);
let link = std::env::temp_dir().join(format!(
"remem-gh825-symlink-{}-{}.jsonl",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
std::os::unix::fs::symlink(&target, &link).expect("create symlink fixture");
assert_eq!(
capture_stop_snapshot(Some(&link.to_string_lossy())).reason_code(),
Some(REASON_PATH_UNTRUSTED)
);
std::fs::remove_file(&link).expect("remove symlink");
std::fs::remove_file(&target).expect("remove target");
}
#[test]
fn oversized_corrupt_and_empty_snapshots_degrade_with_stable_reasons() {
let oversized = temp_transcript(
"oversized",
&"{\"type\":\"turn_ended\",\"status\":\"success\"}\n"
.repeat((CURSOR_TRANSCRIPT_MAX_BYTES as usize / 40) + 2),
);
assert_eq!(
capture_stop_snapshot(Some(&oversized.to_string_lossy())).reason_code(),
Some(REASON_SNAPSHOT_TOO_LARGE)
);
std::fs::remove_file(&oversized).expect("remove oversized");
let corrupt = temp_transcript("corrupt", "{not-json\n");
assert_eq!(
capture_stop_snapshot(Some(&corrupt.to_string_lossy())).reason_code(),
Some(REASON_FORMAT_INVALID)
);
std::fs::remove_file(&corrupt).expect("remove corrupt");
let empty = temp_transcript(
"empty",
"{\"type\":\"turn_ended\",\"status\":\"success\"}\n",
);
assert_eq!(
capture_stop_snapshot(Some(&empty.to_string_lossy())).reason_code(),
Some(REASON_TRANSCRIPT_EMPTY)
);
std::fs::remove_file(&empty).expect("remove empty");
}
#[test]
fn full_snapshot_keeps_physical_ordinals_and_redacted_text() {
let path = temp_transcript("full", VALID);
let outcome = capture_stop_snapshot(Some(&path.to_string_lossy()));
let CursorSnapshotOutcome::Full {
evidence,
last_assistant_message,
} = outcome
else {
panic!("valid transcript must produce a full snapshot");
};
assert_eq!(evidence.record_count, 3);
assert_eq!(evidence.messages.len(), 2);
assert_eq!(evidence.messages[0].ordinal, 0);
assert_eq!(evidence.messages[1].ordinal, 1);
assert_eq!(evidence.messages[1].role, "assistant");
assert_eq!(last_assistant_message.as_deref(), Some("answer"));
assert_eq!(evidence.snapshot_byte_len, VALID.len() as u64);
assert_eq!(evidence.snapshot_hash.len(), 64);
std::fs::remove_file(&path).expect("remove fixture");
}
}