diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0e65e047..cbba9d6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,7 +18,6 @@ updates: - package-ecosystem: "cargo" directories: - "/" - - "/ambient_fs" - "/test_support" open-pull-requests-limit: 5 labels: diff --git a/.gitignore b/.gitignore index a4ca8d45..d1103426 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ target/ **/*.rs.bk **/*~ -.crush +*.swp +.crush/ .claude/ .memdb/ +.grepai/ build.ninja *:Zone.Identifier /graph.dot diff --git a/Cargo.lock b/Cargo.lock index f5bef9cb..0f6d5db9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,16 +38,6 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" -[[package]] -name = "ambient_fs" -version = "0.1.0" -dependencies = [ - "camino", - "rstest", - "tempfile", - "walkdir", -] - [[package]] name = "anstream" version = "0.6.19" @@ -1434,7 +1424,6 @@ dependencies = [ name = "netsuke" version = "0.1.0" dependencies = [ - "ambient_fs", "anyhow", "assert_cmd", "camino", diff --git a/Cargo.toml b/Cargo.toml index 71dde08e..8b81a480 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] } serde_json = { version = "1", features = ["preserve_order"] } serde_json_canonicalizer = "0.3" tempfile = "3.8.0" -ambient_fs = { path = "ambient_fs" } shell-quote = { version = "0.7.2", default-features = false, features = ["sh"] } shlex = "2.0.1" time = { version = "0.3.44", features = ["formatting", "macros", "parsing", "serde"] } @@ -182,15 +181,6 @@ trybuild = "1.0.116" rustix = { version = "1.0.8", features = ["fs"] } [workspace] -# `ambient_fs` is the sanctioned ambient-filesystem escape hatch, so it must be a -# workspace member for the root-level `cargo fmt`, `cargo doc`, `cargo clippy`, and -# `cargo test` gates to format, document, lint, and run its doctests and unit tests. -members = ["ambient_fs"] -# Operate on both packages by default so the bare-cargo gates (`cargo test`, -# `cargo check`, `cargo clippy`, `cargo doc`) cover `ambient_fs` without needing -# `--workspace`; the root package (netsuke, ".") is listed explicitly because a -# default set that omitted it would stop gating netsuke itself. -default-members = [".", "ambient_fs"] # `test_support` is a path dependency that Cargo would otherwise auto-include as a # member. It is excluded here to keep its gate coverage unchanged in this focused # change; folding it into the workspace is tracked separately as it requires diff --git a/README.md b/README.md index efbe4491..9500e8ae 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,15 @@ Netsuke currently requires: ### Installation -Until the v0.1.0 release is published, install the current source checkout with -Cargo: +To install from crates.io: + + + +```sh +cargo install netsuke +``` + +To install the current source checkout with Cargo: diff --git a/ambient_fs/Cargo.toml b/ambient_fs/Cargo.toml deleted file mode 100644 index c67ff25a..00000000 --- a/ambient_fs/Cargo.toml +++ /dev/null @@ -1,54 +0,0 @@ -[package] -name = "ambient_fs" -version = "0.1.0" -edition = "2024" -rust-version = "1.89.0" -publish = false - -[dependencies] -camino = "1.2.0" -walkdir = "2.5" - -[dev-dependencies] -rstest = "0.26.1" -tempfile = "3.8.0" - -[lints.clippy] -pedantic = { level = "warn", priority = -1 } - -# 1. hygiene -# Unfortunately, due to an 'unused-braces' false positive with -# single line rstest fixtures, and the fact that fixtures do -# not permit 'expect' attributes, we must disable this deny. -# allow_attributes = "deny" -allow_attributes_without_reason = "deny" -blanket_clippy_restriction_lints = "deny" - -# 2. debugging leftovers -# Disabled pending further investigation into applicability. -# dbg_macro = "deny" -# print_stdout = "deny" -# print_stderr = "deny" - -# 3. panic-prone operations -unwrap_used = "deny" -# Expect used in tests. Discouraged in implementation code. -# expect_used = "deny" -indexing_slicing = "deny" -string_slice = "deny" -integer_division = "deny" -integer_division_remainder_used = "deny" - -# 4. portability -host_endian_bytes = "deny" -little_endian_bytes = "deny" -big_endian_bytes = "deny" - -# 5. nursery idiom polish -or_fun_call = "deny" -option_if_let_else = "deny" -use_self = "deny" -string_lit_as_bytes = "deny" - -# 6. numerical foot-guns -float_arithmetic = "deny" diff --git a/ambient_fs/src/lib.rs b/ambient_fs/src/lib.rs deleted file mode 100644 index f32cebc4..00000000 --- a/ambient_fs/src/lib.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Deliberately ambient filesystem probes for executable lookup. -//! -//! Netsuke routes filesystem access through capability-scoped `cap_std` -//! handles, enforced by Whitaker's `no_std_fs_operations` lint. The `which` -//! resolver is the one deliberate exception: it consults directories taken -//! from the ambient `PATH` environment variable and canonicalises candidates -//! across arbitrary symlinks, which capability-scoped handles cannot express -//! (a `cap_std` directory handle refuses to follow a symlink that leaves the -//! directory, breaking common layouts such as `/usr/bin/cc -> -//! /etc/alternatives/cc`). -//! -//! This crate confines those ambient operations to a single leaf crate that -//! is excluded from the lint via `dylint.toml`, mirroring the pattern used by -//! other df12 projects. Scope and reuse policy: only the `which` resolver and -//! runner tempfile plumbing may call into this crate; new ambient access -//! elsewhere should use `cap_std` instead. - -use std::{fs, io}; - -use camino::Utf8Path; - -/// Return `true` when `path` names an executable regular file. -/// -/// Follows symlinks, matching `std::fs::metadata` semantics. On Unix an -/// executable file needs at least one execute permission bit; on other -/// platforms any regular file qualifies. -/// -/// # Errors -/// -/// Propagates metadata errors, including `NotFound` when the path is absent; -/// callers decide whether absence is an error. -/// -/// # Examples -/// -/// ``` -/// use camino::Utf8Path; -/// -/// let sh = Utf8Path::new("/bin/sh"); -/// if let Ok(executable) = ambient_fs::is_executable_file(sh) { -/// assert!(executable, "/bin/sh should be executable where it exists"); -/// } -/// ``` -pub fn is_executable_file(path: &Utf8Path) -> io::Result { - let metadata = fs::metadata(path.as_std_path())?; - Ok(metadata.is_file() && has_execute_permission(&metadata)) -} - -#[cfg(unix)] -fn has_execute_permission(metadata: &fs::Metadata) -> bool { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 -} - -#[cfg(not(unix))] -fn has_execute_permission(metadata: &fs::Metadata) -> bool { - metadata.is_file() -} - -/// Canonicalise `path`, resolving symlinks across the whole filesystem. -/// -/// Returns the canonical path as a raw `std::path::PathBuf` so callers keep -/// control over UTF-8 validation and its error mapping. -/// -/// # Errors -/// -/// Propagates the underlying `canonicalize` failure (missing path, permission -/// denied, and so on). -/// -/// # Examples -/// -/// ``` -/// use camino::Utf8Path; -/// -/// let canonical = ambient_fs::canonicalize(Utf8Path::new(".")).expect("cwd resolves"); -/// assert!(canonical.is_absolute()); -/// ``` -pub fn canonicalize(path: &Utf8Path) -> io::Result { - fs::canonicalize(path.as_std_path()) -} - -/// Return `true` when a walk entry refers to a regular file. -/// -/// # Examples -/// -/// ``` -/// let mut walker = walkdir::WalkDir::new(".").max_depth(1).into_iter(); -/// let root = walker.next().expect("walk yields the root").expect("root is readable"); -/// assert!(!ambient_fs::entry_is_file(&root), "the root directory is not a file"); -/// ``` -#[must_use] -pub fn entry_is_file(entry: &walkdir::DirEntry) -> bool { - entry.file_type().is_file() -} - -/// Return `true` when a walk entry refers to a directory. -/// -/// # Examples -/// -/// ``` -/// let mut walker = walkdir::WalkDir::new(".").max_depth(1).into_iter(); -/// let root = walker.next().expect("walk yields the root").expect("root is readable"); -/// assert!(ambient_fs::entry_is_dir(&root), "the walk root is a directory"); -/// ``` -#[must_use] -pub fn entry_is_dir(entry: &walkdir::DirEntry) -> bool { - entry.file_type().is_dir() -} - -/// Flush a file's contents and metadata to disk via its open handle. -/// -/// Used for tempfiles that live in the ambient system temp directory, where -/// no capability-scoped directory handle exists to sync through. -/// -/// # Errors -/// -/// Propagates the underlying `sync_all` failure. -/// -/// # Examples -/// -/// ``` -/// let file = tempfile::tempfile().expect("create tempfile"); -/// ambient_fs::sync_file(&file).expect("sync tempfile"); -/// ``` -pub fn sync_file(file: &fs::File) -> io::Result<()> { - file.sync_all() -} - -#[cfg(test)] -mod tests { - //! Unit tests for the ambient filesystem probes. - use super::{canonicalize, is_executable_file, sync_file}; - use camino::{Utf8Path, Utf8PathBuf}; - use rstest::{fixture, rstest}; - use std::fs; - use std::io; - - /// Assert that probing `path` with `is_executable_file` yields - /// `expected`. - /// - /// Splits the fallible probe (the command) from the assertion on its - /// result (the query) so an I/O failure reads as a probe error, not a - /// wrong answer; a macro rather than a helper fn keeps panic line - /// numbers pointing at the calling test. - macro_rules! assert_executable_probe { - ($path:expr, $expected:expr) => { - let probed = is_executable_file($path).expect("probe executable state"); - assert_eq!( - probed, $expected, - "unexpected executable probe for {}", - $path - ); - }; - } - - /// Create a temporary directory and its owned UTF-8 path. The `TempDir` is - /// returned so the caller keeps it alive for the directory's lifetime. - /// - /// Returns a `Result` because fixtures arrange state rather than assert - /// on it: arrangement failures must propagate to the test body, which is - /// the only place a panic is a test verdict (and the only context - /// Whitaker's `no_expect_outside_tests` recognises). - #[fixture] - fn temp_dir() -> io::Result<(tempfile::TempDir, Utf8PathBuf)> { - let dir = tempfile::tempdir()?; - let path = Utf8Path::from_path(dir.path()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "temp path is not UTF-8"))? - .to_owned(); - Ok((dir, path)) - } - - #[cfg(unix)] - #[rstest] - #[case(0o755, true)] - #[case(0o644, false)] - fn is_executable_file_reflects_unix_execute_bits(#[case] mode: u32, #[case] expected: bool) { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().expect("create tempdir"); - let path = dir.path().join("candidate"); - fs::write(&path, b"").expect("write candidate file"); - let mut perms = fs::metadata(&path).expect("read metadata").permissions(); - perms.set_mode(mode); - fs::set_permissions(&path, perms).expect("set permissions"); - let utf8 = Utf8Path::from_path(&path).expect("temp path is UTF-8"); - assert_executable_probe!(utf8, expected); - } - - #[cfg(not(unix))] - #[rstest] - fn is_executable_file_accepts_any_regular_file() { - let dir = tempfile::tempdir().expect("create tempdir"); - let path = dir.path().join("candidate"); - fs::write(&path, b"").expect("write candidate file"); - let utf8 = Utf8Path::from_path(&path).expect("temp path is UTF-8"); - assert_executable_probe!(utf8, true); - } - - #[rstest] - fn is_executable_file_rejects_directories( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - let (_dir, utf8) = temp_dir?; - assert_executable_probe!(&utf8, false); - Ok(()) - } - - #[rstest] - fn is_executable_file_reports_missing_paths( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - let (_dir, dir_path) = temp_dir?; - let missing = dir_path.join("does-not-exist"); - let err = is_executable_file(&missing).expect_err("missing path should error"); - assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - Ok(()) - } - - #[rstest] - fn canonicalize_resolves_relative_traversal( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - // Force real filesystem resolution: a `..` round-trip only collapses - // back to the original directory if `canonicalize` consults the - // filesystem rather than returning its input unchanged. `std::fs` - // canonicalization of the un-traversed path is the oracle. - let (_dir, root) = temp_dir?; - let nested = root.join("nested"); - fs::create_dir(nested.as_std_path()).expect("create nested dir"); - let traversed = nested.join("..").join("nested"); - let resolved = canonicalize(&traversed).expect("canonicalize traversal"); - let expected = fs::canonicalize(nested.as_std_path()).expect("canonicalize nested"); - assert_eq!(resolved, expected); - Ok(()) - } - - #[rstest] - fn sync_file_persists_written_bytes( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - // Behavioural oracle: sync then reopen by path and read back, proving - // the bytes are persisted rather than merely that `sync_all` returned - // `Ok`. (True crash durability cannot be verified without OS-level - // fault injection, which is out of scope here.) - use std::io::Write as _; - let (_dir, root) = temp_dir?; - let path = root.join("synced"); - let payload = b"ambient_fs sync payload".to_vec(); - let mut file = fs::File::create(path.as_std_path()).expect("create file for sync"); - file.write_all(&payload).expect("write payload"); - sync_file(&file).expect("sync file"); - let read_back = fs::read(path.as_std_path()).expect("read synced file"); - assert_eq!(read_back, payload); - Ok(()) - } -} diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8baf6b6a..b9b7a0cf 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -94,8 +94,11 @@ Whitaker is configured by `dylint.toml` at the repository root. The `no_std_fs_operations` lint currently ignores in-source `allow`/`expect` attributes, so `dylint.toml` excludes each sanctioned ambient-filesystem scope with a documented rationale: the `build_script_build` Cargo build-script crate, -the `ambient_fs` application leaf crate, the `test_support` test-fixture crate, -and the enumerated integration-test and workflow-contract crates. +the `netsuke` application crate, the `test_support` test-fixture crate, and the +enumerated integration-test and workflow-contract crates. Netsuke itself needs +ambient access for executable discovery through `PATH`, cross-directory symlink +canonicalization, and temporary-file synchronization; other filesystem access +should remain capability-scoped. When command output is long, preserve exit codes and logs: @@ -176,13 +179,10 @@ The caller passes two configuration inputs, each carrying intent: feature) as untested. The caller does not set `extra-crate-dirs`, the input reserved for crate -directories outside the Cargo workspace. `ambient_fs`, the repository's -sanctioned ambient-filesystem escape hatch, is a genuine workspace member -(`[workspace] members = ["ambient_fs"]`, with -`default-members = [".", "ambient_fs"]`), not a non-workspace companion crate, -so it needs no separate mutation invocation here. `test_support` is -deliberately excluded from the workspace (`exclude = ["test_support"]`) and -this workflow does not mutate it. +directories outside the Cargo workspace. Netsuke is a single publishable crate; +its sanctioned ambient-filesystem operations live at their application call +sites. `test_support` is deliberately excluded from the workspace +(`exclude = ["test_support"]`) and this workflow does not mutate it. The `uses:` reference pins the shared workflow to a full 40-character commit SHA rather than a branch or tag, so a force-push upstream cannot silently diff --git a/docs/users-guide.md b/docs/users-guide.md index dbed2bb1..cd142a9f 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -13,7 +13,15 @@ change before 1.0. Pin the Netsuke version in automated workflows. Netsuke requires [Ninja](https://ninja-build.org/) on `PATH`. A source build also requires Rust 1.89 or later. -Until v0.1.0 is published, the current checkout can be installed with Cargo: +To install a published release from crates.io: + + + +```sh +cargo install netsuke +``` + +To install the current source checkout with Cargo: diff --git a/docs/whitaker-users-guide.md b/docs/whitaker-users-guide.md index fd088bf3..69f08d0e 100644 --- a/docs/whitaker-users-guide.md +++ b/docs/whitaker-users-guide.md @@ -40,11 +40,12 @@ This: source-build path is taken, the installer reports that it is falling back to Cargo, and on success it prints `Installed from source with cargo install.`. After installation, - `cargo-dylint` is verified by running `cargo dylint --version`, while - `dylint-link` is verified by resolving the executable on `PATH` and then - invoking it with `--help`. The installer sets `RUSTUP_TOOLCHAIN` for that - probe when needed, which avoids false failures from upstream - `dylint-link --version` while still rejecting stale shims and broken scripts. + `cargo-dylint` is verified by running `cargo dylint --version`. + `dylint-link` is never executed: it is a linker wrapper that forwards its + arguments to the underlying linker, so it has no reliable self-reporting + subcommand. A release artefact is trusted once its checksum, extraction, and + executable permissions have been established, and a Cargo-managed copy is + checked by resolving it on `PATH` and comparing the version Cargo recorded. 2. Clones the Whitaker repository to a platform-specific data directory 3. Builds the lint libraries 4. Creates `whitaker` and `whitaker-ls` wrapper scripts. `whitaker` invokes @@ -58,8 +59,8 @@ After installation, run `whitaker --all` in any Rust project to lint it. Use On Windows, the installer's `PATH` check honours `PATHEXT` and falls back to the usual executable suffixes when `PATHEXT` is unset, so a normal Cargo-installed executable such as `dylint-link.exe` in -`%USERPROFILE%\.cargo\bin` is located correctly and then verified with the same -invocation-based probe without needing a separate wrapper or manual +`%USERPROFILE%\.cargo\bin` is located correctly and then matched against the +version Cargo recorded for it, without needing a separate wrapper or manual environment-variable workaround. **Options:** @@ -67,8 +68,8 @@ environment-variable workaround. - `--cranelift` — Tell the installer to add the `rustc-codegen-cranelift` component via `rustup component add`. The `rustc-codegen-cranelift` component is not included in the standard nightly - toolchain, so enable `--cranelift` when your project or CI requires the - Cranelift back-end and you would otherwise need an explicit + toolchain, so enable `--cranelift` when a project or CI pipeline requires the + Cranelift back-end and would otherwise need an explicit `rustc-codegen-cranelift` component-add step before running the installer. - `--skip-deps` — Skip `cargo-dylint`/`dylint-link` installation check - `--skip-wrapper` — Skip wrapper script generation (prints @@ -128,10 +129,10 @@ Stable releases differ from `rolling`: a stable tag is expected to contain the complete artefact set for the release. For production installs, pin to a stable release tag rather than consuming `rolling`. -If you consume rolling-release archives from scripts or CI, verify that the -required target archive exists before proceeding. Treat missing archives as an -expected condition for rolling releases rather than assuming the artefact set -is complete. +Scripts or CI pipelines that consume rolling-release archives should verify +that the required target archive exists before proceeding. Treat missing +archives as an expected condition for rolling releases rather than assuming the +artefact set is complete. ### Selecting individual lints @@ -326,17 +327,6 @@ user-written source location (macro-only glue) are silently excluded from the ordering check, so the lint never fires on compiler- or macro-generated code that the developer cannot edit. -The lint checks: - -- All functions, methods, and trait methods that carry at least one outer - attribute — including macro-heavy cases such as `#[rstest]` and `#[test]`. - -The lint ignores: - -- Attributes whose recovered span is macro-only (for example, inline hints - injected by `#[derive(...)]`). -- Inner attributes (`#![...]`). - #### Configuration @@ -459,6 +449,7 @@ Set `additional_test_attributes` to an array of attribute paths written as strings. Each entry should match the path Whitaker sees on the test function, for example `my_framework::test` or `wasm_bindgen_test`. + #### Ancestor context propagation `additional_test_attributes` now apply during ancestor context detection as @@ -474,7 +465,7 @@ ancestry chain. #[my_framework::test] async fn my_test() { - helper(); // allowed — ancestor is a recognised test function + helper(); // allowed — ancestor is a recognized test function } fn helper() { @@ -526,11 +517,12 @@ calls inside `#[rstest]` tests into injected `#[fixture]` parameters. #### Scope and behaviour -This lint is experimental. The current implementation registers the lint and -loads configuration defaults so teams can opt into the forthcoming rule without -yet receiving helper-call diagnostics. Call-site collection, cross-test -aggregation, and actionable diagnostics are tracked by the later roadmap items -8.2.2 through 8.2.4. +This lint is experimental. The current implementation registers the lint, loads +configuration defaults, and passively collects local helper calls inside strict +`#[rstest]` tests, fingerprinting fixture-local, literal, `const`, and `static` +arguments for later aggregation. The lint remains diagnostic-silent: threshold +evaluation and actionable diagnostics are tracked by 8.2.3, while UI pass/fail +coverage is tracked by 8.2.4. #### Configuration @@ -698,3 +690,46 @@ useful diagnostic. The rule denies only closures whose `panic!` message does not meet the interpolated-only test exception. When the closure contains a static string literal in tests, prefer `.expect("static message")`; only interpolated-only `panic!` fallbacks are permitted there. + +## Clone Detection: AST Feature Extraction + +Whitaker's experimental clone detector runs in two passes. Pass A is a token +scan over the workspace; Pass B lifts each candidate span into an abstract +syntax tree (AST) substrate that later scoring will consume. This release ships +the Pass B substrate only — it does not yet report clones (see below). + +**What the substrate does.** Given a source file and a candidate byte range, +`lower_span` validates a non-empty, UTF-8-aligned `ByteSpan`, parses the +supplied source, and lowers the smallest syntax subtree that covers the span +into a parser-independent `NormalizedTree`. Working from the smallest covering +subtree keeps the representation tied to the candidate rather than the whole +file. + +**Feature vectors.** From a `NormalizedTree`, extraction derives a +deterministic set of features: + +- exact counts of each syntax kind; +- dyadic fixed-point depth weighting (`2^63 >> depth`), so weights halve with + depth and collapse to zero beyond depth 63; +- production bigrams and trigrams (parent-to-child and + grandparent-to-parent-to-child kind sequences); and +- an opaque hexadecimal canonical hash of the normalized subtree. + +**Schema-versioned hashes.** Every canonical hash mixes in +`PARSER_SCHEMA_VERSION`, which is tied to the pinned parser snapshot. A +parser-schema change therefore changes every hash by design, intentionally +invalidating persisted snapshots and caches so stale AST fingerprints are never +reused across incompatible parser versions. + +**Parser feature.** `whitaker_clones_core` enables its exact-pinned +`ra_ap_syntax` parser adapter through the default `parser` feature. The feature +is on by default; building the crate with it disabled makes `lower_span` return +`AstError::ParserUnavailable` instead of lowering anything. + +**Not yet emitted.** Type-3 clone scoring and SARIF Run 1 emission are deferred +to roadmap item 7.3.2. This release builds and exposes the AST substrate only; +it does not score clones or emit AST-based SARIF results. + +Contributors maintaining the pinned parser should follow the +[`ra_ap_syntax` re-pinning runbook](developers-guide.md#ra_ap_syntax-re-pinning-runbook) +in the Developer's Guide. diff --git a/dylint.toml b/dylint.toml index eb80648d..726c02c9 100644 --- a/dylint.toml +++ b/dylint.toml @@ -5,9 +5,10 @@ # - build_script_build: the Cargo build script writes generated man pages and # audit artefacts to ambient paths supplied by Cargo (`OUT_DIR`, `target/`), # where capability-based handles offer no benefit. -# - ambient_fs: the leaf crate that deliberately confines the ambient -# filesystem probes needed by the `which` resolver (PATH lookup, full-path -# canonicalisation, workspace walking); see ambient_fs/src/lib.rs. +# - netsuke: the application crate deliberately performs executable probes in +# directories supplied by ambient PATH, follows cross-directory symlinks when +# canonicalising those executables, and syncs its ambient temporary file. +# These application-boundary operations cannot be capability-scoped. # - test_support: the shared test-fixture crate; fixture management uses # ambient tempdir access by design (see test_support/src/fs.rs), matching # the "test support utilities" exclusion in the Whitaker user's guide. @@ -18,7 +19,7 @@ [no_std_fs_operations] excluded_crates = [ "build_script_build", - "ambient_fs", + "netsuke", "test_support", "advanced_usage_tests", "assert_cmd_tests", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 908d2ecb..8f0ca5d1 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "1.89.0" -components = ["rustfmt", "clippy"] +components = ["rustfmt", "clippy", "rust-analyzer"] diff --git a/src/runner/process/file_io.rs b/src/runner/process/file_io.rs index ea68a0b5..56c965ee 100644 --- a/src/runner/process/file_io.rs +++ b/src/runner/process/file_io.rs @@ -35,10 +35,10 @@ pub fn create_temp_ninja_file(content: &NinjaContent) -> AnyResult io::Result<()> { - ambient_fs::sync_file(tmp.as_file()) + tmp.as_file().sync_all() } pub fn write_text_file_utf8(dir: &cap_fs::Dir, path: &Utf8Path, content: &str) -> AnyResult<()> { diff --git a/src/stdlib/which/lookup/mod.rs b/src/stdlib/which/lookup/mod.rs index 369bb6be..223c926d 100644 --- a/src/stdlib/which/lookup/mod.rs +++ b/src/stdlib/which/lookup/mod.rs @@ -1,10 +1,10 @@ //! Filesystem search utilities for resolving commands for the `which` feature. //! -//! Executable probes and canonicalisation go through the `ambient_fs` crate: -//! PATH lookup is deliberately ambient, so it cannot use the capability-based -//! handles mandated elsewhere in Netsuke. +//! PATH lookup is deliberately ambient, so executable probes and +//! canonicalisation cannot use the capability-based handles mandated elsewhere +//! in Netsuke. -use std::io; +use std::{fs, io}; use camino::{Utf8Path, Utf8PathBuf}; use indexmap::IndexSet; @@ -208,8 +208,8 @@ fn candidates_for_dir(env: &EnvSnapshot, dir: &Utf8Path, command: &str) -> Vec Result { - match ambient_fs::is_executable_file(path) { - Ok(executable) => Ok(executable), + match fs::metadata(path.as_std_path()) { + Ok(metadata) => Ok(is_executable_metadata(&metadata)), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), Err(source) => Err(ResolveError::IsExecutable { path: path.to_owned(), @@ -218,6 +218,17 @@ pub(super) fn is_executable(path: &Utf8Path) -> Result { } } +#[cfg(unix)] +fn is_executable_metadata(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable_metadata(metadata: &fs::Metadata) -> bool { + metadata.is_file() +} + #[derive(Clone, Copy)] struct HandleMissContext<'a> { env: &'a EnvSnapshot, @@ -254,7 +265,7 @@ pub(super) fn canonicalise(paths: Vec) -> Result, let mut resolved = Vec::new(); for path in paths { let canonical = - ambient_fs::canonicalize(&path).map_err(|source| ResolveError::Canonicalise { + fs::canonicalize(path.as_std_path()).map_err(|source| ResolveError::Canonicalise { path: path.clone(), source, })?; diff --git a/src/stdlib/which/lookup/workspace/mod.rs b/src/stdlib/which/lookup/workspace/mod.rs index c4b06017..05521478 100644 --- a/src/stdlib/which/lookup/workspace/mod.rs +++ b/src/stdlib/which/lookup/workspace/mod.rs @@ -99,7 +99,7 @@ pub(super) fn search_workspace( } pub(super) fn should_visit_entry(entry: &walkdir::DirEntry, skip_dirs: &WorkspaceSkipList) -> bool { - if !ambient_fs::entry_is_dir(entry) { + if !entry.file_type().is_dir() { return true; } let name = entry.file_name().to_string_lossy(); diff --git a/src/stdlib/which/lookup/workspace/posix.rs b/src/stdlib/which/lookup/workspace/posix.rs index 32b0b094..76c01463 100644 --- a/src/stdlib/which/lookup/workspace/posix.rs +++ b/src/stdlib/which/lookup/workspace/posix.rs @@ -55,7 +55,7 @@ fn process_workspace_entry( command: &str, _skip_dirs: &WorkspaceSkipList, ) -> Result, ResolveError> { - if !ambient_fs::entry_is_file(&entry) { + if !entry.file_type().is_file() { return Ok(None); } diff --git a/src/stdlib/which/lookup/workspace/windows.rs b/src/stdlib/which/lookup/workspace/windows.rs index b1b33436..9e777e0b 100644 --- a/src/stdlib/which/lookup/workspace/windows.rs +++ b/src/stdlib/which/lookup/workspace/windows.rs @@ -90,7 +90,7 @@ fn match_workspace_entry( command: &str, ctx: &WorkspaceMatchContext, ) -> Result, ResolveError> { - if !ambient_fs::entry_is_file(&entry) { + if !entry.file_type().is_file() { return Ok(None); } diff --git a/test_support/Cargo.lock b/test_support/Cargo.lock index ca344df7..8584156e 100644 --- a/test_support/Cargo.lock +++ b/test_support/Cargo.lock @@ -38,14 +38,6 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" -[[package]] -name = "ambient_fs" -version = "0.1.0" -dependencies = [ - "camino", - "walkdir", -] - [[package]] name = "anstream" version = "0.6.21" @@ -168,6 +160,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -250,7 +257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 1.3.0", ] [[package]] @@ -568,6 +575,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1126,7 +1139,6 @@ dependencies = [ name = "netsuke" version = "0.1.0" dependencies = [ - "ambient_fs", "anyhow", "camino", "cap-std", @@ -1150,7 +1162,7 @@ dependencies = [ "serde_json_canonicalizer", "sha2", "shell-quote", - "shlex", + "shlex 2.0.1", "sys-locale", "tempfile", "thiserror 1.0.69", @@ -1356,6 +1368,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "2.1.5" @@ -1434,6 +1455,31 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -1449,6 +1495,44 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rapidhash" version = "4.4.2" @@ -1658,6 +1742,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1832,6 +1928,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1976,6 +2078,7 @@ dependencies = [ "cap-std", "mockable", "netsuke", + "proptest", "rstest", "sha2", "tempfile", @@ -2256,6 +2359,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "uncased" version = "0.9.10" @@ -2750,6 +2859,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "zerofrom" version = "0.1.7" diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index a083ec17..4f6d65a3 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -21,6 +21,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-cli-usage", "guide-command-available-manifest", "guide-complete-manifest", + "guide-crates-io-install", "guide-first-build-commands", "guide-first-build-manifest", "guide-foreach-manifest", @@ -33,6 +34,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-source-install", "guide-utility-commands", "guide-windows-help", + "readme-crates-io-install", "readme-first-build-commands", "readme-first-build-manifest", "readme-source-install", @@ -148,6 +150,18 @@ fn documented_first_run_flow_builds( #[test] fn installation_examples_match_source_and_release_contracts() -> Result<()> { + let readme_release = documented_example("readme-crates-io-install")?; + let guide_release = documented_example("guide-crates-io-install")?; + let expected_release = "cargo install netsuke\n"; + ensure!( + readme_release.body == expected_release, + "README crates.io install drifted" + ); + ensure!( + guide_release.body == expected_release, + "user's guide crates.io install drifted" + ); + let readme = documented_example("readme-source-install")?; let guide = documented_example("guide-source-install")?; let expected = concat!(