From 7ee6c307fcb7ec6d86cb18fb6d9613e73dc4da05 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 3 Sep 2026 08:38:15 +0900 Subject: [PATCH] fix: require exact attachment acceptance --- crates/session-store/src/fs_store.rs | 1 + crates/session-store/src/uploaded_file.rs | 20 ++++---- crates/tui/src/console/mod.rs | 57 ++++++++++++++++++++++- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index 0f17fb21..a0e80a75 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -987,6 +987,7 @@ mod tests { "safe\u{180e}name.txt", "safe\u{e0001}name.txt", "p\u{0430}ypal.txt", + "report.\u{03c1}df", "\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", diff --git a/crates/session-store/src/uploaded_file.rs b/crates/session-store/src/uploaded_file.rs index 3910bd3a..4ce37588 100644 --- a/crates/session-store/src/uploaded_file.rs +++ b/crates/session-store/src/uploaded_file.rs @@ -65,21 +65,23 @@ 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); + let has_unsafe_component = file_name + .split('.') + .filter(|part| !part.is_empty()) + .any(|part| { + let confusable_skeleton: String = skeleton(part).collect(); + let ascii_confusable = part.chars().any(|ch| !ch.is_ascii()) + && confusable_skeleton.is_ascii() + && !confusable_skeleton.eq_ignore_ascii_case(part); + !part.is_single_script() || ascii_confusable + }); 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 + || has_unsafe_component || file_name.chars().any(|ch| { ch.is_control() || ch.general_category() == GeneralCategory::Format diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index b4f67f1f..984e6e37 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -145,8 +145,15 @@ fn reconcile_attachment_submission( _ => None, }) .collect::>(); - awaiting_acceptance.retain(|file| !accepted_ids.contains(&file.artifact_id.as_str())); - had_awaiting && awaiting_acceptance.is_empty() + let accepts_exact_submission = had_awaiting + && accepted_ids.len() == awaiting_acceptance.len() + && awaiting_acceptance + .iter() + .all(|file| accepted_ids.contains(&file.artifact_id.as_str())); + if accepts_exact_submission { + awaiting_acceptance.clear(); + } + accepts_exact_submission } Event::Error { .. } if !awaiting_acceptance.is_empty() => { pending.append(awaiting_acceptance); @@ -1523,6 +1530,52 @@ mod tests { assert!(connection.awaiting_attachment_acceptance.is_empty()); } + #[test] + fn partial_attachment_echo_does_not_acknowledge_an_atomic_submission() { + let first = UploadedFileRef { + artifact_id: "artifact-1".into(), + file_name: "one.txt".into(), + media_type: "text/plain".into(), + created_at_ms: 1, + availability: UploadedFileAvailability::Available, + byte_len: 1, + sha256: "a".repeat(64), + source_entry_id: None, + }; + let second = UploadedFileRef { + artifact_id: "artifact-2".into(), + file_name: "two.txt".into(), + ..first.clone() + }; + let mut pending = Vec::new(); + let mut awaiting = vec![first.clone(), second.clone()]; + + assert!(!reconcile_attachment_submission( + &mut pending, + &mut awaiting, + &Event::UserMessage { + segments: vec![Segment::UploadedFile { file: first }], + }, + )); + assert_eq!( + awaiting, + vec![ + UploadedFileRef { + artifact_id: "artifact-1".into(), + file_name: "one.txt".into(), + media_type: "text/plain".into(), + created_at_ms: 1, + availability: UploadedFileAvailability::Available, + byte_len: 1, + sha256: "a".repeat(64), + source_entry_id: None, + }, + second, + ] + ); + assert!(pending.is_empty()); + } + #[test] fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() { let mut ansi = String::new();