Files
yoi/crates/fs-operation/tests/list_pagination.rs
Hare af655ae632 feat: limit checkout indexing to entrances and expose bounded typed discovery
Content directories always have no indexable children; direct authorized paths remain resolvable. Add List using provider Read/enumeration authority and separate List permission, dirs-first lexical live continuation, bounded retention/path bytes, scoped result coordinates and External DTO validation. List/Glob/Grep return Descriptor-typed entries without eager acquisition. Bind content Interfaces coherently to the live entrance scope.

Validation: root cargo check; FS 58 unit+6 pagination, Workdir 125+5 (default and http-client), Tools 84 unit+39 integration+1 doctest, Worker 901 unit+110 integration, Server 691 unit+11 CLI, Server API 83 tests; exact crates.io WIP 0.2.0 resolution; format/diff checks. Focused model/local/remote/provider/race/scope checks passed. Commands and semantics in docs/development/wip-integration-validation.md.

Publication/review paused: continuation ShowTicket returned HTTP 401. No Backend bypass, source push, MR approval, target integration or live environment update is claimed.
2026-10-09 02:22:52 +09:00

249 lines
7.7 KiB
Rust

use fs_operation::{
EntryKind, FsAccessPolicy, FsError, FsPath, ListCursor, ListRequest, ListResult,
MAX_RESULT_PATH_BYTES, run_list,
};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
struct Access {
root: PathBuf,
checks: AtomicUsize,
cancel_at: usize,
enumerate: bool,
}
impl Access {
fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
checks: AtomicUsize::new(0),
cancel_at: usize::MAX,
enumerate: true,
}
}
}
impl FsAccessPolicy for Access {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.root) && path != self.root.join("denied")
}
fn is_writable(&self, _: &Path) -> bool {
false
}
fn can_enumerate_directory(&self, _: &Path, _: &Path) -> bool {
self.enumerate
}
fn check_cancelled(&self) -> std::io::Result<()> {
if self.checks.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"cancelled",
));
}
Ok(())
}
}
fn path(value: &str) -> FsPath {
FsPath::new(value).unwrap()
}
fn page(root: &Path, limit: usize, after: Option<ListCursor>) -> ListResult {
run_list(
root,
ListRequest {
path: FsPath::root(),
limit,
after,
},
&Access::new(root),
)
.unwrap()
}
fn names(result: &ListResult) -> Vec<&str> {
result
.entries
.iter()
.map(|entry| entry.path.as_str())
.collect()
}
#[test]
fn list_pages_order_directories_first_then_paths_without_duplicates() {
let root = tempfile::tempdir().unwrap();
for name in ["z", "b", "a"] {
std::fs::write(root.path().join(name), b"xx").unwrap();
}
for name in ["z-dir", "a-dir"] {
std::fs::create_dir(root.path().join(name)).unwrap();
}
let full = page(root.path(), usize::MAX, None);
assert_eq!(names(&full), ["a-dir", "z-dir", "a", "b", "z"]);
let mut after = None;
let mut entries = Vec::new();
loop {
let result = page(root.path(), 2, after);
assert!(result.entries.len() <= 2);
assert_eq!(result.total_entries, 5);
assert_eq!(result.total_bytes, full.total_bytes);
assert_eq!(result.truncated, result.next_after.is_some());
after = result.next_after;
entries.extend(result.entries);
if after.is_none() {
break;
}
}
assert_eq!(entries, full.entries);
let zero = page(root.path(), 0, None);
assert!(zero.entries.is_empty());
assert!(zero.truncated);
assert!(zero.next_after.is_none());
assert_eq!(zero.total_entries, 5);
let exhausted = page(
root.path(),
2,
Some(ListCursor {
kind: EntryKind::File,
path: path("z"),
}),
);
assert!(exhausted.entries.is_empty());
assert!(!exhausted.truncated);
assert!(exhausted.next_after.is_none());
assert_eq!(exhausted.total_entries, 5);
}
#[test]
fn list_live_cursor_survives_removal_and_only_observes_new_keys_after_it() {
let root = tempfile::tempdir().unwrap();
for name in ["b", "d"] {
std::fs::write(root.path().join(name), b"x").unwrap();
}
let first = page(root.path(), 1, None);
assert_eq!(names(&first), ["b"]);
std::fs::remove_file(root.path().join("b")).unwrap();
for name in ["a", "c"] {
std::fs::write(root.path().join(name), b"xx").unwrap();
}
// All directories sort before a file cursor, even when created later.
std::fs::create_dir(root.path().join("z-directory")).unwrap();
let second = page(root.path(), 10, first.next_after);
assert_eq!(names(&second), ["c", "d"]);
assert_eq!(second.total_entries, 4);
assert!(!second.truncated);
assert!(second.next_after.is_none());
std::fs::remove_file(root.path().join("d")).unwrap();
std::fs::rename(root.path().join("a"), root.path().join("e")).unwrap();
let moved = page(
root.path(),
10,
Some(ListCursor {
kind: EntryKind::File,
path: path("d"),
}),
);
assert_eq!(names(&moved), ["e"]);
}
#[test]
fn list_path_byte_bound_returns_a_continuation_instead_of_losing_the_tail() {
let root = tempfile::tempdir().unwrap();
let long_count = MAX_RESULT_PATH_BYTES / (240 + 64) + 10;
// A shorter late key must not leap over a longer evicted earlier key.
let count = long_count + 50;
let expected: Vec<_> = (0..count)
.map(|n| format!("{n:05}{}", "x".repeat(if n < long_count { 235 } else { 1 })))
.collect();
for name in expected.iter().rev() {
std::fs::write(root.path().join(name), b"x").unwrap();
}
let first = page(root.path(), usize::MAX, None);
assert!(first.truncated);
assert_eq!(first.total_entries, count);
assert_eq!(first.total_bytes, count as u64);
let path_bytes: usize = first
.entries
.iter()
.map(|entry| entry.path.as_str().len() + 64)
.sum::<usize>()
+ first.next_after.as_ref().unwrap().path.as_str().len()
+ 64;
assert!(path_bytes <= MAX_RESULT_PATH_BYTES);
let next = page(root.path(), usize::MAX, first.next_after.clone());
assert!(!next.truncated);
let got: Vec<_> = names(&first).into_iter().chain(names(&next)).collect();
assert_eq!(got, expected.iter().map(String::as_str).collect::<Vec<_>>());
let small = page(root.path(), 1, None);
assert_eq!(names(&small), [expected[0].as_str()]);
assert_eq!(small.total_entries, count);
}
#[test]
fn list_keeps_ignored_and_hidden_entries_but_respects_read_and_enumeration_authority() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join(".gitignore"), "ignored\n").unwrap();
for name in ["ignored", ".hidden", "denied"] {
std::fs::write(root.path().join(name), b"x").unwrap();
}
let first = page(root.path(), 2, None);
assert_eq!(names(&first), [".gitignore", ".hidden"]);
let second = page(root.path(), 2, first.next_after);
assert_eq!(names(&second), ["ignored"]);
assert_eq!(second.total_entries, 3);
let mut access = Access::new(root.path());
access.enumerate = false;
let result = run_list(
root.path(),
ListRequest {
path: FsPath::root(),
limit: 1,
after: None,
},
&access,
)
.unwrap();
assert!(result.entries.is_empty());
assert!(!result.truncated);
assert!(result.next_after.is_none());
}
#[test]
fn list_cancellation_is_checked_even_after_the_page_is_full() {
let root = tempfile::tempdir().unwrap();
for name in ["a", "b", "c"] {
std::fs::write(root.path().join(name), b"x").unwrap();
}
let mut access = Access::new(root.path());
access.cancel_at = 3;
let error = run_list(
root.path(),
ListRequest {
path: FsPath::root(),
limit: 1,
after: None,
},
&access,
)
.unwrap_err();
assert!(
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
);
}
#[test]
fn list_rejects_cursor_outside_the_requested_directory() {
let root = tempfile::tempdir().unwrap();
for value in ["", "other/a"] {
let error = run_list(
root.path(),
ListRequest {
path: FsPath::root(),
limit: 1,
after: Some(ListCursor {
kind: EntryKind::File,
path: path(value),
}),
},
&Access::new(root.path()),
)
.unwrap_err();
assert!(matches!(error, FsError::InvalidArgument(_)));
}
}