Skip to content

Commit 7cd8a31

Browse files
committed
fix(tools): reject rss search alias entries
1 parent 47388ee commit 7cd8a31

4 files changed

Lines changed: 297 additions & 8 deletions

File tree

‎rss/tools/search_files.rss‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,10 @@ fn walk_search(state: map, path: string, depth: int) -> map {
782782
} else {
783783
if probe_entries.length > 0 {
784784
drop_page = true;
785+
} else {
786+
if map_int(listed, "next_cursor", 0) > map_int(listed, "cursor", 0) {
787+
drop_page = true;
788+
}
785789
}
786790
}
787791
} else {

‎src/capabilities/confined_io.rs‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,9 @@ mod unix {
225225
));
226226
}
227227
if stat_u64(stat.st_nlink) > 1 {
228-
return Err(path_denied("hard links are not permitted"));
228+
return Err(path_denied(
229+
"regular files with multiple hard links are not permitted",
230+
));
229231
}
230232
let file_len = stat_u64(stat.st_size);
231233
if limit == 0 || offset >= file_len {
@@ -321,18 +323,23 @@ mod unix {
321323
truncated = true;
322324
break;
323325
}
324-
let Some(name) = std::str::from_utf8(name_bytes).ok().map(str::to_string) else {
325-
consumed = consumed.saturating_add(1);
326-
continue;
327-
};
328-
let (file_type, len) = match metadata_at(directory_fd, name_bytes) {
326+
let (file_type, len, nlink) = match metadata_at(directory_fd, name_bytes) {
329327
Ok(meta) => meta,
330328
Err(error) if error.raw_os_error() == Some(libc::ENOENT) => {
331329
consumed = consumed.saturating_add(1);
332330
continue;
333331
}
334332
Err(error) => return Err(map_io("fs::enumerate", error)),
335333
};
334+
if file_type == "file" && nlink > 1 {
335+
return Err(path_denied(
336+
"regular files with multiple hard links are not permitted",
337+
));
338+
}
339+
let Some(name) = std::str::from_utf8(name_bytes).ok().map(str::to_string) else {
340+
consumed = consumed.saturating_add(1);
341+
continue;
342+
};
336343
entries.push(ListEntry {
337344
name,
338345
file_type,
@@ -347,7 +354,10 @@ mod unix {
347354
})
348355
}
349356

350-
fn metadata_at(directory_fd: RawFd, name: &[u8]) -> Result<(&'static str, u64), io::Error> {
357+
fn metadata_at(
358+
directory_fd: RawFd,
359+
name: &[u8],
360+
) -> Result<(&'static str, u64, u64), io::Error> {
351361
let name = CString::new(name).expect("validated component contains no NUL");
352362
let mut stat = MaybeUninit::<libc::stat>::uninit();
353363
let result = unsafe {
@@ -369,7 +379,7 @@ mod unix {
369379
libc::S_IFLNK => "symlink",
370380
_ => "other",
371381
};
372-
Ok((file_type, stat_u64(stat.st_size)))
382+
Ok((file_type, stat_u64(stat.st_size), stat_u64(stat.st_nlink)))
373383
}
374384

375385
fn clear_errno() {

‎tests/capability_tests.rs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1988,3 +1988,105 @@ fn list_examination_budget_counts_non_utf8_slots() {
19881988
}
19891989
}
19901990
}
1991+
1992+
#[cfg(unix)]
1993+
#[test]
1994+
fn list_consumed_non_utf8_only_page_advances_next_cursor() {
1995+
use std::ffi::OsString;
1996+
use std::os::unix::ffi::OsStringExt;
1997+
1998+
let fixture = Fixture::new("list-non-utf8-only");
1999+
fs::create_dir(fixture.root.join("dir")).expect("dir");
2000+
fs::write(
2001+
fixture
2002+
.root
2003+
.join("dir")
2004+
.join(OsString::from_vec(vec![0xff, 0x80])),
2005+
"secret",
2006+
)
2007+
.expect("invalid name");
2008+
let listed = fixture
2009+
.filesystem()
2010+
.list(&fixture.token(CapabilityRisk::Read), "dir", 0, 1)
2011+
.expect("list");
2012+
assert!(
2013+
listed.entries.is_empty(),
2014+
"omitted invalid-byte names must not appear: {:?}",
2015+
listed.entries
2016+
);
2017+
assert!(
2018+
listed.next_cursor > listed.cursor,
2019+
"consumed-but-omitted dirents must advance next_cursor: {listed:?}"
2020+
);
2021+
assert!(
2022+
!listed.truncated,
2023+
"a single consumed-omitted dirent must not claim leftover pages: {listed:?}"
2024+
);
2025+
}
2026+
2027+
#[cfg(unix)]
2028+
#[test]
2029+
fn list_rejects_regular_hardlinks_and_preserves_dirs_and_files() {
2030+
let fixture = Fixture::new("list-hardlink");
2031+
fs::create_dir(fixture.root.join("keep-dir")).expect("dir");
2032+
fs::write(fixture.root.join("keep.txt"), "ok").expect("keep");
2033+
let outside = fixture.root.parent().unwrap().join(format!(
2034+
"outside-shared-{}-{}",
2035+
std::process::id(),
2036+
NEXT_CAP_TMP.fetch_add(1, Ordering::Relaxed)
2037+
));
2038+
fs::write(&outside, "shared").expect("outside");
2039+
fs::hard_link(&outside, fixture.root.join("linked")).expect("hard link");
2040+
2041+
let fs_cap = fixture.filesystem();
2042+
let token = fixture.token(CapabilityRisk::Read);
2043+
let error = fs_cap
2044+
.list(&token, "", 0, 4)
2045+
.expect_err("listing a regular hardlink must fail");
2046+
assert_eq!(error_code(&error), "path_denied");
2047+
assert_eq!(
2048+
error.message(),
2049+
"regular files with multiple hard links are not permitted"
2050+
);
2051+
2052+
let nested = fixture.root.join("keep-dir");
2053+
fs::write(nested.join("inner.txt"), "inner").expect("inner");
2054+
let listed = fs_cap
2055+
.list(&token, "keep-dir", 0, 4)
2056+
.expect("ordinary directory listing must succeed");
2057+
assert!(
2058+
listed
2059+
.entries
2060+
.iter()
2061+
.any(|entry| entry.name == "inner.txt" && entry.file_type == "file")
2062+
);
2063+
assert!(
2064+
listed.entries.iter().all(|entry| entry.name != "linked"),
2065+
"hardlinked names must not leak through a nested listing: {:?}",
2066+
listed.entries
2067+
);
2068+
2069+
let host_fs = Arc::new(fixture.filesystem());
2070+
let source = format!(
2071+
r#"
2072+
pub fn run(input: map) -> map {{
2073+
cap::fs_list("{token}", "", 0, 4)
2074+
}}
2075+
"#
2076+
);
2077+
let result = run_cap_source(&fixture, Some(host_fs), None, None, &source);
2078+
assert_eq!(envelope_error_code(&result), "path_denied");
2079+
let VmValue::Map(fields) = &result else {
2080+
panic!("expected map envelope, got {result:?}");
2081+
};
2082+
let Some(VmValue::Map(error)) = fields.get(&VmValue::string("error")) else {
2083+
panic!("expected error map, got {result:?}");
2084+
};
2085+
assert_eq!(
2086+
error.get(&VmValue::string("message")),
2087+
Some(&VmValue::string(
2088+
"regular files with multiple hard links are not permitted"
2089+
))
2090+
);
2091+
let _ = fs::remove_file(&outside);
2092+
}

‎tests/rss_file_tool_tests.rs‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1986,6 +1986,179 @@ fn search_non_utf8_name_consumes_exam_slot_and_does_not_leak_secret() {
19861986
);
19871987
}
19881988

1989+
#[cfg(unix)]
1990+
#[test]
1991+
fn search_remaining_2_invalid_byte_filename_truncates_like_native() {
1992+
use std::ffi::OsString;
1993+
use std::os::unix::ffi::OsStringExt;
1994+
1995+
let fixture = Fixture::new("search-rem2-invalid");
1996+
fs::write(
1997+
fixture
1998+
.root
1999+
.join(OsString::from_vec(vec![0xff, b'x', 0x80])),
2000+
"secret needle\n",
2001+
)
2002+
.unwrap();
2003+
let mut config = fixture.config();
2004+
config.max_search_files = 2;
2005+
config.artifact_store.root = fixture.parent.join("artifacts-rem2-invalid");
2006+
let arguments = json!({"pattern": "needle"});
2007+
let native = native_execute(
2008+
&fixture.tools_with_config(config.clone()),
2009+
NativeToolExecutor::SearchFiles,
2010+
&arguments,
2011+
);
2012+
let rss = run_rss_search(&fixture, &config, arguments);
2013+
assert_exact_envelope(&native, &rss.result);
2014+
assert!(native.ok, "native={native:?}");
2015+
assert!(native.truncated, "native must truncate: {native:?}");
2016+
assert_eq!(native.content, "");
2017+
assert_eq!(native.data["match_count"], json!(0));
2018+
assert_eq!(native.data["files_visited"], json!(0));
2019+
assert_eq!(native.data["dirs_visited"], json!(1));
2020+
assert_eq!(rss.result["truncated"], json!(true), "rss={}", rss.result);
2021+
assert_eq!(rss.result["content"], json!(""));
2022+
assert_eq!(rss.result["data"]["match_count"], json!(0));
2023+
assert_eq!(rss.result["data"]["files_visited"], json!(0));
2024+
assert_eq!(rss.result["data"]["dirs_visited"], json!(1));
2025+
}
2026+
2027+
#[cfg(unix)]
2028+
#[test]
2029+
fn search_nested_remaining_2_hidden_dirent_stops_later_siblings_like_native() {
2030+
use std::ffi::OsString;
2031+
use std::os::unix::ffi::OsStringExt;
2032+
2033+
let fixture = Fixture::new("search-nested-rem2-hidden");
2034+
fs::create_dir_all(fixture.root.join("adir")).unwrap();
2035+
fs::create_dir_all(fixture.root.join("bdir")).unwrap();
2036+
fs::create_dir_all(fixture.root.join("zdir")).unwrap();
2037+
fs::write(fixture.root.join("adir/f0.txt"), "needle\n").unwrap();
2038+
fs::write(fixture.root.join("adir/f1.txt"), "needle\n").unwrap();
2039+
fs::write(fixture.root.join("adir/f2.txt"), "needle\n").unwrap();
2040+
fs::write(
2041+
fixture
2042+
.root
2043+
.join("bdir")
2044+
.join(OsString::from_vec(vec![0xff, 0x80])),
2045+
"secret needle\n",
2046+
)
2047+
.unwrap();
2048+
fs::write(fixture.root.join("zdir/late.txt"), "needle late\n").unwrap();
2049+
let mut config = fixture.config();
2050+
config.max_search_files = 5;
2051+
config.artifact_store.root = fixture.parent.join("artifacts-nested-rem2-hidden");
2052+
let arguments = json!({"pattern": "needle"});
2053+
let native = native_execute(
2054+
&fixture.tools_with_config(config.clone()),
2055+
NativeToolExecutor::SearchFiles,
2056+
&arguments,
2057+
);
2058+
let rss = run_rss_search(&fixture, &config, arguments);
2059+
assert_exact_envelope(&native, &rss.result);
2060+
assert!(native.ok, "native={native:?}");
2061+
assert!(native.truncated, "native must truncate: {native:?}");
2062+
assert!(
2063+
native.content.contains("adir/f0.txt")
2064+
&& native.content.contains("adir/f1.txt")
2065+
&& native.content.contains("adir/f2.txt"),
2066+
"prior matches must remain: native={native:?}"
2067+
);
2068+
assert!(
2069+
!native.content.contains("late.txt"),
2070+
"later sibling must not be traversed after remaining<=2 hidden consumption: native={native:?}"
2071+
);
2072+
assert_eq!(native.data["files_visited"], json!(3));
2073+
assert_eq!(native.data["dirs_visited"], json!(3));
2074+
assert_eq!(rss.result["truncated"], json!(true), "rss={}", rss.result);
2075+
assert_eq!(rss.result["data"]["files_visited"], json!(3));
2076+
assert_eq!(rss.result["data"]["dirs_visited"], json!(3));
2077+
assert!(
2078+
!rss.result["content"]
2079+
.as_str()
2080+
.is_some_and(|content| content.contains("late.txt")),
2081+
"later sibling must not leak: rss={}",
2082+
rss.result
2083+
);
2084+
}
2085+
2086+
#[cfg(unix)]
2087+
#[test]
2088+
fn search_hardlink_only_directory_is_fatal_like_native() {
2089+
let fixture = Fixture::new("search-hardlink-only");
2090+
let outside = fixture.parent.join("outside-shared");
2091+
fs::write(&outside, "needle shared\n").unwrap();
2092+
fs::hard_link(&outside, fixture.root.join("linked")).unwrap();
2093+
let mut config = fixture.config();
2094+
config.artifact_store.root = fixture.parent.join("artifacts-hardlink-only");
2095+
let arguments = json!({"pattern": "needle"});
2096+
let native = native_execute(
2097+
&fixture.tools_with_config(config.clone()),
2098+
NativeToolExecutor::SearchFiles,
2099+
&arguments,
2100+
);
2101+
let rss = run_rss_search(&fixture, &config, arguments);
2102+
assert_exact_envelope(&native, &rss.result);
2103+
assert!(!native.ok, "native={native:?}");
2104+
let native_error = native.error.as_ref().expect("native error");
2105+
assert_eq!(native_error.code, "path_denied");
2106+
assert_eq!(
2107+
native_error.message,
2108+
"regular files with multiple hard links are not permitted"
2109+
);
2110+
assert_eq!(native.content, "");
2111+
assert_eq!(native.data, json!({}));
2112+
assert!(!native.truncated);
2113+
assert_eq!(rss.result["ok"], json!(false), "rss={}", rss.result);
2114+
assert_eq!(rss.result["error"]["code"], json!("path_denied"));
2115+
assert_eq!(
2116+
rss.result["error"]["message"],
2117+
json!("regular files with multiple hard links are not permitted")
2118+
);
2119+
assert_eq!(rss.result["content"], json!(""));
2120+
}
2121+
2122+
#[cfg(unix)]
2123+
#[test]
2124+
fn search_hardlink_in_child_discards_parent_matches_like_native() {
2125+
let fixture = Fixture::new("search-hardlink-child");
2126+
fs::write(fixture.root.join("a.txt"), "needle parent\n").unwrap();
2127+
fs::create_dir(fixture.root.join("sub")).unwrap();
2128+
let outside = fixture.parent.join("outside-shared");
2129+
fs::write(&outside, "needle child\n").unwrap();
2130+
fs::hard_link(&outside, fixture.root.join("sub/linked")).unwrap();
2131+
let mut config = fixture.config();
2132+
config.artifact_store.root = fixture.parent.join("artifacts-hardlink-child");
2133+
let arguments = json!({"pattern": "needle"});
2134+
let native = native_execute(
2135+
&fixture.tools_with_config(config.clone()),
2136+
NativeToolExecutor::SearchFiles,
2137+
&arguments,
2138+
);
2139+
let rss = run_rss_search(&fixture, &config, arguments);
2140+
assert_exact_envelope(&native, &rss.result);
2141+
assert!(
2142+
!native.ok,
2143+
"native must discard partial matches: {native:?}"
2144+
);
2145+
let native_error = native.error.as_ref().expect("native error");
2146+
assert_eq!(native_error.code, "path_denied");
2147+
assert_eq!(
2148+
native_error.message,
2149+
"regular files with multiple hard links are not permitted"
2150+
);
2151+
assert_eq!(native.content, "");
2152+
assert_eq!(native.data, json!({}));
2153+
assert!(
2154+
!rss.result["content"]
2155+
.as_str()
2156+
.is_some_and(|content| content.contains("a.txt")),
2157+
"parent matches must be discarded: rss={}",
2158+
rss.result
2159+
);
2160+
}
2161+
19892162
#[test]
19902163
fn search_directory_order_is_byte_lexicographic_including_multibyte() {
19912164
let fixture = Fixture::new("sort-multi");

0 commit comments

Comments
 (0)