diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index a93a17a63..9caf814b6 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -200,6 +200,7 @@ fn run_read_with_limits( total_lines, content_hash: content_hasher.finalize().into(), truncated: end < total_lines || byte_truncated, + byte_truncated, }) } @@ -885,6 +886,34 @@ mod bounded_read_tests { assert!(error.to_string().contains("exceeds provider limit 4096")); } + #[test] + fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::write(root.join("text"), "雪雪needle\nnext").unwrap(); + for (offset, max_bytes, bytes, more, cut) in [ + (0, 5, "雪", true, true), + (0, 64, "雪雪needle\n", true, false), + (1, 64, "next", false, false), + ] { + let result = run_read_bounded( + &root, + ReadRequest { + path: FsPath::new("text").unwrap(), + offset, + limit: 1, + max_bytes, + }, + &RootAccess(root.clone()), + BoundedReadLimits::new(1024, 64).unwrap(), + ) + .unwrap(); + assert_eq!(result.bytes, bytes.as_bytes()); + assert_eq!(result.truncated, more); + assert_eq!(result.byte_truncated, cut); + } + } + #[test] fn bounded_read_caps_retained_response_independently_of_request() { let temp = tempfile::tempdir().unwrap(); @@ -907,6 +936,7 @@ mod bounded_read_tests { assert_eq!(result.bytes, b"alpha"); assert_eq!(result.total_lines, 3); assert!(result.truncated); + assert!(result.byte_truncated); } } diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index a2dc6d18f..a064c8509 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -204,7 +204,11 @@ pub struct ReadResult { #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total_lines: usize, pub content_hash: ContentHash, + /// More source lines remain, or the selected window exceeded the response byte budget. pub truncated: bool, + /// The selected line window was cut by its byte budget, possibly within a line. + /// Line-based cursors must not treat such a window as completely scanned. + pub byte_truncated: bool, } /// Binary-safe byte range read with an optional whole-file version fence. diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index b57415103..04445de88 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -637,6 +637,12 @@ impl WipOperationHandler for FileHandler { .map_err(map_tool_error)?; let (read, validator, record_read) = result; self.check_connection()?; + if read.byte_truncated { + return Err(protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "text line window exceeds the provider byte budget; use byte ranges", + )); + } let text = String::from_utf8_lossy(&read.bytes); let value = crate::file_operation::page_result( operation, diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index e814d0d2b..9c50f3d0f 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -993,3 +993,14 @@ async fn http_failed_text_read_does_not_authorize_mutations() { } } } + +#[tokio::test] +async fn http_long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await; + let remote = fixture.open(TOKEN).await.unwrap(); + checkout_wip_tests::assert_long_utf8_search_refuses_incomplete_line( + Arc::new(remote), + fixture.dir.path(), + ) + .await; +} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index 7c3c44b3e..0577b22f2 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -1619,3 +1619,82 @@ async fn failed_text_read_does_not_authorize_edit() { .await; } } + +pub(super) async fn assert_long_utf8_search_refuses_incomplete_line( + source: WorkdirSessionHandle, + directory: &std::path::Path, +) { + let original = format!("{}needle\nnext\n", "雪".repeat(22_000)); + std::fs::write(directory.join("text"), &original).unwrap(); + for (name, text) in [ + ("normal", "雪\nneedle\nnext\n"), + ("unterminated", "雪\nneedle\nnext"), + ] { + std::fs::write(directory.join(name), text).unwrap(); + } + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), source) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let inspected = model_inspect(r.clone(), "/checkouts/main/text").await; + let result = model_invoke( + r.clone(), + &inspected, + "search", + json!({"pattern":"needle","limit":1}), + ) + .await; + assert!( + result.is_err(), + "search advanced over a byte-truncated UTF-8 line: {result:?}" + ); + let failure = result.unwrap_err(); + assert!( + failure.to_string().contains("ResourceLimitExceeded"), + "{failure}" + ); + let read = model_invoke(r.clone(), &inspected, "read", json!({"limit":1})) + .await + .unwrap_err(); + assert!(read.to_string().contains("ResourceLimitExceeded"), "{read}"); + let write = model_invoke(r.clone(), &inspected, "write", json!({"content":"lost"})) + .await + .unwrap_err(); + assert!(write.to_string().contains("InvalidArguments"), "{write}"); + assert_eq!( + std::fs::read_to_string(directory.join("text")).unwrap(), + original + ); + for name in ["normal", "unterminated"] { + let inspected = model_inspect(r.clone(), &format!("/checkouts/main/{name}")).await; + for offset in 0..=3 { + let page = json_content( + model_invoke( + r.clone(), + &inspected, + "search", + json!({"pattern":"needle","offset":offset,"limit":1}), + ) + .await + .unwrap(), + ); + assert_eq!(page["next_offset"], (offset + 1).min(3)); + assert_eq!(page["truncated"], offset < 2); + assert_eq!( + page["matches"], + if offset == 1 { + json!([{"line":2,"text":"needle"}]) + } else { + json!([]) + } + ); + } + } +} + +#[tokio::test] +async fn long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() { + let dir = TempDir::new().unwrap(); + assert_long_utf8_search_refuses_incomplete_line(session(&dir, false), dir.path()).await; +} diff --git a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs index 54f74ebe4..877ce5dff 100644 --- a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs +++ b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs @@ -256,6 +256,7 @@ fn read_result() -> WorkdirSessionOperationResult { total_lines: 1, content_hash: sha2::Sha256::digest(b"visible\n").into(), truncated: false, + byte_truncated: false, }) }