fix(checkouts): preserve scoped descriptor ignore rules
T-699 rev20, MR 01a10d66-db02-70c2-b152-197aca92d6b7 seq3 native_glob_ignore_regression. Load authorized root/nested ignore sources through FsAccessPolicy descriptor opens, retain only bounded ancestor matchers, preserve source precedence/negation/anchors and prune ignored directories before enumeration. Expanded parity also exposed Grep source-class precedence and ignored-directory rescue errors; use the shared matcher without relaxing Grep's existing ignore-open/Git-marker provider contract. No ambient global/linked-worktree administration pointer traversal or unsafe WalkBuilder ignore loading.
Add 7 fs boundary tests, 4 actual Local/scoped provider parity tests and 1 native WIP search/link regression. Current corrected source passes cargo check --workspace --all-targets; cargo test -p fs-operation -p workdir -p tools -p worker (fs37 Workdir125 Tools72+10+29+1doc Worker780+109integration); cargo test -p worker-runtime -p yoi-workspace-server (Runtime279+17+17 Backend576+11); fmt/diff. Logs target/validation/T-699/rereview-{check,core,hosts}.log. Earlier intermediate parity/WIP assertion failures retained, not passing evidence. No target/ref integration, RO source changes, environment update or completion.
This commit is contained in:
@@ -125,6 +125,30 @@ provider-owned ignore loading, glob/type selection, grep context/offset/limit,
|
||||
rendering, and source/result limits remain in their shared engines. Unreadable
|
||||
ignore files are not opened to discover out-of-scope contents.
|
||||
|
||||
Descriptor search loads `.ignore`, `.gitignore` and an authorized directory's
|
||||
`.git/info/exclude` through the same `FsAccessPolicy` opens as other sources.
|
||||
It preserves anchored rules, negation, nearest-directory precedence within a
|
||||
source class, `.ignore` precedence over Git rules, and nested repository
|
||||
boundaries. Ignored directories are pruned before enumeration or loading their
|
||||
own rules; explicitly selected ignored search roots are still traversable.
|
||||
Only the current ancestor matchers are retained, never a second tree/store.
|
||||
Glob requires an authorized `.git` directory/file marker for Git rules. Grep
|
||||
retains the existing descriptor-provider contract of applying `.gitignore`
|
||||
without a marker and rejecting unavailable/symlinked ignore opens; Glob skips
|
||||
unavailable ignore sources like its ordinary walker. Scope-denied sources are
|
||||
skipped by both before open. Bounds/cancellation failures are never retried.
|
||||
|
||||
Configuration discovery cannot expand provider authority. Ambient ancestors
|
||||
outside the search result/provider root, global Git configuration/ignore files,
|
||||
and linked-worktree `.git`/`commondir` administration pointers are not followed.
|
||||
A `.git` file activates repository matching without treating it as a directory
|
||||
or opening the host path it contains. In-root readable ignore rules remain
|
||||
active, including inherited rules above a nested base. Descriptor ignore text
|
||||
must be UTF-8, bounded to 1 MiB/file, 16 KiB/line and 8 MiB aggregate per search;
|
||||
limit violations fail rather than returning silently incomplete filtering.
|
||||
Grep's separate candidate-content budget remains 64 MiB. Ordinary unscoped
|
||||
path-backed Tools keep their original discovery policy.
|
||||
|
||||
Scoped ordinary trait List/Glob/Grep use this boundary as well, returning
|
||||
session-logical coordinates. Unscoped normal Tools continue using their original
|
||||
trait/provider behavior. A provider without checkout search support returns
|
||||
|
||||
@@ -368,6 +368,405 @@ async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
|
||||
assert!(ExternalWorkdirOperationResult::try_from(bad_result).is_err());
|
||||
}
|
||||
|
||||
fn write_ignore_fixture(root: &std::path::Path, relative: &str, contents: &str) {
|
||||
let file = root.join(relative);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, contents).unwrap();
|
||||
}
|
||||
|
||||
async fn verify_ignore_search_parity(session: &dyn WorkdirSession, base: &str, expected: &[&str]) {
|
||||
let request = GlobRequest {
|
||||
path: path(base),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
};
|
||||
// On a Local session this is the actual ordinary WalkBuilder-backed path,
|
||||
// not a broker wrapper that would route both calls through checkout_search.
|
||||
let ordinary = session.glob(request.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ordinary.paths,
|
||||
expected.iter().map(|value| path(value)).collect::<Vec<_>>(),
|
||||
"ordinary Glob fixture semantics at {base:?}"
|
||||
);
|
||||
assert!(!ordinary.truncated);
|
||||
let CheckoutSearchResult::Glob(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected native Glob result")
|
||||
};
|
||||
assert_eq!(native, ordinary, "native Glob parity at {base:?}");
|
||||
|
||||
// Content mode renders paths in sorted order, avoiding dependence on the
|
||||
// different walkers' visitation order. All fixture content has one match.
|
||||
let request = GrepRequest {
|
||||
path: path(base),
|
||||
..grep(GrepOutputMode::Content)
|
||||
};
|
||||
let ordinary = session.grep(request.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ordinary.paths,
|
||||
expected.iter().map(|value| path(value)).collect::<Vec<_>>(),
|
||||
"ordinary Grep fixture semantics at {base:?}"
|
||||
);
|
||||
assert_eq!(ordinary.matched_files, expected.len());
|
||||
assert_eq!(ordinary.match_count, expected.len());
|
||||
assert!(!ordinary.truncated);
|
||||
let CheckoutSearchResult::Grep(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected native Grep result")
|
||||
};
|
||||
assert_eq!(native, ordinary, "native Grep parity at {base:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_local_glob_inherits_root_and_nested_ignore_rules() {
|
||||
// Exercise the two ignore sources independently so rule precedence cannot
|
||||
// conceal either missing source. A .git marker activates .gitignore rules.
|
||||
for ignore_name in [".ignore", ".gitignore"] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
ignore_name,
|
||||
"root-only.txt\n/root-anchored.txt\n*.drop.txt\n!keep.drop.txt\ninherited.txt\npruned/\n",
|
||||
);
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
&format!("nested/{ignore_name}"),
|
||||
"/nested-anchored.txt\nnested-only.txt\n!inherited.txt\nignored-dir/\n!ignored-dir/rescue.txt\n",
|
||||
);
|
||||
for relative in [
|
||||
"visible.txt",
|
||||
"root-only.txt",
|
||||
"root-anchored.txt",
|
||||
"discard.drop.txt",
|
||||
"keep.drop.txt",
|
||||
"inherited.txt",
|
||||
"pruned/rescue.txt",
|
||||
"nested/visible.txt",
|
||||
"nested/root-only.txt",
|
||||
"nested/root-anchored.txt",
|
||||
"nested/nested-only.txt",
|
||||
"nested/nested-anchored.txt",
|
||||
"nested/discard.drop.txt",
|
||||
"nested/keep.drop.txt",
|
||||
"nested/inherited.txt",
|
||||
"nested/pruned/rescue.txt",
|
||||
"nested/ignored-dir/rescue.txt",
|
||||
"nested/deep/nested-anchored.txt",
|
||||
] {
|
||||
write_ignore_fixture(root.path(), relative, "needle fixture\n");
|
||||
}
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"",
|
||||
&[
|
||||
"keep.drop.txt",
|
||||
"nested/deep/nested-anchored.txt",
|
||||
"nested/inherited.txt",
|
||||
"nested/keep.drop.txt",
|
||||
"nested/root-anchored.txt",
|
||||
"nested/visible.txt",
|
||||
"visible.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
// Starting explicitly below the provider root must still inherit the
|
||||
// root's rules and retain provider-relative (not base-relative) paths.
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"nested",
|
||||
&[
|
||||
"nested/deep/nested-anchored.txt",
|
||||
"nested/inherited.txt",
|
||||
"nested/keep.drop.txt",
|
||||
"nested/root-anchored.txt",
|
||||
"nested/visible.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
verify_ignore_search_parity(&source, "nested/deep", &["nested/deep/nested-anchored.txt"])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_local_glob_preserves_ignore_source_precedence_across_levels() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
".ignore",
|
||||
"priority.txt\n!ignore-wins.txt\nroot-ignore.txt\n",
|
||||
);
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
".gitignore",
|
||||
"ignore-wins.txt\nrevived-git.txt\n",
|
||||
);
|
||||
write_ignore_fixture(root.path(), "nested/.ignore", "!root-ignore.txt\n");
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
"nested/.gitignore",
|
||||
"!priority.txt\n!revived-git.txt\n",
|
||||
);
|
||||
for relative in [
|
||||
"priority.txt",
|
||||
"ignore-wins.txt",
|
||||
"root-ignore.txt",
|
||||
"revived-git.txt",
|
||||
"nested/priority.txt",
|
||||
"nested/ignore-wins.txt",
|
||||
"nested/root-ignore.txt",
|
||||
"nested/revived-git.txt",
|
||||
] {
|
||||
write_ignore_fixture(root.path(), relative, "needle fixture\n");
|
||||
}
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
// .ignore outranks .gitignore even when the .gitignore is deeper. Within
|
||||
// the same source kind, a deeper negation can override an ancestor rule.
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"",
|
||||
&[
|
||||
"ignore-wins.txt",
|
||||
"nested/ignore-wins.txt",
|
||||
"nested/revived-git.txt",
|
||||
"nested/root-ignore.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"nested",
|
||||
&[
|
||||
"nested/ignore-wins.txt",
|
||||
"nested/revived-git.txt",
|
||||
"nested/root-ignore.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_scoped_broker_skips_denied_ancestor_ignore_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
for (relative, contents) in [
|
||||
(".ignore", "root-ignore-visible.txt\n"),
|
||||
(".gitignore", "root-git-visible.txt\n"),
|
||||
("parent/.ignore", "parent-ignore-visible.txt\n"),
|
||||
("parent/.gitignore", "parent-git-visible.txt\n"),
|
||||
("parent/nested/.ignore", "allowed-ignore.txt\n"),
|
||||
] {
|
||||
write_ignore_fixture(root.path(), relative, contents);
|
||||
}
|
||||
for name in [
|
||||
"root-ignore-visible.txt",
|
||||
"root-git-visible.txt",
|
||||
"parent-ignore-visible.txt",
|
||||
"parent-git-visible.txt",
|
||||
"allowed-ignore.txt",
|
||||
] {
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
&format!("parent/nested/{name}"),
|
||||
"needle fixture\n",
|
||||
);
|
||||
}
|
||||
let source = Arc::new(LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
));
|
||||
// Without a scope layer, ordinary Local traversal really does inherit
|
||||
// these ancestor rules. Each denied source suppresses a different file.
|
||||
assert!(
|
||||
source
|
||||
.glob(GlobRequest {
|
||||
path: path("parent/nested"),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.paths
|
||||
.is_empty()
|
||||
);
|
||||
// Make accidental consumption observable even if a matcher were to drop
|
||||
// the valid patterns: these denied sparse sources exceed the shared Grep
|
||||
// source budget. The allowed subtree remains tiny.
|
||||
for relative in [
|
||||
".ignore",
|
||||
".gitignore",
|
||||
"parent/.ignore",
|
||||
"parent/.gitignore",
|
||||
] {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(root.path().join(relative))
|
||||
.unwrap()
|
||||
.set_len(65 * 1024 * 1024)
|
||||
.unwrap();
|
||||
}
|
||||
let broker = WorkdirToolBroker::new(source.clone());
|
||||
let lease = broker
|
||||
.scope(scope(vec![rule("parent/nested", true)], "parent/nested"))
|
||||
.await
|
||||
.unwrap();
|
||||
// Broker .glob/.grep use the native path under a scope. Compare those
|
||||
// public entry points with explicit checkout_search and assert rebasing.
|
||||
verify_ignore_search_parity(
|
||||
&*lease.tool_session(),
|
||||
"",
|
||||
&[
|
||||
"parent-git-visible.txt",
|
||||
"parent-ignore-visible.txt",
|
||||
"root-git-visible.txt",
|
||||
"root-ignore-visible.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
// Also exercise a Local native request with an unre-based provider root:
|
||||
// allowed descendants do not grant authority to ancestor ignore sources.
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
|
||||
path: path("parent/nested"),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
}));
|
||||
request.scope_layers = vec![vec![rule("parent/nested", true)]];
|
||||
let CheckoutSearchResult::Glob(result) = source.checkout_search(request).await.unwrap() else {
|
||||
panic!("expected native Glob result")
|
||||
};
|
||||
assert_eq!(
|
||||
result.paths,
|
||||
vec![
|
||||
path("parent/nested/parent-git-visible.txt"),
|
||||
path("parent/nested/parent-ignore-visible.txt"),
|
||||
path("parent/nested/root-git-visible.txt"),
|
||||
path("parent/nested/root-ignore-visible.txt"),
|
||||
]
|
||||
);
|
||||
assert!(!result.truncated);
|
||||
lease.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_effects() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
std::fs::create_dir(root.path().join("nested")).unwrap();
|
||||
// Following any of these sources would hide an otherwise visible file.
|
||||
// The native descriptor provider must safely skip rejected ignore opens,
|
||||
// including when the source belongs to an explicitly selected base.
|
||||
for (relative, hidden) in [
|
||||
(".ignore", "root-ignore-visible.txt"),
|
||||
(".gitignore", "root-git-visible.txt"),
|
||||
("nested/.ignore", "nested-ignore-visible.txt"),
|
||||
("nested/.gitignore", "nested-git-visible.txt"),
|
||||
] {
|
||||
let target = outside.path().join(relative.replace('/', "-"));
|
||||
let contents = format!("{hidden}\n");
|
||||
std::fs::write(&target, &contents).unwrap();
|
||||
std::os::unix::fs::symlink(&target, root.path().join(relative)).unwrap();
|
||||
let visible = if relative.starts_with("nested/") {
|
||||
format!("nested/{hidden}")
|
||||
} else {
|
||||
hidden.to_string()
|
||||
};
|
||||
write_ignore_fixture(root.path(), &visible, "needle fixture\n");
|
||||
}
|
||||
write_ignore_fixture(outside.path(), "secret.txt", "needle outside\n");
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
for (base, expected) in [
|
||||
(
|
||||
"",
|
||||
vec![
|
||||
path("nested/nested-git-visible.txt"),
|
||||
path("nested/nested-ignore-visible.txt"),
|
||||
path("root-git-visible.txt"),
|
||||
path("root-ignore-visible.txt"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"nested",
|
||||
vec![
|
||||
path("nested/nested-git-visible.txt"),
|
||||
path("nested/nested-ignore-visible.txt"),
|
||||
],
|
||||
),
|
||||
] {
|
||||
let CheckoutSearchResult::Glob(result) = source
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
GlobRequest {
|
||||
path: path(base),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected native Glob result")
|
||||
};
|
||||
assert_eq!(result.paths, expected);
|
||||
assert!(!result.truncated);
|
||||
// Grep's existing descriptor contract rejects an unauthorized ignore
|
||||
// open, rather than ignoring its error as Glob does. Both behaviors
|
||||
// preserve confinement; do not relax Grep merely to match Glob.
|
||||
let error = source
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||
GrepRequest {
|
||||
path: path(base),
|
||||
..grep(GrepOutputMode::Content)
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::OutOfScope(_)));
|
||||
}
|
||||
for (relative, hidden) in [
|
||||
(".ignore", "root-ignore-visible.txt"),
|
||||
(".gitignore", "root-git-visible.txt"),
|
||||
("nested/.ignore", "nested-ignore-visible.txt"),
|
||||
("nested/.gitignore", "nested-git-visible.txt"),
|
||||
] {
|
||||
assert!(
|
||||
std::fs::symlink_metadata(root.path().join(relative))
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(outside.path().join(relative.replace('/', "-"))).unwrap(),
|
||||
format!("{hidden}\n")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
|
||||
"needle outside\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_escape() {
|
||||
let root = fixture();
|
||||
|
||||
Reference in New Issue
Block a user