fix: preserve attachment lifecycle boundaries

This commit is contained in:
2026-09-03 08:10:35 +09:00
parent 9dc8d9a77a
commit a2e1a3d939
8 changed files with 302 additions and 19 deletions
+30
View File
@@ -918,6 +918,36 @@ mod tests {
));
}
#[test]
fn uploaded_file_names_reject_format_mixed_script_and_confusable_forms() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsStore::new(tmp.path()).unwrap();
let session_id = new_session_id();
let limits = UploadedFileLimits::default();
for file_name in [
"safe\u{00ad}name.txt",
"safe\u{061c}name.txt",
"safe\u{180e}name.txt",
"safe\u{e0001}name.txt",
"p\u{0430}ypal.txt",
"\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04cf}.txt",
"\u{ff26}\u{ff49}\u{ff4c}\u{ff45}.txt",
"re\u{0301}sume\u{0301}.txt",
] {
assert!(matches!(
store.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits),
Err(StoreError::InvalidUploadedFileName)
));
}
for file_name in ["notes.txt", "résumé.txt", "日本語.txt", "📎.txt"] {
store
.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits)
.unwrap();
}
}
#[test]
fn paste_artifact_limits_and_corruption_fail_closed() {
let tmp = tempfile::TempDir::new().unwrap();
+20 -5
View File
@@ -462,11 +462,18 @@ pub fn fork_at(
) -> Result<SegmentId, StoreError> {
let entries = store.read_all(source_session_id, source_id)?;
let cut = if at_turn_index == 0 {
// Branch directly after the SegmentStart (or whatever opens the
// segment), before any turn completes.
// Branch from the seeded state before any new turn completes. A typed
// input checkpoint immediately following SegmentStart is part of that
// seed and must stay atomic with its annotated history.
entries
.iter()
.position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
.position(|entry| {
!matches!(
entry,
LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::InputSegmentsCheckpoint { .. }
)
})
.unwrap_or(entries.len())
} else {
entries
@@ -478,8 +485,9 @@ pub fn fork_at(
let state = segment_log::collect_state(&entries[..cut]);
let fork_id = crate::new_segment_id();
let ts = segment_log::now_millis();
let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
ts,
session_id: source_session_id,
system_prompt: state.system_prompt,
config: state.config,
@@ -490,7 +498,14 @@ pub fn fork_at(
}),
compacted_from: None,
};
store.create_segment(source_session_id, fork_id, &[entry])?;
let mut fork_entries = vec![entry];
if !state.user_segments.is_empty() {
fork_entries.push(LogEntry::InputSegmentsCheckpoint {
ts,
user_segments: state.user_segments,
});
}
store.create_segment(source_session_id, fork_id, &fork_entries)?;
Ok(fork_id)
}
+16 -6
View File
@@ -10,6 +10,8 @@ use protocol::{UploadedFileAvailability, UploadedFileRef};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use unicode_normalization::UnicodeNormalization;
use unicode_properties::general_category::{GeneralCategory, UnicodeGeneralCategory};
use unicode_security::{confusable_detection::skeleton, mixed_script::MixedScript};
use uuid::Uuid;
use crate::StoreError;
@@ -62,18 +64,26 @@ struct StoredUploadedFile {
}
pub(crate) fn validate_file_name(file_name: &str) -> Result<()> {
let normalized: String = file_name.nfkc().collect();
let stem = file_name
.rsplit_once('.')
.map_or(file_name, |(stem, _)| stem);
let confusable_skeleton: String = skeleton(stem).collect();
let ascii_confusable = stem.chars().any(|ch| !ch.is_ascii())
&& confusable_skeleton.is_ascii()
&& !confusable_skeleton.eq_ignore_ascii_case(stem);
if file_name.is_empty()
|| file_name.chars().count() > MAX_FILE_NAME_CHARS
|| file_name == "."
|| file_name == ".."
|| normalized != file_name
|| !stem.is_single_script()
|| ascii_confusable
|| file_name.chars().any(|ch| {
ch.is_control()
|| matches!(
ch,
'/' | '\\' | '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
)
|| ('\u{202a}'..='\u{202e}').contains(&ch)
|| ('\u{2066}'..='\u{2069}').contains(&ch)
|| ch.general_category() == GeneralCategory::Format
|| matches!(ch, '/' | '\\')
})
{
return Err(StoreError::InvalidUploadedFileName);