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.
This commit is contained in:
2026-10-09 02:22:52 +09:00
parent 6445999120
commit af655ae632
29 changed files with 2297 additions and 210 deletions
+2
View File
@@ -941,6 +941,7 @@ mod tests {
let list_error = run_list(
&root,
ListRequest {
after: None,
path: FsPath::new("selected-dir").unwrap(),
limit: 10,
},
@@ -1008,6 +1009,7 @@ mod tests {
let listed = run_list(
&root,
ListRequest {
after: None,
path: FsPath::new("notes").unwrap(),
limit: 10,
},
+113 -21
View File
@@ -1,3 +1,5 @@
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::ffi::OsString;
use std::fs;
use std::io::{BufReader, Read};
@@ -7,9 +9,9 @@ use sha2::{Digest, Sha256};
use crate::{
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
direct_symlink,
FsAccessPolicy, FsError, FsPath, ListCursor, ListEntry, ListRequest, ListResult,
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
WriteRequest, WriteResult, direct_symlink,
};
const READ_BUFFER_BYTES: usize = 16 * 1024;
@@ -437,12 +439,55 @@ pub fn run_edit(
})
}
fn list_order(
left_kind: EntryKind,
left: &FsPath,
right_kind: EntryKind,
right: &FsPath,
) -> Ordering {
(left_kind != EntryKind::Directory)
.cmp(&(right_kind != EntryKind::Directory))
.then_with(|| left.as_str().cmp(right.as_str()))
}
struct RetainedListEntry(ListEntry);
impl PartialEq for RetainedListEntry {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for RetainedListEntry {}
impl Ord for RetainedListEntry {
fn cmp(&self, other: &Self) -> Ordering {
list_order(self.0.kind, &self.0.path, other.0.kind, &other.0.path)
}
}
impl PartialOrd for RetainedListEntry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
fn list_entry_bytes(entry: &ListEntry) -> usize {
entry.path.as_str().len().saturating_add(64)
}
pub fn run_list(
root: &Path,
request: ListRequest,
access: &dyn FsAccessPolicy,
) -> Result<ListResult, FsError> {
let logical = request.path;
if let Some(after) = &request.after {
if after.path.is_root()
|| Path::new(after.path.as_str()).parent() != Some(Path::new(logical.as_str()))
{
return Err(FsError::InvalidArgument(
"List cursor must name a direct child of the listed directory".into(),
));
}
}
access
.check_cancelled()
.map_err(|error| map_io(&logical, error))?;
@@ -462,11 +507,15 @@ pub fn run_list(
total_entries: 0,
total_bytes: 0,
truncated: false,
next_after: None,
});
}
let mut entries = Vec::new();
let mut entries: BinaryHeap<RetainedListEntry> = BinaryHeap::new();
let mut retained_limit = request.limit.saturating_add(1);
let mut retained_path_bytes = 0_usize;
let mut provider_truncated = false;
let mut total_entries = 0usize;
let mut total_bytes = 0u64;
let mut remaining_entries = 0usize;
let read_dir = access
.open_read_dir(&logical_base, &path)
.map_err(|error| map_io(&logical, error))?;
@@ -513,34 +562,77 @@ pub fn run_list(
let Ok(result_path) = FsPath::new(relative) else {
continue;
};
let retained = result_path.as_str().len().saturating_add(64);
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
provider_truncated = true;
break;
total_entries += 1;
total_bytes = total_bytes.saturating_add(metadata.len());
if request.after.as_ref().is_some_and(|after| {
list_order(kind, &result_path, after.kind, &after.path) != Ordering::Greater
}) {
continue;
}
retained_path_bytes = retained_path_bytes.saturating_add(retained);
entries.push(ListEntry {
remaining_entries += 1;
let candidate = RetainedListEntry(ListEntry {
path: result_path,
kind,
size: metadata.len(),
});
if entries.len() == retained_limit {
if candidate >= *entries.peek().expect("nonempty List retention") {
continue;
}
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
}
retained_path_bytes = retained_path_bytes.saturating_add(list_entry_bytes(&candidate.0));
entries.push(candidate);
// Evict greatest keys, never stop scanning: enumeration order is
// unspecified and a later entry may precede every retained key.
while retained_path_bytes > crate::MAX_RESULT_PATH_BYTES {
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
// Never grow cardinality again after a byte-bound eviction: a
// later short path must not leap over a discarded earlier key.
retained_limit = entries.len();
}
if retained_limit == 0 {
return Err(FsError::InvalidArgument(
"List entry exceeds provider response bound".into(),
));
}
}
entries.sort_by(|left, right| {
let left_dir = left.kind == EntryKind::Directory;
let right_dir = right.kind == EntryKind::Directory;
right_dir
.cmp(&left_dir)
.then_with(|| left.path.as_str().cmp(right.path.as_str()))
});
let total_entries = entries.len();
let total_bytes = entries.iter().map(|entry| entry.size).sum();
let truncated = provider_truncated || entries.len() > request.limit;
let mut entries: Vec<_> = entries
.into_sorted_vec()
.into_iter()
.map(|entry| entry.0)
.collect();
entries.truncate(request.limit);
// Continuation duplicates the last path on the wire; include it in the
// aggregate provider response bound too.
let mut response_bytes: usize = entries.iter().map(list_entry_bytes).sum();
while remaining_entries > entries.len()
&& entries.last().is_some_and(|last| {
response_bytes.saturating_add(list_entry_bytes(last)) > crate::MAX_RESULT_PATH_BYTES
})
{
response_bytes -= list_entry_bytes(&entries.pop().unwrap());
}
if request.limit > 0 && remaining_entries > 0 && entries.is_empty() {
return Err(FsError::InvalidArgument(
"List entry exceeds provider response bound".into(),
));
}
let truncated = remaining_entries > entries.len();
let next_after = if truncated {
entries.last().map(|entry| ListCursor {
kind: entry.kind,
path: entry.path.clone(),
})
} else {
None
};
Ok(ListResult {
entries,
total_entries,
total_bytes,
truncated,
next_after,
})
}
+21
View File
@@ -278,6 +278,22 @@ pub struct ListRequest {
pub path: FsPath,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub limit: usize,
/// Exclusive live continuation in the same coordinates as returned entries.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<ListCursor>,
}
/// A List ordering key: directories first, then lexical path (all other kinds
/// share one ordering group). The cursor need not still exist. Each page scans
/// the live directory: insertions before the key are not revisited, insertions
/// after it may appear, and removals disappear. Renames or kind changes can move
/// an entry across the key and cause omission or repetition; no snapshot or
/// cross-page consistency is promised. Scoped wrappers translate coordinates.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ListCursor {
pub kind: EntryKind,
pub path: FsPath,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -293,6 +309,11 @@ pub struct ListEntry {
#[serde(deny_unknown_fields)]
pub struct ListResult {
pub entries: Vec<ListEntry>,
/// Last returned key when more entries follow. A zero-limit request has no
/// continuation even when truncated. Totals describe all visible entries
/// in this scan, including those at or before `after`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_after: Option<ListCursor>,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub total_entries: usize,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
@@ -0,0 +1,248 @@
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(_)));
}
}