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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user