chore: integrate bounded shared text page refusal
This commit is contained in:
@@ -200,6 +200,7 @@ fn run_read_with_limits(
|
|||||||
total_lines,
|
total_lines,
|
||||||
content_hash: content_hasher.finalize().into(),
|
content_hash: content_hasher.finalize().into(),
|
||||||
truncated: end < total_lines || byte_truncated,
|
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"));
|
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]
|
#[test]
|
||||||
fn bounded_read_caps_retained_response_independently_of_request() {
|
fn bounded_read_caps_retained_response_independently_of_request() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -907,6 +936,7 @@ mod bounded_read_tests {
|
|||||||
assert_eq!(result.bytes, b"alpha");
|
assert_eq!(result.bytes, b"alpha");
|
||||||
assert_eq!(result.total_lines, 3);
|
assert_eq!(result.total_lines, 3);
|
||||||
assert!(result.truncated);
|
assert!(result.truncated);
|
||||||
|
assert!(result.byte_truncated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,11 @@ pub struct ReadResult {
|
|||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub total_lines: usize,
|
pub total_lines: usize,
|
||||||
pub content_hash: ContentHash,
|
pub content_hash: ContentHash,
|
||||||
|
/// More source lines remain, or the selected window exceeded the response byte budget.
|
||||||
pub truncated: bool,
|
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.
|
/// Binary-safe byte range read with an optional whole-file version fence.
|
||||||
|
|||||||
@@ -637,6 +637,12 @@ impl WipOperationHandler for FileHandler {
|
|||||||
.map_err(map_tool_error)?;
|
.map_err(map_tool_error)?;
|
||||||
let (read, validator, record_read) = result;
|
let (read, validator, record_read) = result;
|
||||||
self.check_connection()?;
|
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 text = String::from_utf8_lossy(&read.bytes);
|
||||||
let value = crate::file_operation::page_result(
|
let value = crate::file_operation::page_result(
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1619,3 +1619,82 @@ async fn failed_text_read_does_not_authorize_edit() {
|
|||||||
.await;
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ fn read_result() -> WorkdirSessionOperationResult {
|
|||||||
total_lines: 1,
|
total_lines: 1,
|
||||||
content_hash: sha2::Sha256::digest(b"visible\n").into(),
|
content_hash: sha2::Sha256::digest(b"visible\n").into(),
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
byte_truncated: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user