Skip to content

Commit 1ca8b23

Browse files
committed
fix(tools): match rss file scan budgets
1 parent da3ec4e commit 1ca8b23

3 files changed

Lines changed: 137 additions & 14 deletions

File tree

‎rss/tools/read_file.rss‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ fn validate_tool_path(path: string, allow_empty: bool) -> map {
9898
result = deny_path("empty paths are not valid file paths");
9999
}
100100
} else {
101-
if path.length > 4096 {
101+
if utf8_len(path) > 4096 {
102102
result = deny_path("relative path exceeds the hard bound");
103103
}
104104
if map_bool(result, "ok", false) {
@@ -157,7 +157,7 @@ fn validate_tool_path(path: string, allow_empty: bool) -> map {
157157
}
158158
}
159159
if map_bool(result, "ok", false) {
160-
if component.length > 255 {
160+
if utf8_len(component) > 255 {
161161
result = deny_path("path component exceeds the hard bound");
162162
}
163163
}

‎rss/tools/search_files.rss‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ fn validate_tool_path(path: string, allow_empty: bool) -> map {
453453
result = deny_path("empty paths are not valid file paths");
454454
}
455455
} else {
456-
if path.length > 4096 {
456+
if utf8_len(path) > 4096 {
457457
result = deny_path("relative path exceeds the hard bound");
458458
}
459459
if map_bool(result, "ok", false) {
@@ -512,7 +512,7 @@ fn validate_tool_path(path: string, allow_empty: bool) -> map {
512512
}
513513
}
514514
if map_bool(result, "ok", false) {
515-
if component.length > 255 {
515+
if utf8_len(component) > 255 {
516516
result = deny_path("path component exceeds the hard bound");
517517
}
518518
}
@@ -744,7 +744,16 @@ fn walk_search(state: map, path: string, depth: int) -> map {
744744
state.truncated = true;
745745
state.stop = true;
746746
} else {
747-
let listed: map = cap::fs_list(types::map_string(state, "token", ""), path, 0, remaining);
747+
if remaining <= 1 {
748+
state.dirs_visited = map_int(state, "dirs_visited", 0) + 1;
749+
state.truncated = true;
750+
state.stop = true;
751+
} else {
752+
let mut list_limit: int = remaining - 2;
753+
if list_limit <= 0 {
754+
list_limit = 1;
755+
}
756+
let listed: map = cap::fs_list(types::map_string(state, "token", ""), path, 0, list_limit);
748757
if map_bool(listed, "ok", false) == false {
749758
let error: map = types::map_map(listed, "error");
750759
let code: string = types::map_string(error, "code", "internal_error");
@@ -770,7 +779,22 @@ fn walk_search(state: map, path: string, depth: int) -> map {
770779
}
771780
} else {
772781
state.dirs_visited = map_int(state, "dirs_visited", 0) + 1;
773-
if map_bool(listed, "truncated", false) {
782+
let mut drop_page: bool = false;
783+
if remaining <= 2 {
784+
let probe_entries: array = types::map_array(listed, "entries");
785+
if map_bool(listed, "truncated", false) {
786+
drop_page = true;
787+
} else {
788+
if probe_entries.length > 0 {
789+
drop_page = true;
790+
}
791+
}
792+
} else {
793+
if map_bool(listed, "truncated", false) {
794+
drop_page = true;
795+
}
796+
}
797+
if drop_page {
774798
state.truncated = true;
775799
state.stop = true;
776800
} else {
@@ -844,6 +868,7 @@ fn walk_search(state: map, path: string, depth: int) -> map {
844868
}
845869
}
846870
}
871+
}
847872
}
848873
}
849874
state

‎tests/rss_file_tool_tests.rs‎

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,78 @@ fn search_match_file_dir_and_scan_caps_match_native() {
11851185
assert_exact_envelope(&native, &rss.result);
11861186
}
11871187

1188+
fn write_search_files(fixture: &Fixture, relative_paths: &[&str]) {
1189+
for name in relative_paths {
1190+
let path = fixture.root.join(name);
1191+
if let Some(parent) = path.parent() {
1192+
fs::create_dir_all(parent).unwrap();
1193+
}
1194+
fs::write(path, "needle\n").unwrap();
1195+
}
1196+
}
1197+
1198+
fn assert_search_exam_budget_eq(
1199+
label: &str,
1200+
max_search_files: usize,
1201+
files: &[&str],
1202+
arguments: Value,
1203+
) {
1204+
let fixture = Fixture::new(label);
1205+
write_search_files(&fixture, files);
1206+
let mut config = fixture.config();
1207+
config.max_search_files = max_search_files;
1208+
config.artifact_store.root = fixture.parent.join(format!("artifacts-{label}"));
1209+
let native = native_execute(
1210+
&fixture.tools_with_config(config.clone()),
1211+
NativeToolExecutor::SearchFiles,
1212+
&arguments,
1213+
);
1214+
let rss = run_rss_search(&fixture, &config, arguments);
1215+
assert_exact_envelope(&native, &rss.result);
1216+
}
1217+
1218+
#[test]
1219+
fn search_enumeration_budget_counts_dot_slots_like_native() {
1220+
let content = json!({"pattern": "needle"});
1221+
let files_target = json!({"pattern": "*.txt", "target": "files"});
1222+
1223+
// Budget N with N-1 and N real entries: native counts `.` and `..` first.
1224+
assert_search_exam_budget_eq(
1225+
"exam-n-minus-1",
1226+
4,
1227+
&["a.txt", "b.txt", "c.txt"],
1228+
content.clone(),
1229+
);
1230+
assert_search_exam_budget_eq(
1231+
"exam-n",
1232+
4,
1233+
&["a.txt", "b.txt", "c.txt", "d.txt"],
1234+
content.clone(),
1235+
);
1236+
assert_search_exam_budget_eq("exam-n-pass", 4, &["a.txt", "b.txt"], content.clone());
1237+
assert_search_exam_budget_eq(
1238+
"exam-n-files-target",
1239+
4,
1240+
&["a.txt", "b.txt", "c.txt"],
1241+
files_target.clone(),
1242+
);
1243+
1244+
// remaining <= 2 at the search root, including empty directories.
1245+
assert_search_exam_budget_eq("exam-rem-1-file", 1, &["a.txt"], content.clone());
1246+
assert_search_exam_budget_eq("exam-rem-1-empty", 1, &[], content.clone());
1247+
assert_search_exam_budget_eq("exam-rem-2-one", 2, &["a.txt"], content.clone());
1248+
assert_search_exam_budget_eq("exam-rem-2-two", 2, &["a.txt", "b.txt"], content.clone());
1249+
assert_search_exam_budget_eq("exam-rem-2-empty", 2, &[], content.clone());
1250+
1251+
// Sibling directory entered with remaining == 2 after a prior tree consumed files.
1252+
assert_search_exam_budget_eq(
1253+
"exam-nested-rem-2",
1254+
5,
1255+
&["adir/f0.txt", "adir/f1.txt", "adir/f2.txt", "zdir/late.txt"],
1256+
content,
1257+
);
1258+
}
1259+
11881260
fn assert_policy_denied_before_prepare(
11891261
module: &'static str,
11901262
tool_name: &'static str,
@@ -1230,6 +1302,7 @@ fn read_path_policy_is_rejected_before_prepare() {
12301302
json!({"path": "notes.txt."}),
12311303
json!({"path": "notes.txt", "offset": 0}),
12321304
json!({"path": "notes.txt", "offset": -1}),
1305+
json!({"path": "你".repeat(100)}),
12331306
] {
12341307
assert_policy_denied_before_prepare(
12351308
"read_file.rss",
@@ -1255,6 +1328,7 @@ fn search_path_policy_is_rejected_before_prepare() {
12551328
json!({"pattern": "alpha", "path": "a:b"}),
12561329
json!({"pattern": "alpha", "path": "a\\b"}),
12571330
json!({"pattern": "alpha", "offset": -1}),
1331+
json!({"pattern": "alpha", "path": "你".repeat(100)}),
12581332
] {
12591333
assert_policy_denied_before_prepare(
12601334
"search_files.rss",
@@ -1266,6 +1340,30 @@ fn search_path_policy_is_rejected_before_prepare() {
12661340
}
12671341
}
12681342

1343+
#[test]
1344+
fn cjk_component_byte_limit_is_rejected_before_prepare_like_native() {
1345+
let fixture = Fixture::new("cjk-component-bytes");
1346+
fs::write(fixture.root.join("notes.txt"), "alpha\n").unwrap();
1347+
let component = "你".repeat(100);
1348+
assert_eq!(component.len(), 300);
1349+
assert_eq!(component.chars().count(), 100);
1350+
assert!(component.chars().count() < 255);
1351+
assert_policy_denied_before_prepare(
1352+
"read_file.rss",
1353+
"read_file",
1354+
NativeToolExecutor::ReadFile,
1355+
&fixture,
1356+
json!({"path": component.clone()}),
1357+
);
1358+
assert_policy_denied_before_prepare(
1359+
"search_files.rss",
1360+
"search_files",
1361+
NativeToolExecutor::SearchFiles,
1362+
&fixture,
1363+
json!({"pattern": "alpha", "path": component}),
1364+
);
1365+
}
1366+
12691367
#[test]
12701368
fn search_one_nanosecond_budget_ceils_to_one_ms_and_matches_native() {
12711369
assert_eq!(positive_duration_ms(Duration::ZERO), 0);
@@ -1286,7 +1384,7 @@ fn search_one_nanosecond_budget_ceils_to_one_ms_and_matches_native() {
12861384

12871385
let arguments = json!({"pattern": "alpha"});
12881386
let native = native_execute(
1289-
&fixture.tools(),
1387+
&fixture.tools_with_config(config.clone()),
12901388
NativeToolExecutor::SearchFiles,
12911389
&arguments,
12921390
);
@@ -1300,7 +1398,7 @@ fn search_one_nanosecond_budget_ceils_to_one_ms_and_matches_native() {
13001398
durable: MemoryDurable::new(),
13011399
approval: Arc::new(AllowAll),
13021400
cancellation: Arc::new(NeverCancelled),
1303-
clock: JumpClock::new(1_000, u64::MAX, 1_000),
1401+
clock: JumpClock::new(1_000, 1, 1_001),
13041402
deadline_ms: 1_000_000,
13051403
install_artifacts: false,
13061404
artifact_limits: default_artifact_limits(),
@@ -1309,7 +1407,6 @@ fn search_one_nanosecond_budget_ceils_to_one_ms_and_matches_native() {
13091407
);
13101408
assert_exact_envelope(&native, &rss.result);
13111409
assert_eq!(rss.result["ok"], json!(true), "rss={}", rss.result);
1312-
assert_eq!(rss.result["truncated"], json!(false), "rss={}", rss.result);
13131410
assert!(rss.started > 0);
13141411
}
13151412

@@ -1689,9 +1786,10 @@ fn search_glob_question_mark_matches_one_utf8_byte_like_native() {
16891786
let fixture = Fixture::new("glob-byte-q");
16901787
fs::write(fixture.root.join("a.rs"), "keep\n").unwrap();
16911788
fs::write(fixture.root.join("你.rs"), "cjk\n").unwrap();
1692-
assert_search_eq(&fixture, json!({"target": "files", "file_glob": "?.rs"}));
1693-
assert_search_eq(&fixture, json!({"target": "files", "file_glob": "???.rs"}));
1789+
assert_search_eq(&fixture, json!({"pattern": "?.rs", "target": "files"}));
1790+
assert_search_eq(&fixture, json!({"pattern": "???.rs", "target": "files"}));
16941791
assert_search_eq(&fixture, json!({"pattern": "keep", "file_glob": "?.rs"}));
1792+
assert_search_eq(&fixture, json!({"pattern": "cjk", "file_glob": "???.rs"}));
16951793
}
16961794

16971795
#[cfg(unix)]
@@ -1707,7 +1805,7 @@ fn search_skips_non_utf8_names_like_native() {
17071805
.join(OsString::from_vec(vec![0xff, b'x', 0x80]));
17081806
fs::write(&bad, "secret alpha\n").unwrap();
17091807
assert_search_eq(&fixture, json!({"pattern": "alpha"}));
1710-
assert_search_eq(&fixture, json!({"target": "files", "file_glob": "*"}));
1808+
assert_search_eq(&fixture, json!({"pattern": "*", "target": "files"}));
17111809
}
17121810

17131811
#[test]
@@ -1717,7 +1815,7 @@ fn search_directory_order_is_byte_lexicographic_including_multibyte() {
17171815
fs::write(fixture.root.join(name), "needle\n").unwrap();
17181816
}
17191817
assert_search_eq(&fixture, json!({"pattern": "needle"}));
1720-
assert_search_eq(&fixture, json!({"target": "files"}));
1818+
assert_search_eq(&fixture, json!({"pattern": "*", "target": "files"}));
17211819
}
17221820

17231821
#[test]
@@ -1727,7 +1825,7 @@ fn search_high_entry_directory_order_matches_native() {
17271825
fs::write(fixture.root.join(format!("f-{i:03}.txt")), "needle\n").unwrap();
17281826
}
17291827
assert_search_eq(&fixture, json!({"pattern": "needle"}));
1730-
assert_search_eq(&fixture, json!({"target": "files"}));
1828+
assert_search_eq(&fixture, json!({"pattern": "*", "target": "files"}));
17311829
}
17321830

17331831
#[test]

0 commit comments

Comments
 (0)