Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3610,6 +3610,14 @@ The locator's unit tests live beside it in
executable layouts, an `out` directory that is *not* the Cargo build layout,
both fallback paths, and the missing-binary case.

The private, test-only child module
`test_support/src/netsuke/locator/tests/locator_property_tests.rs` owns the
generated property coverage. `locator.rs` retains the fixed Cargo-layout and
named presence-mask regression tests, while the child verifies candidate-list
content and order, first-present lookup selection across optional
`CARGO_TARGET_DIR`/profile/target-triple layouts, and missing-candidate
diagnostics.

## Digest rendering

`src/hex.rs` (`netsuke::hex`) is the single owner of lowercase hexadecimal
Expand Down
36 changes: 36 additions & 0 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2308,6 +2308,42 @@ rule, so its view of the environment always matches the child's.
The developers' guide documents the module layout and the `PATH` composition
helper under "Module: `runner::process::command_env`".

Integration-test support finds the already-built `netsuke` executable before
spawning it. Its locator derives an ordered candidate list from the test
executable's layout and injected `CARGO_TARGET_DIR`, then selects the first
existing candidate. The property tests construct the filesystem layout and the
expected result independently, so they validate both successful selection and
the diagnostic that records every attempted candidate.

For screen readers: The following sequence shows a property test creating
candidate paths, asking the locator to inspect them in order, and comparing the
result with its independently reconstructed expectation. When a candidate
exists, the locator returns the first matching path. When none exists, it
returns a diagnostic listing every candidate it checked.

```mermaid
sequenceDiagram
participant PropertyTest
participant Locator
participant Filesystem
participant Diagnostic

PropertyTest->>Filesystem: create candidate paths from generated layout
PropertyTest->>Locator: netsuke_executable_from()
Locator->>Filesystem: check candidates in lookup order
Filesystem-->>Locator: candidate presence
alt executable candidate exists
Locator-->>PropertyTest: first matching path
else no candidate exists
Locator->>Diagnostic: build missing-candidate diagnostic
Diagnostic-->>PropertyTest: missing diagnostic
end
PropertyTest->>PropertyTest: verify independently reconstructed result
```

Figure: Property-based test support resolves the first executable candidate or
reports every missing candidate.

### 6.2 The Criticality of Shell Escaping

A primary security responsibility for Netsuke is the prevention of command
Expand Down
5 changes: 5 additions & 0 deletions docs/snapshot-testing-in-netsuke-using-insta.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ field named `version` remains visible in snapshot diffs. The generator name and
`schema_version` are deliberately excluded from this redaction and remain
asserted structurally.

The fixed diagnostic example exercises every structural filter path because the
regular expression has no data-dependent branches. The
`snapshot_test_support` property test separately varies valid `SemVer`
generator versions.

## Running and Updating Snapshot Tests

> In this repository the canonical runner is cargo-nextest: `make test`, or
Expand Down
11 changes: 11 additions & 0 deletions src/diagnostic_json_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
//! Tests for Netsuke's JSON diagnostics schema.
//!
//! The diagnostic snapshot filter is one anchored regular expression with no
//! data-dependent branching. A fixed example that places unrelated `version`
//! fields on both sides of the matching generator block therefore covers every
//! filter path; property generation would not exercise a distinct behaviour.

use super::{render_diagnostic_json, render_error_json};
use crate::ir::IrGenError;
Expand Down Expand Up @@ -28,6 +33,9 @@ fn parse_json_value(document: &str) -> Result<Value> {
fn snapshot_filter_preserves_versions_outside_the_generator_block() {
let rendered = concat!(
"{\n",
" \"schema\": {\n",
" \"version\": \"3.4.5\"\n",
" },\n",
" \"generator\": {\n",
" \"name\": \"netsuke\",\n",
" \"version\": \"9.9.9\"\n",
Expand All @@ -42,6 +50,9 @@ fn snapshot_filter_preserves_versions_outside_the_generator_block() {
snapshot_settings().bind(|| {
assert_snapshot!(rendered, @r#"
{
"schema": {
"version": "3.4.5"
},
"generator": {
"name": "netsuke",
"version": "[version]"
Expand Down
3 changes: 3 additions & 0 deletions test_support/src/netsuke/locator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ mod tests {
use mockable::MockEnv;
use rstest::{fixture, rstest};

#[path = "locator_property_tests.rs"]
mod locator_property_tests;

fn utf8_root(temp: &tempfile::TempDir) -> Result<Utf8PathBuf> {
Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
.map_err(|path| anyhow::anyhow!("temp dir {} is not UTF-8", path.display()))
Expand Down
150 changes: 150 additions & 0 deletions test_support/src/netsuke/locator/tests/locator_property_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Property tests for generated Netsuke executable locator layouts.
//!
//! The table tests in `locator.rs` pin Cargo's named layouts and exhaust every
//! three-candidate presence mask. These properties complement them by stating
//! the same candidate ordering and selection invariants over arbitrary valid
//! UTF-8 root components, profiles, target triples, and target directories.

use super::super::{candidate_paths, netsuke_executable_from};
use super::{binary_name, env_with_target_dir, touch, utf8_root};
use proptest::prelude::*;
use proptest::test_runner::TestCaseError;

/// List the DOS device names that cannot form Windows path components.
const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];

/// Determine whether `component` is reserved as a Windows device name.
fn is_windows_reserved_device_name(component: &str) -> bool {
WINDOWS_RESERVED_DEVICE_NAMES
.iter()
.any(|name| component.eq_ignore_ascii_case(name))
}

/// Generate a valid UTF-8 path component that is safe on Windows.
fn safe_component() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_-]{0,8}".prop_filter("component must not be a Windows device name", |component| {
!is_windows_reserved_device_name(component)
})
}

/// Generate a valid UTF-8 path component for a temporary-root child.
fn root_component() -> impl Strategy<Value = String> {
safe_component()
}

/// Generate a valid Cargo profile component distinct from `deps`.
fn profile_component() -> impl Strategy<Value = String> {
safe_component().prop_filter("profile component must not be `deps`", |component| {
component != "deps"
})
}

/// Generate a valid target-triple component distinct from `deps`.
fn target_triple() -> impl Strategy<Value = String> {
safe_component().prop_filter("target triple must not be `deps`", |component| {
component != "deps"
})
}

/// Generate an optional valid UTF-8 `CARGO_TARGET_DIR` component.
fn target_dir_component() -> impl Strategy<Value = Option<String>> {
proptest::option::of(root_component())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Generate target directories that cannot alias the primary candidate.
fn lookup_target_dir_component() -> impl Strategy<Value = Option<String>> {
target_dir_component().prop_filter("target directory must not be `build`", |component| {
component.as_deref() != Some("build")
})
}

/// Build an absolute UTF-8 root under a newly allocated temporary directory.
fn generated_root(
root_component: String,
) -> Result<(tempfile::TempDir, camino::Utf8PathBuf), TestCaseError> {
let temp = tempfile::tempdir().map_err(|error| TestCaseError::fail(error.to_string()))?;
let root = utf8_root(&temp).map_err(|error| TestCaseError::fail(error.to_string()))?;
Ok((temp, root.join(root_component)))
}

proptest! {
/// Keep candidate contents and order stable for every generated layout.
#[test]
fn candidate_paths_match_the_documented_order(
root_component in root_component(),
profile in profile_component(),
triple in target_triple(),
target_dir_component in target_dir_component(),
) {
let (_temp, root) = generated_root(root_component)?;
let exe_dir = root.join("build").join(&triple).join(&profile);
let target_dir = target_dir_component
.as_deref()
.map(|component| root.join(component));
let env = env_with_target_dir(target_dir.as_deref());
let binary = binary_name();

let candidates = candidate_paths(&env, &exe_dir, &binary);
let mut expected = vec![exe_dir.join(&binary)];
if let Some(target_root) = &target_dir {
expected.push(target_root.join(&profile).join(&binary));
expected.push(target_root.join(&triple).join(&profile).join(&binary));
}

prop_assert_eq!(&candidates, &expected);
if target_dir.is_none() {
prop_assert_eq!(candidates.len(), 1);
}
}

/// Resolve the first staged candidate and report every missing path.
#[test]
fn executable_lookup_honours_generated_candidate_order(
root_component in root_component(),
profile in profile_component(),
triple in target_triple(),
target_dir_component in lookup_target_dir_component(),
presence in 0u8..8,
) {
let (_temp, root) = generated_root(root_component)?;
let exe_dir = root.join("build").join(&triple).join(&profile);
let executable = exe_dir.join("deps").join("test-exe");
touch(&executable).map_err(|error| TestCaseError::fail(error.to_string()))?;

let target_dir = target_dir_component
.as_deref()
.map(|component| root.join(component));
let env = env_with_target_dir(target_dir.as_deref());
let binary = binary_name();
let candidates = candidate_paths(&env, &exe_dir, &binary);
for (slot, candidate) in candidates.iter().enumerate() {
if presence & (1 << slot) != 0 {
touch(candidate).map_err(|error| TestCaseError::fail(error.to_string()))?;
}
}

let located = netsuke_executable_from(&env, &executable);
let first_present = candidates
.iter()
.enumerate()
.find(|(slot, _)| presence & (1 << slot) != 0);
if let Some((_, expected)) = first_present {
let resolved = located.map_err(|error| TestCaseError::fail(error.to_string()))?;
prop_assert_eq!(resolved.as_path(), expected.as_path());
} else {
let error = located
.err()
.ok_or_else(|| TestCaseError::fail("missing candidates should fail"))?;
let message = error.to_string();
for candidate in candidates {
prop_assert!(
message.contains(candidate.as_str()),
"missing-candidate diagnostic should list {candidate}; got: {message}"
);
}
}
}
}
Loading