From 5d7c038ff6d9692c5c580843fed8bc8cba9412ea Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 00:23:41 +0100 Subject: [PATCH 01/21] Refactor stdlib path helpers Move path filter logic into a dedicated module, stub Unix-only file tests on non-Unix targets, and expand the HOME resolution to cover Windows fallbacks with updated docs and tests. --- Cargo.lock | 24 ++ Cargo.toml | 7 + docs/netsuke-design.md | 24 +- docs/roadmap.md | 2 +- src/stdlib.rs | 308 ------------------------- src/stdlib/mod.rs | 110 +++++++++ src/stdlib/path.rs | 359 +++++++++++++++++++++++++++++ src/stdlib/path/io_helpers.rs | 59 +++++ tests/std_filter_tests.rs | 420 ++++++++++++++++++++++++++++++++++ 9 files changed, 1001 insertions(+), 312 deletions(-) delete mode 100644 src/stdlib.rs create mode 100644 src/stdlib/mod.rs create mode 100644 src/stdlib/path.rs create mode 100644 src/stdlib/path/io_helpers.rs create mode 100644 tests/std_filter_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 76397f21..2429a3a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -900,6 +900,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.5" @@ -1019,10 +1029,12 @@ dependencies = [ "clap", "clap_mangen", "cucumber", + "digest", "glob", "insta", "itertools 0.12.1", "itoa", + "md-5", "miette", "minijinja", "mockable", @@ -1036,6 +1048,7 @@ dependencies = [ "serde_json_canonicalizer", "serde_yml", "serial_test", + "sha1", "sha2", "shell-quote", "shlex", @@ -1566,6 +1579,17 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index a441e58f..6300874e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,10 @@ include = [ ] license = "ISC" +[features] +default = [] +legacy-digests = ["sha1", "md5"] + [dependencies] clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } @@ -23,7 +27,10 @@ semver = { version = "1", features = ["serde"] } anyhow = "1" thiserror = "1" miette = { version = "7.6.0", features = ["fancy"] } +digest = "0.10" sha2 = "0.10" +sha1 = { version = "0.10", optional = true } +md5 = { package = "md-5", version = "0.10", optional = true } itoa = "1" itertools = "0.12" glob = "0.3.3" diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 86ecdf79..b295844a 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -828,9 +828,9 @@ The `dir`, `file`, and `symlink` tests use `cap_std`'s UTF-8-capable operand's [`FileType`][filetype]. Because this lookup does not follow links, `symlink` tests never report a file or directory for the same path. On Unix the `pipe`, `block_device`, `char_device`, and legacy `device` tests also probe the -metadata. On non-Unix targets only `pipe` and `device` are registered; both -always return `false` so templates remain portable. Missing paths evaluate to -`false`, while I/O errors raise a template error. +metadata. On non-Unix targets these predicates are stubbed to always return +`false` so templates remain portable. Missing paths evaluate to `false`, while +I/O errors raise a template error. [cap-symlink]: https://docs.rs/cap-std/latest/cap_std/fs_utf8/struct.Dir.html#method.symlink_metadata @@ -867,6 +867,24 @@ All built-in filters use `snake_case`. The `camel_case` helper is provided in place of `camelCase` so naming remains consistent with `snake_case` and `kebab-case`. +Implementation notes: + +- Filters use `cap-std` directories for all filesystem work, avoiding ambient + authority. +- `realpath` canonicalises the parent directory before joining the resolved + entry so results are absolute and symlink-free. +- `contents` and `linecount` currently support UTF-8 input; other encodings are + rejected with an explicit error. +- `hash` and `digest` accept `sha256` (default) and `sha512`. Legacy + algorithms `sha1` and `md5` are cryptographically broken and are disabled by + default; enabling them requires the `legacy-digests` Cargo feature and should + only be done for compatibility with existing ecosystems. +- `expanduser` mirrors shell semantics by inspecting `HOME`, `USERPROFILE`, + and on Windows the `HOMEDRIVE`/`HOMEPATH` or `HOMESHARE` fallbacks. + Platform-specific forms such as `~user` remain unsupported. +- `with_suffix` removes dotted suffix segments (default `n = 1`) before + appending the provided suffix. + #### Generic collection filters | Filter | Purpose | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c02af94..4f44b641 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -159,7 +159,7 @@ library, and CLI ergonomics. - [x] Implement the basic file-system tests (`dir`, `file`, `symlink`, `pipe`, `block_device`, `char_device`, legacy `device`). *(done)* - - [ ] Implement the path and file filters (basename, dirname, with_suffix, + - [x] Implement the path and file filters (basename, dirname, with_suffix, realpath, contents, hash, etc.). - [ ] Implement the generic collection filters (`uniq`, `flatten`, diff --git a/src/stdlib.rs b/src/stdlib.rs deleted file mode 100644 index 7481c860..00000000 --- a/src/stdlib.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! File-type predicates for Jinja templates. -//! -//! Registers `dir`, `file`, and `symlink` tests on all platforms. On Unix it -//! also provides `pipe`, `block_device`, `char_device`, and the legacy `device` -//! test. On other platforms `pipe` and `device` are stubbed to always return -//! `false`. -//! -//! I/O errors yield [`ErrorKind::InvalidOperation`] while missing paths return -//! `Ok(false)` rather than an error. - -use camino::Utf8Path; -#[cfg(unix)] -use cap_std::fs::FileTypeExt; -use cap_std::{ambient_authority, fs, fs_utf8::Dir}; -use minijinja::{Environment, Error, ErrorKind, value::Value}; -use std::io; - -fn is_dir(ft: fs::FileType) -> bool { - ft.is_dir() -} -fn is_file(ft: fs::FileType) -> bool { - ft.is_file() -} -fn is_symlink(ft: fs::FileType) -> bool { - ft.is_symlink() -} -#[cfg(unix)] -fn is_fifo(ft: fs::FileType) -> bool { - ft.is_fifo() -} -#[cfg(unix)] -fn is_block_device(ft: fs::FileType) -> bool { - ft.is_block_device() -} -#[cfg(unix)] -fn is_char_device(ft: fs::FileType) -> bool { - ft.is_char_device() -} -#[cfg(unix)] -fn is_device(ft: fs::FileType) -> bool { - is_block_device(ft) || is_char_device(ft) -} - -type FileTest = (&'static str, fn(fs::FileType) -> bool); - -/// Register standard library helpers with the Jinja environment. -/// -/// # Examples -/// ``` -/// use minijinja::{Environment, context}; -/// use netsuke::stdlib; -/// -/// let mut env = Environment::new(); -/// stdlib::register(&mut env); -/// env.add_template("t", "{% if path is dir %}yes{% endif %}").unwrap(); -/// let tmpl = env.get_template("t").unwrap(); -/// let cwd = std::env::current_dir().unwrap(); -/// let rendered = tmpl -/// .render(context!(path => cwd.to_string_lossy())) -/// .unwrap(); -/// assert_eq!(rendered, "yes"); -/// ``` -pub fn register(env: &mut Environment<'_>) { - const TESTS: &[FileTest] = &[ - ("dir", is_dir), - ("file", is_file), - ("symlink", is_symlink), - #[cfg(unix)] - ("pipe", is_fifo), - #[cfg(unix)] - ("block_device", is_block_device), - #[cfg(unix)] - ("char_device", is_char_device), - // Deprecated combined test; prefer block_device or char_device. - #[cfg(unix)] - ("device", is_device), - ]; - - for &(name, pred) in TESTS { - env.add_test(name, move |val: Value| -> Result { - if let Some(s) = val.as_str() { - return is_file_type(Utf8Path::new(s), pred); - } - Ok(false) - }); - } - - #[cfg(not(unix))] - { - env.add_test("pipe", |_val: Value| Ok(false)); - env.add_test("block_device", |_val: Value| Ok(false)); - env.add_test("char_device", |_val: Value| Ok(false)); - env.add_test("device", |_val: Value| Ok(false)); - } -} - -/// Determine whether `path` matches the given file type predicate. -/// -/// Uses `Dir::symlink_metadata`, so symbolic links are inspected without -/// following the target. `is_symlink` and `is_file`/`is_dir` cannot both be -/// true for the same path. -/// -/// Returns `Ok(false)` if the path does not exist. -fn is_file_type(path: &Utf8Path, predicate: F) -> Result -where - F: Fn(fs::FileType) -> bool, -{ - let (dir_path, file_name) = match (path.parent(), path.file_name()) { - (Some(parent), Some(name)) => (parent, name), - (Some(parent), None) => (parent, "."), - (None, Some(name)) => (Utf8Path::new("."), name), - (None, None) => (Utf8Path::new("."), "."), - }; - let dir = match Dir::open_ambient_dir(dir_path, ambient_authority()) { - Ok(d) => d, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), - Err(err) => { - return Err(Error::new( - ErrorKind::InvalidOperation, - format!("cannot open directory for {path}: {err}"), - ) - .with_source(err)); - } - }; - match dir.symlink_metadata(Utf8Path::new(file_name)) { - Ok(md) => Ok(predicate(md.file_type())), - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(Error::new( - ErrorKind::InvalidOperation, - format!("cannot read metadata for {path}: {err}"), - ) - .with_source(err)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use camino::Utf8PathBuf; - use cap_std::fs_utf8::Dir; - use rstest::{fixture, rstest}; - #[cfg(unix)] - use rustix::fs::{Dev, FileType, Mode, mknodat}; - use tempfile::tempdir; - - #[fixture] - fn file_paths() -> ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ) { - let temp = tempdir().expect("tempdir"); - let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8"); - let dir = root.join("d"); - let file = root.join("f"); - let link = root.join("s"); - let fifo = root.join("p"); - let handle = Dir::open_ambient_dir(&root, ambient_authority()).expect("ambient"); - handle.create_dir("d").expect("dir"); - handle.write("f", b"x").expect("file"); - handle.symlink("f", "s").expect("symlink"); - #[cfg(unix)] - mknodat( - &handle, - "p", - FileType::Fifo, - Mode::RUSR | Mode::WUSR, - Dev::default(), - ) - .expect("fifo"); - let bdev = Utf8PathBuf::from("/dev/loop0"); - let cdev = Utf8PathBuf::from("/dev/null"); - (temp, dir, file, link, fifo, bdev, cdev) - } - - #[rstest] - fn detects_dir( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, dir, _, _, _, _, _) = file_paths; - assert!(is_file_type(&dir, is_dir).expect("dir")); - } - - #[rstest] - fn detects_file( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, _, file, _, _, _, _) = file_paths; - assert!(is_file_type(&file, is_file).expect("file")); - } - - #[rstest] - fn detects_symlink( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, _, file, link, _, _, _) = file_paths; - assert!(is_file_type(&link, is_symlink).expect("link")); - assert!(!is_file_type(&file, is_symlink).expect("file")); - } - - #[cfg(unix)] - #[rstest] - fn detects_pipe( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, _, _, _, fifo, _, _) = file_paths; - assert!(is_file_type(&fifo, is_fifo).expect("fifo")); - } - - #[cfg(unix)] - #[rstest] - fn detects_block_device( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, _, _, _, _, bdev, _) = file_paths; - if !bdev.as_std_path().exists() { - eprintln!("block device fixture not found; skipping"); - return; - } - assert!(is_file_type(&bdev, is_block_device).expect("block")); - } - - #[cfg(unix)] - #[rstest] - fn detects_char_device( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, _, _, _, _, _, cdev) = file_paths; - assert!(is_file_type(&cdev, is_char_device).expect("char")); - } - - #[cfg(unix)] - #[rstest] - fn detects_device( - file_paths: ( - tempfile::TempDir, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - Utf8PathBuf, - ), - ) { - let (_, _, _, _, _, _, cdev) = file_paths; - assert!(is_file_type(&cdev, is_device).expect("device")); - } - - #[rstest] - fn nonexistent_path_is_false() { - let temp = tempdir().expect("tempdir"); - let missing = - Utf8PathBuf::from_path_buf(temp.path().join("missing")).expect("utf8 missing path"); - assert!(!is_file_type(&missing, is_file).expect("missing")); - } -} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs new file mode 100644 index 00000000..eabb1dd2 --- /dev/null +++ b/src/stdlib/mod.rs @@ -0,0 +1,110 @@ +//! File-system helpers for `MiniJinja` templates. +//! +//! Registers platform-aware file tests and a suite of path and file filters. +//! Tests such as `dir`, `file`, and `symlink` inspect metadata without +//! following symlinks, while filters expose conveniences like `basename`, +//! `with_suffix`, `realpath`, and content hashing. + +mod path; + +use camino::Utf8Path; +use cap_std::fs; +#[cfg(unix)] +use cap_std::fs::FileTypeExt; +use minijinja::{Environment, Error, value::Value}; + +type FileTest = (&'static str, fn(fs::FileType) -> bool); + +/// Register standard library helpers with the `MiniJinja` environment. +/// +/// # Examples +/// ``` +/// use minijinja::{context, Environment}; +/// use netsuke::stdlib; +/// +/// let mut env = Environment::new(); +/// stdlib::register(&mut env); +/// env.add_template("t", "{{ path | basename }}").expect("add template"); +/// let tmpl = env.get_template("t").expect("get template"); +/// let rendered = tmpl +/// .render(context!(path => "foo/bar.txt")) +/// .expect("render"); +/// assert_eq!(rendered, "bar.txt"); +/// ``` +pub fn register(env: &mut Environment<'_>) { + register_file_tests(env); + path::register_filters(env); +} + +fn register_file_tests(env: &mut Environment<'_>) { + const TESTS: &[FileTest] = &[ + ("dir", is_dir), + ("file", is_file), + ("symlink", is_symlink), + ("pipe", is_fifo), + ("block_device", is_block_device), + ("char_device", is_char_device), + ("device", is_device), + ]; + + for &(name, pred) in TESTS { + env.add_test(name, move |val: Value| -> Result { + if let Some(s) = val.as_str() { + return path::file_type_matches(Utf8Path::new(s), pred); + } + Ok(false) + }); + } +} + +fn is_dir(ft: fs::FileType) -> bool { + ft.is_dir() +} + +fn is_file(ft: fs::FileType) -> bool { + ft.is_file() +} + +fn is_symlink(ft: fs::FileType) -> bool { + ft.is_symlink() +} + +#[cfg(unix)] +fn is_fifo(ft: fs::FileType) -> bool { + ft.is_fifo() +} + +#[cfg(not(unix))] +fn is_fifo(_ft: fs::FileType) -> bool { + false +} + +#[cfg(unix)] +fn is_block_device(ft: fs::FileType) -> bool { + ft.is_block_device() +} + +#[cfg(not(unix))] +fn is_block_device(_ft: fs::FileType) -> bool { + false +} + +#[cfg(unix)] +fn is_char_device(ft: fs::FileType) -> bool { + ft.is_char_device() +} + +#[cfg(not(unix))] +fn is_char_device(_ft: fs::FileType) -> bool { + false +} + +#[cfg(unix)] +fn is_device(ft: fs::FileType) -> bool { + is_block_device(ft) || is_char_device(ft) +} + +#[cfg(not(unix))] +fn is_device(_ft: fs::FileType) -> bool { + false +} diff --git a/src/stdlib/path.rs b/src/stdlib/path.rs new file mode 100644 index 00000000..e8e92eb6 --- /dev/null +++ b/src/stdlib/path.rs @@ -0,0 +1,359 @@ +use std::{ + env, + fmt::Write as FmtWrite, + io::{self, Read}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs, fs_utf8::Dir}; +use digest::Digest; +#[cfg(feature = "legacy-digests")] +use md5::Md5; +use minijinja::{Environment, Error, ErrorKind}; + +mod io_helpers; + +use io_helpers::io_to_error; +#[cfg(feature = "legacy-digests")] +use sha1::Sha1; +use sha2::{Sha256, Sha512}; + +pub(super) fn register_filters(env: &mut Environment<'_>) { + env.add_filter("basename", |raw: String| -> Result { + Ok(basename(Utf8Path::new(&raw))) + }); + env.add_filter("dirname", |raw: String| -> Result { + Ok(dirname(Utf8Path::new(&raw))) + }); + env.add_filter( + "with_suffix", + |raw: String, + suffix: String, + count: Option, + sep: Option| + -> Result { + let count = count.unwrap_or(1); + let sep = sep.unwrap_or_else(|| ".".to_string()); + with_suffix(Utf8Path::new(&raw), &suffix, count, &sep).map(Utf8PathBuf::into_string) + }, + ); + env.add_filter( + "relative_to", + |raw: String, root: String| -> Result { + relative_to(Utf8Path::new(&raw), Utf8Path::new(&root)) + }, + ); + env.add_filter("realpath", |raw: String| -> Result { + canonicalize_any(Utf8Path::new(&raw)).map(Utf8PathBuf::into_string) + }); + env.add_filter("expanduser", |raw: String| -> Result { + expanduser(&raw) + }); + env.add_filter("size", |raw: String| -> Result { + file_size(Utf8Path::new(&raw)) + }); + env.add_filter( + "contents", + |raw: String, encoding: Option| -> Result { + let encoding = encoding.unwrap_or_else(|| "utf-8".to_string()); + read_text(Utf8Path::new(&raw), &encoding) + }, + ); + env.add_filter("linecount", |raw: String| -> Result { + linecount(Utf8Path::new(&raw)) + }); + env.add_filter( + "hash", + |raw: String, alg: Option| -> Result { + let alg = alg.unwrap_or_else(|| "sha256".to_string()); + compute_hash(Utf8Path::new(&raw), &alg) + }, + ); + env.add_filter( + "digest", + |raw: String, len: Option, alg: Option| -> Result { + let len = len.unwrap_or(8); + let alg = alg.unwrap_or_else(|| "sha256".to_string()); + compute_digest(Utf8Path::new(&raw), len, &alg) + }, + ); +} + +pub(super) fn file_type_matches(path: &Utf8Path, predicate: F) -> Result +where + F: Fn(fs::FileType) -> bool, +{ + let (dir, name, _) = match parent_dir(path) { + Ok(parts) => parts, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(io_to_error(path, "open directory", err)), + }; + match dir.symlink_metadata(Utf8Path::new(&name)) { + Ok(md) => Ok(predicate(md.file_type())), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(io_to_error(path, "stat", err)), + } +} + +fn normalise_parent(parent: Option<&Utf8Path>) -> Utf8PathBuf { + parent + .filter(|p| !p.as_str().is_empty()) + .map_or_else(|| Utf8PathBuf::from("."), Utf8Path::to_path_buf) +} + +fn dir_and_basename(path: &Utf8Path) -> (Utf8PathBuf, String) { + let dir = normalise_parent(path.parent()); + let name = path.file_name().map_or_else(|| ".".into(), str::to_string); + (dir, name) +} + +fn basename(path: &Utf8Path) -> String { + path.file_name().unwrap_or(path.as_str()).to_string() +} + +fn parent_dir(path: &Utf8Path) -> Result<(Dir, String, Utf8PathBuf), io::Error> { + let (dir_path, name) = dir_and_basename(path); + let dir = Dir::open_ambient_dir(&dir_path, ambient_authority())?; + Ok((dir, name, dir_path)) +} + +fn open_parent_dir(path: &Utf8Path) -> Result<(Dir, String, Utf8PathBuf), Error> { + parent_dir(path).map_err(|err| io_to_error(path, "open directory", err)) +} + +fn is_root(path: &Utf8Path) -> bool { + path.parent().is_none() && path.file_name().is_none() && !path.as_str().is_empty() +} + +fn current_dir_utf8() -> Result { + let cwd = env::current_dir()?; + Utf8PathBuf::from_path_buf(cwd) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "current dir is not valid UTF-8")) +} + +fn canonicalize_any(path: &Utf8Path) -> Result { + if path.as_str().is_empty() || path == Utf8Path::new(".") { + return current_dir_utf8() + .map_err(|err| io_to_error(Utf8Path::new("."), "canonicalise", err)); + } + if is_root(path) { + return Ok(path.to_path_buf()); + } + let (dir, name, dir_path) = open_parent_dir(path)?; + let canonical_child = dir + .canonicalize(Utf8Path::new(&name)) + .map_err(|err| io_to_error(path, "canonicalise", err))?; + if name == "." { + return canonicalize_any(&dir_path); + } + let mut parent = if dir_path.as_str() == "." { + current_dir_utf8().map_err(|err| io_to_error(Utf8Path::new("."), "canonicalise", err))? + } else { + canonicalize_any(&dir_path)? + }; + parent.push(&canonical_child); + Ok(parent) +} + +fn dirname(path: &Utf8Path) -> String { + normalise_parent(path.parent()).into_string() +} + +fn with_suffix( + path: &Utf8Path, + suffix: &str, + count: usize, + sep: &str, +) -> Result { + if sep.is_empty() { + return Err(Error::new( + ErrorKind::InvalidOperation, + "with_suffix requires a non-empty separator", + )); + } + let mut base = path.to_path_buf(); + let name = base.file_name().map(str::to_owned).unwrap_or_default(); + if !name.is_empty() { + base.pop(); + } + let mut stem = name; + let mut removed = 0; + while removed < count { + if let Some(idx) = stem.rfind(sep) { + stem.truncate(idx); + removed += 1; + } else { + break; + } + } + stem.push_str(suffix); + let replacement = Utf8PathBuf::from(stem); + base.push(&replacement); + Ok(base) +} + +fn relative_to(path: &Utf8Path, root: &Utf8Path) -> Result { + path.strip_prefix(root) + .map(|p| p.as_str().to_string()) + .map_err(|_| { + Error::new( + ErrorKind::InvalidOperation, + format!("{path} is not relative to {root}"), + ) + }) +} + +fn is_user_specific_expansion(stripped: &str) -> bool { + matches!( + stripped.chars().next(), + Some(first) if first != '/' && first != std::path::MAIN_SEPARATOR + ) +} + +fn resolve_home() -> Result { + home_from_env().ok_or_else(|| { + Error::new( + ErrorKind::InvalidOperation, + "cannot expand ~: no home directory environment variables are set", + ) + }) +} + +fn expanduser(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix('~') { + if is_user_specific_expansion(stripped) { + return Err(Error::new( + ErrorKind::InvalidOperation, + "user-specific ~ expansion is unsupported", + )); + } + let home = resolve_home()?; + Ok(format!("{home}{stripped}")) + } else { + Ok(raw.to_string()) + } +} + +#[cfg(windows)] +fn home_from_env() -> Option { + env::var("HOME") + .or_else(|_| env::var("USERPROFILE")) + .ok() + .or_else( + || match (env::var("HOMEDRIVE").ok(), env::var("HOMEPATH").ok()) { + (Some(drive), Some(path)) if !path.is_empty() => Some(format!("{drive}{path}")), + _ => env::var("HOMESHARE").ok(), + }, + ) +} + +#[cfg(not(windows))] +fn home_from_env() -> Option { + env::var("HOME").or_else(|_| env::var("USERPROFILE")).ok() +} + +fn file_size(path: &Utf8Path) -> Result { + let (dir, name, _) = open_parent_dir(path)?; + dir.metadata(Utf8Path::new(&name)) + .map(|meta| meta.len()) + .map_err(|err| io_to_error(path, "stat", err)) +} + +fn read_text(path: &Utf8Path, encoding: &str) -> Result { + let encoding = encoding.to_ascii_lowercase(); + if encoding != "utf-8" && encoding != "utf8" { + return Err(Error::new( + ErrorKind::InvalidOperation, + format!("unsupported encoding '{encoding}'"), + )); + } + let (dir, name, _) = open_parent_dir(path)?; + dir.read_to_string(Utf8Path::new(&name)) + .map_err(|err| io_to_error(path, "read", err)) +} + +fn linecount(path: &Utf8Path) -> Result { + let text = read_text(path, "utf-8")?; + Ok(text.lines().count()) +} + +fn compute_hash(path: &Utf8Path, alg: &str) -> Result { + match alg.to_ascii_lowercase().as_str() { + "sha256" => hash_stream::(path), + "sha512" => hash_stream::(path), + "sha1" => { + #[cfg(feature = "legacy-digests")] + { + hash_stream::(path) + } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'sha1' (enable feature 'legacy-digests')" + .to_string(), + )) + } + } + "md5" => { + #[cfg(feature = "legacy-digests")] + { + hash_stream::(path) + } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'md5' (enable feature 'legacy-digests')" + .to_string(), + )) + } + } + other => Err(Error::new( + ErrorKind::InvalidOperation, + format!("unsupported hash algorithm '{other}'"), + )), + } +} + +fn hash_stream(path: &Utf8Path) -> Result +where + H: Digest, +{ + let (dir, name, _) = open_parent_dir(path)?; + let mut opts = fs::OpenOptions::new(); + opts.read(true); + let mut file = dir + .open_with(Utf8Path::new(&name), &opts) + .map_err(|err| io_to_error(path, "open file", err))?; + let mut hasher = H::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file + .read(&mut buffer) + .map_err(|err| io_to_error(path, "read", err))?; + if read == 0 { + break; + } + let chunk = buffer.get(..read).unwrap_or(&[]); + hasher.update(chunk); + } + let digest = hasher.finalize(); + Ok(encode_hex(digest.as_slice())) +} + +fn compute_digest(path: &Utf8Path, len: usize, alg: &str) -> Result { + let mut hash = compute_hash(path, alg)?; + if len < hash.len() { + hash.truncate(len); + } + Ok(hash) +} + +fn encode_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(&mut out, "{b:02x}"); + } + out +} diff --git a/src/stdlib/path/io_helpers.rs b/src/stdlib/path/io_helpers.rs new file mode 100644 index 00000000..d7d9f1d1 --- /dev/null +++ b/src/stdlib/path/io_helpers.rs @@ -0,0 +1,59 @@ +use std::io::{self, ErrorKind as IoErrorKind}; + +use camino::Utf8Path; +use minijinja::{Error, ErrorKind}; + +pub(super) fn io_to_error(path: &Utf8Path, action: &str, err: io::Error) -> Error { + let io_kind = err.kind(); + let label = io_error_kind_label(io_kind); + let detail = err.to_string(); + + let message = if detail.is_empty() { + format!("{action} failed for {path}: {label} [{io_kind:?}]") + } else if detail.to_ascii_lowercase().contains(label) { + format!("{action} failed for {path}: {detail} [{io_kind:?}]") + } else { + format!("{action} failed for {path}: {label} [{io_kind:?}] ({detail})") + }; + + Error::new(ErrorKind::InvalidOperation, message).with_source(err) +} + +fn io_error_kind_label(kind: IoErrorKind) -> &'static str { + match kind { + IoErrorKind::NotFound => "not found", + IoErrorKind::PermissionDenied => "permission denied", + IoErrorKind::AlreadyExists => "already exists", + IoErrorKind::InvalidInput => "invalid input", + IoErrorKind::InvalidData => "invalid data", + IoErrorKind::TimedOut => "timed out", + IoErrorKind::Interrupted => "interrupted", + IoErrorKind::WouldBlock => "operation would block", + IoErrorKind::WriteZero => "write zero", + IoErrorKind::UnexpectedEof => "unexpected end of file", + IoErrorKind::BrokenPipe => "broken pipe", + IoErrorKind::ConnectionRefused => "connection refused", + IoErrorKind::ConnectionReset => "connection reset", + IoErrorKind::ConnectionAborted => "connection aborted", + IoErrorKind::NotConnected => "not connected", + IoErrorKind::AddrInUse => "address in use", + IoErrorKind::AddrNotAvailable => "address not available", + IoErrorKind::OutOfMemory => "out of memory", + IoErrorKind::Unsupported => "unsupported operation", + IoErrorKind::FileTooLarge => "file too large", + IoErrorKind::ResourceBusy => "resource busy", + IoErrorKind::ExecutableFileBusy => "executable busy", + IoErrorKind::Deadlock => "deadlock", + IoErrorKind::CrossesDevices => "cross-device link", + IoErrorKind::TooManyLinks => "too many links", + IoErrorKind::InvalidFilename => "invalid filename", + IoErrorKind::ArgumentListTooLong => "argument list too long", + IoErrorKind::StaleNetworkFileHandle => "stale network file handle", + IoErrorKind::StorageFull => "storage full", + IoErrorKind::NotSeekable => "not seekable", + IoErrorKind::NetworkDown => "network down", + IoErrorKind::NetworkUnreachable => "network unreachable", + IoErrorKind::HostUnreachable => "host unreachable", + _ => "io error", + } +} diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs new file mode 100644 index 00000000..7698b268 --- /dev/null +++ b/tests/std_filter_tests.rs @@ -0,0 +1,420 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, ErrorKind, context}; +use netsuke::stdlib; +use rstest::{fixture, rstest}; +use tempfile::tempdir; +use test_support::{EnvVarGuard, env_lock::EnvLock}; + +#[fixture] +fn filter_workspace() -> (tempfile::TempDir, Utf8PathBuf) { + let temp = tempdir().expect("tempdir"); + let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8"); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); + dir.write("file", b"data").expect("file"); + #[cfg(unix)] + dir.symlink("file", "link").expect("symlink"); + #[cfg(not(unix))] + dir.write("link", b"data").expect("link copy"); + dir.write("lines.txt", b"one\ntwo\nthree\n").expect("lines"); + (temp, root) +} + +fn render<'a>( + env: &mut Environment<'a>, + name: &'a str, + template: &'a str, + path: &Utf8PathBuf, +) -> String { + env.add_template(name, template).expect("template"); + env.get_template(name) + .expect("get template") + .render(context!(path => path.as_str())) + .expect("render") +} + +fn register_template( + env: &mut Environment<'_>, + name: impl Into, + source: impl Into, +) { + let leaked_name = Box::leak(name.into().into_boxed_str()); + let leaked_source = Box::leak(source.into().into_boxed_str()); + env.add_template(leaked_name, leaked_source) + .expect("template"); +} + +#[rstest] +fn basename_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render(&mut env, "basename", "{{ path | basename }}", &file); + assert_eq!(output, "file"); +} + +#[rstest] +fn dirname_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render(&mut env, "dirname", "{{ path | dirname }}", &file); + assert_eq!(output, root.as_str()); +} + +#[rstest] +fn with_suffix_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file.tar.gz"); + Dir::open_ambient_dir(&root, ambient_authority()) + .expect("dir") + .write("file.tar.gz", b"data") + .expect("write"); + let first = render( + &mut env, + "suffix", + "{{ path | with_suffix('.log') }}", + &file, + ); + assert_eq!(first, root.join("file.tar.log").as_str()); + let second = render( + &mut env, + "suffix_alt", + "{{ path | with_suffix('.zip', 2) }}", + &file, + ); + assert_eq!(second, root.join("file.zip").as_str()); +} + +#[rstest] +fn with_suffix_filter_without_separator(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render( + &mut env, + "suffix_plain", + "{{ path | with_suffix('.log') }}", + &file, + ); + assert_eq!(output, root.join("file.log").as_str()); +} + +#[rstest] +fn with_suffix_filter_empty_separator(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template( + "suffix_empty_sep", + "{{ path | with_suffix('.log', 1, '') }}", + ) + .expect("template"); + let template = env.get_template("suffix_empty_sep").expect("get template"); + let file = root.join("file.tar.gz"); + let result = template.render(context!(path => file.as_str())); + let err = result.expect_err("with_suffix should reject empty separator"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("non-empty separator"), + "error should mention separator requirement", + ); +} + +#[rstest] +fn with_suffix_filter_excessive_count(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file.tar.gz"); + let output = render( + &mut env, + "suffix_excessive", + "{{ path | with_suffix('.bak', 5) }}", + &file, + ); + assert_eq!(output, root.join("file.bak").as_str()); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let link = root.join("link"); + let output = render(&mut env, "realpath", "{{ path | realpath }}", &link); + assert_eq!(output, root.join("file").as_str()); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter_missing_path(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template("realpath_missing", "{{ path | realpath }}") + .expect("template"); + let template = env.get_template("realpath_missing").expect("get template"); + let missing = root.join("missing"); + let result = template.render(context!(path => missing.as_str())); + let err = result.expect_err("realpath should error for missing path"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("not found"), + "error should mention missing path", + ); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter_root_path(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let root_path = root + .ancestors() + .find(|candidate| candidate.parent().is_none()) + .map(Utf8Path::to_path_buf) + .expect("root ancestor"); + assert!( + !root_path.as_str().is_empty(), + "root path should not be empty", + ); + let output = render( + &mut env, + "realpath_root", + "{{ path | realpath }}", + &root_path, + ); + assert_eq!(output, root_path.as_str()); +} + +#[rstest] +fn contents_and_linecount_filters(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let text = render(&mut env, "contents", "{{ path | contents }}", &file); + assert_eq!(text, "data"); + let lines = render( + &mut env, + "linecount", + "{{ path | linecount }}", + &root.join("lines.txt"), + ); + assert_eq!(lines.parse::().expect("usize"), 3); +} + +#[rstest] +fn contents_filter_unsupported_encoding(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template("contents_bad_encoding", "{{ path | contents('latin-1') }}") + .expect("template"); + let template = env + .get_template("contents_bad_encoding") + .expect("get template"); + let file = root.join("file"); + let result = template.render(context!(path => file.as_str())); + let err = result.expect_err("contents should error on unsupported encoding"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("unsupported encoding"), + "error should mention unsupported encoding", + ); +} + +#[rstest] +#[case( + "sha256", + "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7", + "3a6eb079" +)] +#[case( + "sha512", + "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876", + "77c7ce9a" +)] +#[cfg_attr( + feature = "legacy-digests", + case("sha1", "a17c9aaa61e80a1bf71d0d850af4e5baa9800bbd", "a17c9aaa",) +)] +#[cfg_attr( + feature = "legacy-digests", + case("md5", "8d777f385d3dfec8815d20f7496026dc", "8d777f38",) +)] +fn hash_and_digest_filters( + filter_workspace: (tempfile::TempDir, Utf8PathBuf), + #[case] alg: &str, + #[case] expected_hash: &str, + #[case] expected_digest: &str, +) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + + let hash_template_name = format!("hash_{alg}"); + let hash_template = format!("{{{{ path | hash('{alg}') }}}}"); + register_template(&mut env, hash_template_name.as_str(), hash_template); + let hash_result = env + .get_template(hash_template_name.as_str()) + .expect("get template") + .render(context!(path => file.as_str())) + .expect("render hash"); + assert_eq!(hash_result, expected_hash); + + let digest_template_name = format!("digest_{alg}"); + let digest_template = format!("{{{{ path | digest(8, '{alg}') }}}}"); + register_template(&mut env, digest_template_name.as_str(), digest_template); + let digest_result = env + .get_template(digest_template_name.as_str()) + .expect("get template") + .render(context!(path => file.as_str())) + .expect("render digest"); + assert_eq!(digest_result, expected_digest); +} + +#[cfg(not(feature = "legacy-digests"))] +#[rstest] +fn hash_filter_legacy_algorithms_disabled(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + + register_template(&mut env, "hash_sha1", "{{ path | hash('sha1') }}"); + let template = env.get_template("hash_sha1").expect("get template"); + let result = template.render(context!(path => root.join("file").as_str())); + let err = result.expect_err("hash should require the legacy-digests feature for sha1"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("enable feature 'legacy-digests'"), + "error should mention legacy feature: {err}", + ); +} + +#[rstest] +fn hash_filter_rejects_unknown_algorithm(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + + register_template(&mut env, "hash_unknown", "{{ path | hash('whirlpool') }}"); + let hash_template = env.get_template("hash_unknown").expect("get template"); + let hash_result = hash_template.render(context!(path => file.as_str())); + let hash_err = hash_result.expect_err("hash should reject unsupported algorithms"); + assert_eq!(hash_err.kind(), ErrorKind::InvalidOperation); + assert!( + hash_err + .to_string() + .contains("unsupported hash algorithm 'whirlpool'"), + "error should mention unsupported algorithm: {hash_err}", + ); + + register_template( + &mut env, + "digest_unknown", + "{{ path | digest(8, 'whirlpool') }}", + ); + let digest_template = env.get_template("digest_unknown").expect("get template"); + let digest_result = digest_template.render(context!(path => file.as_str())); + let digest_err = digest_result.expect_err("digest should reject unsupported algorithms"); + assert_eq!(digest_err.kind(), ErrorKind::InvalidOperation); + assert!( + digest_err + .to_string() + .contains("unsupported hash algorithm 'whirlpool'"), + "error should mention unsupported algorithm: {digest_err}", + ); +} + +#[rstest] +fn size_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let size = render(&mut env, "size", "{{ path | size }}", &file); + assert_eq!(size.parse::().expect("u64"), 4); +} + +#[rstest] +fn expanduser_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let _lock = EnvLock::acquire(); + let _home_guard = EnvVarGuard::set("HOME", root.as_str()); + let _profile_guard = EnvVarGuard::remove("USERPROFILE"); + let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let _path_guard = EnvVarGuard::remove("HOMEPATH"); + let _share_guard = EnvVarGuard::remove("HOMESHARE"); + let home = render( + &mut env, + "expanduser", + "{{ path | expanduser }}", + &Utf8PathBuf::from("~/workspace"), + ); + assert_eq!(home, root.join("workspace").as_str()); +} + +#[rstest] +fn expanduser_filter_missing_home(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, _root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let _lock = EnvLock::acquire(); + let _home_guard = EnvVarGuard::remove("HOME"); + let _profile_guard = EnvVarGuard::remove("USERPROFILE"); + let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let _path_guard = EnvVarGuard::remove("HOMEPATH"); + let _share_guard = EnvVarGuard::remove("HOMESHARE"); + env.add_template("expanduser_missing_home", "{{ path | expanduser }}") + .expect("template"); + let template = env + .get_template("expanduser_missing_home") + .expect("get template"); + let result = template.render(context!(path => "~/workspace")); + let err = result.expect_err("expanduser should error when HOME is unset"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string() + .contains("no home directory environment variables are set"), + "error should mention missing HOME", + ); +} + +#[rstest] +fn expanduser_filter_user_specific(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let _lock = EnvLock::acquire(); + let _home_guard = EnvVarGuard::set("HOME", root.as_str()); + let _profile_guard = EnvVarGuard::remove("USERPROFILE"); + let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let _path_guard = EnvVarGuard::remove("HOMEPATH"); + let _share_guard = EnvVarGuard::remove("HOMESHARE"); + env.add_template("expanduser_user_specific", "{{ path | expanduser }}") + .expect("template"); + let template = env + .get_template("expanduser_user_specific") + .expect("get template"); + let result = template.render(context!(path => "~otheruser/workspace")); + let err = result.expect_err("expanduser should reject ~user expansion"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string() + .contains("user-specific ~ expansion is unsupported"), + "error should mention unsupported user expansion", + ); +} From da959f7d6f971109c615efcab0b18de3e76015c0 Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 01:57:23 +0100 Subject: [PATCH 02/21] Add stdlib filter edge case tests --- tests/std_filter_tests.rs | 54 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index 7698b268..a9ce4107 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -88,6 +88,13 @@ fn with_suffix_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { &file, ); assert_eq!(second, root.join("file.zip").as_str()); + let third = render( + &mut env, + "suffix_count_zero", + "{{ path | with_suffix('.bak', 0) }}", + &file, + ); + assert_eq!(third, root.join("file.tar.gz.bak").as_str()); } #[rstest] @@ -210,6 +217,19 @@ fn contents_and_linecount_filters(filter_workspace: (tempfile::TempDir, Utf8Path &root.join("lines.txt"), ); assert_eq!(lines.parse::().expect("usize"), 3); + + Dir::open_ambient_dir(&root, ambient_authority()) + .expect("dir") + .write("empty.txt", b"") + .expect("empty file"); + let empty_file = root.join("empty.txt"); + let empty_lines = render( + &mut env, + "empty_linecount", + "{{ path | linecount }}", + &empty_file, + ); + assert_eq!(empty_lines.parse::().expect("usize"), 0); } #[rstest] @@ -243,14 +263,32 @@ fn contents_filter_unsupported_encoding(filter_workspace: (tempfile::TempDir, Ut "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876", "77c7ce9a" )] +#[case( + "sha256-empty", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "e3b0c442" +)] +#[case( + "sha512-empty", + "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", + "cf83e135" +)] #[cfg_attr( feature = "legacy-digests", case("sha1", "a17c9aaa61e80a1bf71d0d850af4e5baa9800bbd", "a17c9aaa",) )] +#[cfg_attr( + feature = "legacy-digests", + case("sha1-empty", "da39a3ee5e6b4b0d3255bfef95601890afd80709", "da39a3ee",) +)] #[cfg_attr( feature = "legacy-digests", case("md5", "8d777f385d3dfec8815d20f7496026dc", "8d777f38",) )] +#[cfg_attr( + feature = "legacy-digests", + case("md5-empty", "d41d8cd98f00b204e9800998ecf8427e", "d41d8cd9",) +)] fn hash_and_digest_filters( filter_workspace: (tempfile::TempDir, Utf8PathBuf), #[case] alg: &str, @@ -260,10 +298,20 @@ fn hash_and_digest_filters( let (_temp, root) = filter_workspace; let mut env = Environment::new(); stdlib::register(&mut env); - let file = root.join("file"); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); + + let (file, algorithm) = alg.strip_suffix("-empty").map_or_else( + || (root.join("file"), alg), + |stripped| { + let relative = format!("{stripped}_empty"); + dir.write(relative.as_str(), b"") + .expect("create empty file"); + (root.join(relative.as_str()), stripped) + }, + ); let hash_template_name = format!("hash_{alg}"); - let hash_template = format!("{{{{ path | hash('{alg}') }}}}"); + let hash_template = format!("{{{{ path | hash('{algorithm}') }}}}"); register_template(&mut env, hash_template_name.as_str(), hash_template); let hash_result = env .get_template(hash_template_name.as_str()) @@ -273,7 +321,7 @@ fn hash_and_digest_filters( assert_eq!(hash_result, expected_hash); let digest_template_name = format!("digest_{alg}"); - let digest_template = format!("{{{{ path | digest(8, '{alg}') }}}}"); + let digest_template = format!("{{{{ path | digest(8, '{algorithm}') }}}}"); register_template(&mut env, digest_template_name.as_str(), digest_template); let digest_result = env .get_template(digest_template_name.as_str()) From ccf067546412c2f7d6639e023270845d3f9816f5 Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 01:57:29 +0100 Subject: [PATCH 03/21] Remove legacy path module --- src/ir/graph.rs | 8 + src/stdlib/path.rs | 359 ---------------------------------- src/stdlib/path/filters.rs | 72 +++++++ src/stdlib/path/fs_utils.rs | 79 ++++++++ src/stdlib/path/hash_utils.rs | 86 ++++++++ src/stdlib/path/io_helpers.rs | 33 ++++ src/stdlib/path/mod.rs | 8 + src/stdlib/path/path_utils.rs | 151 ++++++++++++++ tests/cucumber.rs | 7 + tests/features/stdlib.feature | 10 + tests/std_filter_tests.rs | 32 +++ tests/steps/mod.rs | 1 + tests/steps/stdlib_steps.rs | 84 ++++++++ 13 files changed, 571 insertions(+), 359 deletions(-) delete mode 100644 src/stdlib/path.rs create mode 100644 src/stdlib/path/filters.rs create mode 100644 src/stdlib/path/fs_utils.rs create mode 100644 src/stdlib/path/hash_utils.rs create mode 100644 src/stdlib/path/mod.rs create mode 100644 src/stdlib/path/path_utils.rs create mode 100644 tests/features/stdlib.feature create mode 100644 tests/steps/stdlib_steps.rs diff --git a/src/ir/graph.rs b/src/ir/graph.rs index b9fa8ffd..eec46936 100644 --- a/src/ir/graph.rs +++ b/src/ir/graph.rs @@ -62,6 +62,7 @@ pub struct BuildEdge { /// /// ``` /// use netsuke::ir::IrGenError; +/// use serde::ser::Error as _; /// /// fn describe(err: IrGenError) -> String { /// match err { @@ -86,6 +87,7 @@ pub enum IrGenError { /// /// ``` /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let err = IrGenError::RuleNotFound { /// target_name: "app".into(), @@ -107,6 +109,7 @@ pub enum IrGenError { /// /// ``` /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let err = IrGenError::MultipleRules { /// target_name: "lib".into(), @@ -129,6 +132,7 @@ pub enum IrGenError { /// /// ``` /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let err = IrGenError::EmptyRule { target_name: "docs".into() }; /// assert_eq!( @@ -143,6 +147,7 @@ pub enum IrGenError { /// /// ``` /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let err = IrGenError::DuplicateOutput { /// outputs: vec!["obj.o".into()], @@ -162,6 +167,7 @@ pub enum IrGenError { /// ``` /// use camino::Utf8PathBuf; /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let err = IrGenError::CircularDependency { /// cycle: vec![Utf8PathBuf::from("a"), Utf8PathBuf::from("a")], @@ -184,6 +190,7 @@ pub enum IrGenError { /// /// ``` /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let source = serde_json::Error::custom("invalid action"); /// let err = IrGenError::ActionSerialisation(source); @@ -196,6 +203,7 @@ pub enum IrGenError { /// /// ``` /// use netsuke::ir::IrGenError; + /// use serde::ser::Error as _; /// /// let err = IrGenError::InvalidCommand { /// command: "echo $in".into(), diff --git a/src/stdlib/path.rs b/src/stdlib/path.rs deleted file mode 100644 index e8e92eb6..00000000 --- a/src/stdlib/path.rs +++ /dev/null @@ -1,359 +0,0 @@ -use std::{ - env, - fmt::Write as FmtWrite, - io::{self, Read}, -}; - -use camino::{Utf8Path, Utf8PathBuf}; -use cap_std::{ambient_authority, fs, fs_utf8::Dir}; -use digest::Digest; -#[cfg(feature = "legacy-digests")] -use md5::Md5; -use minijinja::{Environment, Error, ErrorKind}; - -mod io_helpers; - -use io_helpers::io_to_error; -#[cfg(feature = "legacy-digests")] -use sha1::Sha1; -use sha2::{Sha256, Sha512}; - -pub(super) fn register_filters(env: &mut Environment<'_>) { - env.add_filter("basename", |raw: String| -> Result { - Ok(basename(Utf8Path::new(&raw))) - }); - env.add_filter("dirname", |raw: String| -> Result { - Ok(dirname(Utf8Path::new(&raw))) - }); - env.add_filter( - "with_suffix", - |raw: String, - suffix: String, - count: Option, - sep: Option| - -> Result { - let count = count.unwrap_or(1); - let sep = sep.unwrap_or_else(|| ".".to_string()); - with_suffix(Utf8Path::new(&raw), &suffix, count, &sep).map(Utf8PathBuf::into_string) - }, - ); - env.add_filter( - "relative_to", - |raw: String, root: String| -> Result { - relative_to(Utf8Path::new(&raw), Utf8Path::new(&root)) - }, - ); - env.add_filter("realpath", |raw: String| -> Result { - canonicalize_any(Utf8Path::new(&raw)).map(Utf8PathBuf::into_string) - }); - env.add_filter("expanduser", |raw: String| -> Result { - expanduser(&raw) - }); - env.add_filter("size", |raw: String| -> Result { - file_size(Utf8Path::new(&raw)) - }); - env.add_filter( - "contents", - |raw: String, encoding: Option| -> Result { - let encoding = encoding.unwrap_or_else(|| "utf-8".to_string()); - read_text(Utf8Path::new(&raw), &encoding) - }, - ); - env.add_filter("linecount", |raw: String| -> Result { - linecount(Utf8Path::new(&raw)) - }); - env.add_filter( - "hash", - |raw: String, alg: Option| -> Result { - let alg = alg.unwrap_or_else(|| "sha256".to_string()); - compute_hash(Utf8Path::new(&raw), &alg) - }, - ); - env.add_filter( - "digest", - |raw: String, len: Option, alg: Option| -> Result { - let len = len.unwrap_or(8); - let alg = alg.unwrap_or_else(|| "sha256".to_string()); - compute_digest(Utf8Path::new(&raw), len, &alg) - }, - ); -} - -pub(super) fn file_type_matches(path: &Utf8Path, predicate: F) -> Result -where - F: Fn(fs::FileType) -> bool, -{ - let (dir, name, _) = match parent_dir(path) { - Ok(parts) => parts, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), - Err(err) => return Err(io_to_error(path, "open directory", err)), - }; - match dir.symlink_metadata(Utf8Path::new(&name)) { - Ok(md) => Ok(predicate(md.file_type())), - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(io_to_error(path, "stat", err)), - } -} - -fn normalise_parent(parent: Option<&Utf8Path>) -> Utf8PathBuf { - parent - .filter(|p| !p.as_str().is_empty()) - .map_or_else(|| Utf8PathBuf::from("."), Utf8Path::to_path_buf) -} - -fn dir_and_basename(path: &Utf8Path) -> (Utf8PathBuf, String) { - let dir = normalise_parent(path.parent()); - let name = path.file_name().map_or_else(|| ".".into(), str::to_string); - (dir, name) -} - -fn basename(path: &Utf8Path) -> String { - path.file_name().unwrap_or(path.as_str()).to_string() -} - -fn parent_dir(path: &Utf8Path) -> Result<(Dir, String, Utf8PathBuf), io::Error> { - let (dir_path, name) = dir_and_basename(path); - let dir = Dir::open_ambient_dir(&dir_path, ambient_authority())?; - Ok((dir, name, dir_path)) -} - -fn open_parent_dir(path: &Utf8Path) -> Result<(Dir, String, Utf8PathBuf), Error> { - parent_dir(path).map_err(|err| io_to_error(path, "open directory", err)) -} - -fn is_root(path: &Utf8Path) -> bool { - path.parent().is_none() && path.file_name().is_none() && !path.as_str().is_empty() -} - -fn current_dir_utf8() -> Result { - let cwd = env::current_dir()?; - Utf8PathBuf::from_path_buf(cwd) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "current dir is not valid UTF-8")) -} - -fn canonicalize_any(path: &Utf8Path) -> Result { - if path.as_str().is_empty() || path == Utf8Path::new(".") { - return current_dir_utf8() - .map_err(|err| io_to_error(Utf8Path::new("."), "canonicalise", err)); - } - if is_root(path) { - return Ok(path.to_path_buf()); - } - let (dir, name, dir_path) = open_parent_dir(path)?; - let canonical_child = dir - .canonicalize(Utf8Path::new(&name)) - .map_err(|err| io_to_error(path, "canonicalise", err))?; - if name == "." { - return canonicalize_any(&dir_path); - } - let mut parent = if dir_path.as_str() == "." { - current_dir_utf8().map_err(|err| io_to_error(Utf8Path::new("."), "canonicalise", err))? - } else { - canonicalize_any(&dir_path)? - }; - parent.push(&canonical_child); - Ok(parent) -} - -fn dirname(path: &Utf8Path) -> String { - normalise_parent(path.parent()).into_string() -} - -fn with_suffix( - path: &Utf8Path, - suffix: &str, - count: usize, - sep: &str, -) -> Result { - if sep.is_empty() { - return Err(Error::new( - ErrorKind::InvalidOperation, - "with_suffix requires a non-empty separator", - )); - } - let mut base = path.to_path_buf(); - let name = base.file_name().map(str::to_owned).unwrap_or_default(); - if !name.is_empty() { - base.pop(); - } - let mut stem = name; - let mut removed = 0; - while removed < count { - if let Some(idx) = stem.rfind(sep) { - stem.truncate(idx); - removed += 1; - } else { - break; - } - } - stem.push_str(suffix); - let replacement = Utf8PathBuf::from(stem); - base.push(&replacement); - Ok(base) -} - -fn relative_to(path: &Utf8Path, root: &Utf8Path) -> Result { - path.strip_prefix(root) - .map(|p| p.as_str().to_string()) - .map_err(|_| { - Error::new( - ErrorKind::InvalidOperation, - format!("{path} is not relative to {root}"), - ) - }) -} - -fn is_user_specific_expansion(stripped: &str) -> bool { - matches!( - stripped.chars().next(), - Some(first) if first != '/' && first != std::path::MAIN_SEPARATOR - ) -} - -fn resolve_home() -> Result { - home_from_env().ok_or_else(|| { - Error::new( - ErrorKind::InvalidOperation, - "cannot expand ~: no home directory environment variables are set", - ) - }) -} - -fn expanduser(raw: &str) -> Result { - if let Some(stripped) = raw.strip_prefix('~') { - if is_user_specific_expansion(stripped) { - return Err(Error::new( - ErrorKind::InvalidOperation, - "user-specific ~ expansion is unsupported", - )); - } - let home = resolve_home()?; - Ok(format!("{home}{stripped}")) - } else { - Ok(raw.to_string()) - } -} - -#[cfg(windows)] -fn home_from_env() -> Option { - env::var("HOME") - .or_else(|_| env::var("USERPROFILE")) - .ok() - .or_else( - || match (env::var("HOMEDRIVE").ok(), env::var("HOMEPATH").ok()) { - (Some(drive), Some(path)) if !path.is_empty() => Some(format!("{drive}{path}")), - _ => env::var("HOMESHARE").ok(), - }, - ) -} - -#[cfg(not(windows))] -fn home_from_env() -> Option { - env::var("HOME").or_else(|_| env::var("USERPROFILE")).ok() -} - -fn file_size(path: &Utf8Path) -> Result { - let (dir, name, _) = open_parent_dir(path)?; - dir.metadata(Utf8Path::new(&name)) - .map(|meta| meta.len()) - .map_err(|err| io_to_error(path, "stat", err)) -} - -fn read_text(path: &Utf8Path, encoding: &str) -> Result { - let encoding = encoding.to_ascii_lowercase(); - if encoding != "utf-8" && encoding != "utf8" { - return Err(Error::new( - ErrorKind::InvalidOperation, - format!("unsupported encoding '{encoding}'"), - )); - } - let (dir, name, _) = open_parent_dir(path)?; - dir.read_to_string(Utf8Path::new(&name)) - .map_err(|err| io_to_error(path, "read", err)) -} - -fn linecount(path: &Utf8Path) -> Result { - let text = read_text(path, "utf-8")?; - Ok(text.lines().count()) -} - -fn compute_hash(path: &Utf8Path, alg: &str) -> Result { - match alg.to_ascii_lowercase().as_str() { - "sha256" => hash_stream::(path), - "sha512" => hash_stream::(path), - "sha1" => { - #[cfg(feature = "legacy-digests")] - { - hash_stream::(path) - } - #[cfg(not(feature = "legacy-digests"))] - { - Err(Error::new( - ErrorKind::InvalidOperation, - "unsupported hash algorithm 'sha1' (enable feature 'legacy-digests')" - .to_string(), - )) - } - } - "md5" => { - #[cfg(feature = "legacy-digests")] - { - hash_stream::(path) - } - #[cfg(not(feature = "legacy-digests"))] - { - Err(Error::new( - ErrorKind::InvalidOperation, - "unsupported hash algorithm 'md5' (enable feature 'legacy-digests')" - .to_string(), - )) - } - } - other => Err(Error::new( - ErrorKind::InvalidOperation, - format!("unsupported hash algorithm '{other}'"), - )), - } -} - -fn hash_stream(path: &Utf8Path) -> Result -where - H: Digest, -{ - let (dir, name, _) = open_parent_dir(path)?; - let mut opts = fs::OpenOptions::new(); - opts.read(true); - let mut file = dir - .open_with(Utf8Path::new(&name), &opts) - .map_err(|err| io_to_error(path, "open file", err))?; - let mut hasher = H::new(); - let mut buffer = [0_u8; 8192]; - loop { - let read = file - .read(&mut buffer) - .map_err(|err| io_to_error(path, "read", err))?; - if read == 0 { - break; - } - let chunk = buffer.get(..read).unwrap_or(&[]); - hasher.update(chunk); - } - let digest = hasher.finalize(); - Ok(encode_hex(digest.as_slice())) -} - -fn compute_digest(path: &Utf8Path, len: usize, alg: &str) -> Result { - let mut hash = compute_hash(path, alg)?; - if len < hash.len() { - hash.truncate(len); - } - Ok(hash) -} - -fn encode_hex(bytes: &[u8]) -> String { - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(&mut out, "{b:02x}"); - } - out -} diff --git a/src/stdlib/path/filters.rs b/src/stdlib/path/filters.rs new file mode 100644 index 00000000..f5d02fdd --- /dev/null +++ b/src/stdlib/path/filters.rs @@ -0,0 +1,72 @@ +use camino::Utf8Path; +use minijinja::{Environment, Error, ErrorKind}; + +use super::{fs_utils, hash_utils, path_utils}; + +pub(crate) fn register_filters(env: &mut Environment<'_>) { + env.add_filter("basename", |raw: String| -> Result { + Ok(path_utils::basename(Utf8Path::new(&raw))) + }); + env.add_filter("dirname", |raw: String| -> Result { + Ok(path_utils::dirname(Utf8Path::new(&raw))) + }); + env.add_filter( + "with_suffix", + |raw: String, + suffix: String, + count: Option, + sep: Option| + -> Result { + let count = count.unwrap_or(1); + let sep = sep.unwrap_or_else(|| ".".to_string()); + path_utils::with_suffix(Utf8Path::new(&raw), &suffix, count, &sep) + .map(camino::Utf8PathBuf::into_string) + }, + ); + env.add_filter( + "relative_to", + |raw: String, root: String| -> Result { + path_utils::relative_to(Utf8Path::new(&raw), Utf8Path::new(&root)) + }, + ); + env.add_filter("realpath", |raw: String| -> Result { + path_utils::canonicalize_any(Utf8Path::new(&raw)).map(camino::Utf8PathBuf::into_string) + }); + env.add_filter("expanduser", |raw: String| -> Result { + path_utils::expanduser(&raw) + }); + env.add_filter("size", |raw: String| -> Result { + fs_utils::file_size(Utf8Path::new(&raw)) + }); + env.add_filter( + "contents", + |raw: String, encoding: Option| -> Result { + let encoding = encoding.unwrap_or_else(|| "utf-8".to_string()); + match encoding.to_ascii_lowercase().as_str() { + "utf-8" | "utf8" => fs_utils::read_utf8(Utf8Path::new(&raw)), + other => Err(Error::new( + ErrorKind::InvalidOperation, + format!("unsupported encoding '{other}'"), + )), + } + }, + ); + env.add_filter("linecount", |raw: String| -> Result { + fs_utils::linecount(Utf8Path::new(&raw)) + }); + env.add_filter( + "hash", + |raw: String, alg: Option| -> Result { + let alg = alg.unwrap_or_else(|| "sha256".to_string()); + hash_utils::compute_hash(Utf8Path::new(&raw), &alg) + }, + ); + env.add_filter( + "digest", + |raw: String, len: Option, alg: Option| -> Result { + let len = len.unwrap_or(8); + let alg = alg.unwrap_or_else(|| "sha256".to_string()); + hash_utils::compute_digest(Utf8Path::new(&raw), len, &alg) + }, + ); +} diff --git a/src/stdlib/path/fs_utils.rs b/src/stdlib/path/fs_utils.rs new file mode 100644 index 00000000..481d576f --- /dev/null +++ b/src/stdlib/path/fs_utils.rs @@ -0,0 +1,79 @@ +use std::io; + +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ + ambient_authority, fs, + fs_utf8::{Dir, File, OpenOptions}, +}; +use minijinja::Error; + +use super::io_helpers::io_to_error; +use super::path_utils::normalise_parent; + +pub(super) struct ParentDir { + pub handle: Dir, + pub entry: String, + pub dir_path: Utf8PathBuf, +} + +pub(super) fn parent_dir(path: &Utf8Path) -> Result { + let dir_path = normalise_parent(path.parent()); + let handle = Dir::open_ambient_dir(&dir_path, ambient_authority())?; + let entry = path.file_name().map_or_else(|| ".".into(), str::to_owned); + Ok(ParentDir { + handle, + entry, + dir_path, + }) +} + +pub(super) fn open_parent_dir(path: &Utf8Path) -> Result { + parent_dir(path).map_err(|err| io_to_error(path, "open directory", err)) +} + +pub(crate) fn file_type_matches(path: &Utf8Path, predicate: F) -> Result +where + F: Fn(fs::FileType) -> bool, +{ + match parent_dir(path) { + Ok(parent) => match parent.handle.symlink_metadata(Utf8Path::new(&parent.entry)) { + Ok(metadata) => Ok(predicate(metadata.file_type())), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(io_to_error(path, "stat", err)), + }, + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(io_to_error(path, "open directory", err)), + } +} + +pub(super) fn file_size(path: &Utf8Path) -> Result { + let parent = open_parent_dir(path)?; + parent + .handle + .metadata(Utf8Path::new(&parent.entry)) + .map(|metadata| metadata.len()) + .map_err(|err| io_to_error(path, "stat", err)) +} + +pub(super) fn read_utf8(path: &Utf8Path) -> Result { + let parent = open_parent_dir(path)?; + parent + .handle + .read_to_string(Utf8Path::new(&parent.entry)) + .map_err(|err| io_to_error(path, "read", err)) +} + +pub(super) fn linecount(path: &Utf8Path) -> Result { + let content = read_utf8(path)?; + Ok(content.lines().count()) +} + +pub(crate) fn open_file(path: &Utf8Path) -> Result { + let parent = open_parent_dir(path)?; + let mut options = OpenOptions::new(); + options.read(true); + parent + .handle + .open_with(Utf8Path::new(&parent.entry), &options) + .map_err(|err| io_to_error(path, "open file", err)) +} diff --git a/src/stdlib/path/hash_utils.rs b/src/stdlib/path/hash_utils.rs new file mode 100644 index 00000000..cc4b0cc5 --- /dev/null +++ b/src/stdlib/path/hash_utils.rs @@ -0,0 +1,86 @@ +use std::io::Read; + +use camino::Utf8Path; +use digest::Digest; +#[cfg(feature = "legacy-digests")] +use md5::Md5; +use minijinja::{Error, ErrorKind}; +#[cfg(feature = "legacy-digests")] +use sha1::Sha1; +use sha2::{Sha256, Sha512}; + +use super::{fs_utils, io_helpers::io_to_error}; + +pub(super) fn compute_hash(path: &Utf8Path, alg: &str) -> Result { + match alg.to_ascii_lowercase().as_str() { + "sha256" => hash_stream::(path), + "sha512" => hash_stream::(path), + "sha1" => { + #[cfg(feature = "legacy-digests")] + { + hash_stream::(path) + } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'sha1' (enable feature legacy-digests)", + )) + } + } + "md5" => { + #[cfg(feature = "legacy-digests")] + { + hash_stream::(path) + } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'md5' (enable feature legacy-digests)", + )) + } + } + other => Err(Error::new( + ErrorKind::InvalidOperation, + format!("unsupported hash algorithm '{other}'"), + )), + } +} + +pub(super) fn compute_digest(path: &Utf8Path, len: usize, alg: &str) -> Result { + let mut hash = compute_hash(path, alg)?; + if len < hash.len() { + hash.truncate(len); + } + Ok(hash) +} + +fn hash_stream(path: &Utf8Path) -> Result +where + H: Digest, +{ + let mut file = fs_utils::open_file(path)?; + let mut hasher = H::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file + .read(&mut buffer) + .map_err(|err| io_to_error(path, "read", err))?; + if read == 0 { + break; + } + let chunk = buffer.get(..read).expect("read beyond buffer capacity"); + hasher.update(chunk); + } + Ok(encode_hex(&hasher.finalize())) +} + +fn encode_hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(&mut out, "{byte:02x}"); + } + out +} diff --git a/src/stdlib/path/io_helpers.rs b/src/stdlib/path/io_helpers.rs index d7d9f1d1..09e828d6 100644 --- a/src/stdlib/path/io_helpers.rs +++ b/src/stdlib/path/io_helpers.rs @@ -57,3 +57,36 @@ fn io_error_kind_label(kind: IoErrorKind) -> &'static str { _ => "io error", } } + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8PathBuf; + use rstest::rstest; + + #[rstest] + #[case(io::Error::new(io::ErrorKind::NotFound, ""), "not found")] + #[case( + io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"), + "permission denied" + )] + #[case( + io::Error::new(io::ErrorKind::UnexpectedEof, "unexpected end of file"), + "unexpected end of file" + )] + fn io_to_error_includes_label(#[case] err: io::Error, #[case] expected_label: &str) { + let path = Utf8PathBuf::from("/tmp/example"); + let error = io_to_error(path.as_path(), "read", err); + assert_eq!(error.kind(), ErrorKind::InvalidOperation); + let text = error.to_string(); + assert!(text.contains("read failed for /tmp/example")); + assert!(text.contains(expected_label)); + } + + #[rstest] + #[case(io::ErrorKind::AddrInUse, "address in use")] + #[case(io::ErrorKind::Other, "io error")] + fn io_error_kind_label_matches_expected(#[case] kind: io::ErrorKind, #[case] expected: &str) { + assert_eq!(io_error_kind_label(kind), expected); + } +} diff --git a/src/stdlib/path/mod.rs b/src/stdlib/path/mod.rs new file mode 100644 index 00000000..d8cd5672 --- /dev/null +++ b/src/stdlib/path/mod.rs @@ -0,0 +1,8 @@ +mod filters; +mod fs_utils; +mod hash_utils; +mod io_helpers; +mod path_utils; + +pub(crate) use filters::register_filters; +pub(crate) use fs_utils::file_type_matches; diff --git a/src/stdlib/path/path_utils.rs b/src/stdlib/path/path_utils.rs new file mode 100644 index 00000000..6acfa93b --- /dev/null +++ b/src/stdlib/path/path_utils.rs @@ -0,0 +1,151 @@ +use std::{env, io}; + +use camino::{Utf8Path, Utf8PathBuf}; +use minijinja::{Error, ErrorKind}; + +use super::fs_utils::{ParentDir, open_parent_dir}; +use super::io_helpers::io_to_error; + +pub(super) fn basename(path: &Utf8Path) -> String { + path.file_name().unwrap_or(path.as_str()).to_string() +} + +pub(super) fn dirname(path: &Utf8Path) -> String { + normalise_parent(path.parent()).into_string() +} + +pub(super) fn with_suffix( + path: &Utf8Path, + suffix: &str, + count: usize, + sep: &str, +) -> Result { + if sep.is_empty() { + return Err(Error::new( + ErrorKind::InvalidOperation, + "with_suffix requires a non-empty separator", + )); + } + let mut base = path.to_path_buf(); + let name = base.file_name().map(str::to_owned).unwrap_or_default(); + if !name.is_empty() { + base.pop(); + } + let mut stem = name; + let mut removed = 0; + while removed < count { + if let Some(idx) = stem.rfind(sep) { + stem.truncate(idx); + removed += 1; + } else { + break; + } + } + stem.push_str(suffix); + let replacement = Utf8PathBuf::from(stem); + base.push(&replacement); + Ok(base) +} + +pub(super) fn relative_to(path: &Utf8Path, root: &Utf8Path) -> Result { + path.strip_prefix(root) + .map(|p| p.as_str().to_string()) + .map_err(|_| { + Error::new( + ErrorKind::InvalidOperation, + format!("{path} is not relative to {root}"), + ) + }) +} + +pub(super) fn canonicalize_any(path: &Utf8Path) -> Result { + if path.as_str().is_empty() || path == Utf8Path::new(".") { + return current_dir_utf8() + .map_err(|err| io_to_error(Utf8Path::new("."), "canonicalise", err)); + } + if is_root(path) { + return Ok(path.to_path_buf()); + } + let ParentDir { + handle, + entry, + dir_path, + } = open_parent_dir(path)?; + handle + .canonicalize(Utf8Path::new(&entry)) + .map(|resolved| { + if resolved.is_absolute() { + resolved + } else { + let mut absolute = dir_path; + absolute.push(&resolved); + absolute + } + }) + .map_err(|err| io_to_error(path, "canonicalise", err)) +} + +pub(super) fn expanduser(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix('~') { + if is_user_specific_expansion(stripped) { + return Err(Error::new( + ErrorKind::InvalidOperation, + "user-specific ~ expansion is unsupported", + )); + } + let home = resolve_home()?; + Ok(format!("{home}{stripped}")) + } else { + Ok(raw.to_string()) + } +} + +pub(super) fn is_user_specific_expansion(stripped: &str) -> bool { + matches!( + stripped.chars().next(), + Some(first) if first != '/' && first != std::path::MAIN_SEPARATOR + ) +} + +pub(super) fn normalise_parent(parent: Option<&Utf8Path>) -> Utf8PathBuf { + parent + .filter(|p| !p.as_str().is_empty()) + .map_or_else(|| Utf8PathBuf::from("."), Utf8Path::to_path_buf) +} + +fn resolve_home() -> Result { + home_from_env().ok_or_else(|| { + Error::new( + ErrorKind::InvalidOperation, + "cannot expand ~: no home directory environment variables are set", + ) + }) +} + +fn is_root(path: &Utf8Path) -> bool { + path.parent().is_none() && path.file_name().is_none() && !path.as_str().is_empty() +} + +fn current_dir_utf8() -> Result { + let cwd = env::current_dir()?; + Utf8PathBuf::from_path_buf(cwd) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "current dir is not valid UTF-8")) +} + +#[cfg(windows)] +fn home_from_env() -> Option { + env::var("HOME") + .or_else(|_| env::var("USERPROFILE")) + .ok() + .or_else( + || match (env::var("HOMEDRIVE").ok(), env::var("HOMEPATH").ok()) { + (Some(drive), Some(path)) if !path.is_empty() => Some(format!("{drive}{path}")), + _ => env::var("HOMESHARE").ok(), + }, + ) +} + +#[cfg(not(windows))] +fn home_from_env() -> Option { + env::var("HOME").or_else(|_| env::var("USERPROFILE")).ok() +} diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 74a1aaa9..8e98985a 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -1,5 +1,6 @@ //! Cucumber test runner and world state. +use camino::Utf8PathBuf; use cucumber::World; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; @@ -29,6 +30,12 @@ pub struct CliWorld { pub temp: Option, /// Guard that restores `PATH` after each scenario. pub path_guard: Option, + /// Root directory for stdlib scenarios. + pub stdlib_root: Option, + /// Captured output from the last stdlib render. + pub stdlib_output: Option, + /// Error from the last stdlib render. + pub stdlib_error: Option, /// Snapshot of pre-scenario values for environment variables that were overridden. /// Stores the original value (`Some`) or `None` if the variable was previously unset. pub env_vars: HashMap>, diff --git a/tests/features/stdlib.feature b/tests/features/stdlib.feature new file mode 100644 index 00000000..e6530291 --- /dev/null +++ b/tests/features/stdlib.feature @@ -0,0 +1,10 @@ +Feature: Template stdlib filters + Scenario: Rendering basename for a file path + Given a stdlib workspace + When I render "{{ path | basename }}" with stdlib path "file" + Then the stdlib output is "file" + + Scenario: Size filter reports errors for missing files + Given a stdlib workspace + When I render "{{ path | size }}" with stdlib path "missing" + Then the stdlib error contains "not found" diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index a9ce4107..74fb9113 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -395,6 +395,23 @@ fn size_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { assert_eq!(size.parse::().expect("u64"), 4); } +#[rstest] +fn size_filter_missing_file(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + register_template(&mut env, "size_missing", "{{ path | size }}"); + let missing = root.join("does_not_exist"); + let template = env.get_template("size_missing").expect("get template"); + let result = template.render(context!(path => missing.as_str())); + let err = result.expect_err("size should error for missing file"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("does_not_exist") || err.to_string().contains("not found"), + "error should mention missing file: {err}", + ); +} + #[rstest] fn expanduser_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { let (_temp, root) = filter_workspace; @@ -415,6 +432,21 @@ fn expanduser_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { assert_eq!(home, root.join("workspace").as_str()); } +#[rstest] +fn expanduser_filter_non_tilde_path(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render( + &mut env, + "expanduser_plain", + "{{ path | expanduser }}", + &file, + ); + assert_eq!(output, file.as_str()); +} + #[rstest] fn expanduser_filter_missing_home(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { let (_temp, _root) = filter_workspace; diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 1577c07a..1a90b67d 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -10,3 +10,4 @@ mod ir_steps; mod manifest_steps; mod ninja_steps; mod process_steps; +mod stdlib_steps; diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs new file mode 100644 index 00000000..58ca7fba --- /dev/null +++ b/tests/steps/stdlib_steps.rs @@ -0,0 +1,84 @@ +use crate::CliWorld; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use cucumber::{given, then, when}; +use minijinja::{Environment, context}; +use netsuke::stdlib; + +fn ensure_workspace(world: &mut CliWorld) -> Utf8PathBuf { + if let Some(root) = &world.stdlib_root { + return root.clone(); + } + let temp = tempfile::tempdir().expect("create stdlib workspace"); + let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8"); + let handle = Dir::open_ambient_dir(&root, ambient_authority()).expect("open workspace"); + handle.write("file", b"data").expect("write file"); + world.temp = Some(temp); + world.stdlib_root = Some(root.clone()); + root +} + +fn render_template(world: &mut CliWorld, template: &str, path: &Utf8Path) { + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template("scenario", template) + .expect("add template"); + let render = env + .get_template("scenario") + .expect("get template") + .render(context!(path => path.as_str())); + match render { + Ok(output) => { + world.stdlib_output = Some(output); + world.stdlib_error = None; + } + Err(err) => { + world.stdlib_output = None; + world.stdlib_error = Some(err.to_string()); + } + } +} + +#[given("a stdlib workspace")] +fn stdlib_workspace(world: &mut CliWorld) { + let root = ensure_workspace(world); + // record root to reuse in subsequent steps + world.stdlib_root = Some(root); +} + +#[when(regex = r#"^I render "(.+)" with stdlib path "(.+)"$"#)] +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +fn render_stdlib_template(world: &mut CliWorld, template: String, path: String) { + let root = ensure_workspace(world); + let target = root.join(path); + render_template(world, &template, &target); +} + +#[then(regex = r#"^the stdlib output is "(.+)"$"#)] +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +fn assert_stdlib_output(world: &mut CliWorld, expected: String) { + let output = world + .stdlib_output + .as_ref() + .expect("expected stdlib output"); + assert_eq!(output, &expected); +} + +#[then(regex = r#"^the stdlib error contains "(.+)"$"#)] +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +fn assert_stdlib_error(world: &mut CliWorld, fragment: String) { + let error = world.stdlib_error.as_ref().expect("expected stdlib error"); + assert!( + error.contains(&fragment), + "error `{error}` should contain `{fragment}`", + ); +} From c2f3f8f0dedb246ec0659e29cef78847bc6101b2 Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 09:28:19 +0100 Subject: [PATCH 04/21] Add stdlib behavioural coverage --- src/stdlib/path/path_utils.rs | 14 ++-- tests/features/stdlib.feature | 43 +++++++++++- tests/features_unix/stdlib.feature | 6 ++ tests/std_filter_tests.rs | 41 ++++++++++++ tests/steps/stdlib_steps.rs | 101 ++++++++++++++++++++++++++--- 5 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 tests/features_unix/stdlib.feature diff --git a/src/stdlib/path/path_utils.rs b/src/stdlib/path/path_utils.rs index 6acfa93b..7b5547af 100644 --- a/src/stdlib/path/path_utils.rs +++ b/src/stdlib/path/path_utils.rs @@ -85,6 +85,13 @@ pub(super) fn canonicalize_any(path: &Utf8Path) -> Result { .map_err(|err| io_to_error(path, "canonicalise", err)) } +pub(super) fn is_user_specific_expansion(stripped: &str) -> bool { + matches!( + stripped.chars().next(), + Some(first) if first != '/' && first != std::path::MAIN_SEPARATOR + ) +} + pub(super) fn expanduser(raw: &str) -> Result { if let Some(stripped) = raw.strip_prefix('~') { if is_user_specific_expansion(stripped) { @@ -100,13 +107,6 @@ pub(super) fn expanduser(raw: &str) -> Result { } } -pub(super) fn is_user_specific_expansion(stripped: &str) -> bool { - matches!( - stripped.chars().next(), - Some(first) if first != '/' && first != std::path::MAIN_SEPARATOR - ) -} - pub(super) fn normalise_parent(parent: Option<&Utf8Path>) -> Utf8PathBuf { parent .filter(|p| !p.as_str().is_empty()) diff --git a/tests/features/stdlib.feature b/tests/features/stdlib.feature index e6530291..fc748e07 100644 --- a/tests/features/stdlib.feature +++ b/tests/features/stdlib.feature @@ -1,10 +1,49 @@ Feature: Template stdlib filters - Scenario: Rendering basename for a file path + Background: Given a stdlib workspace + + Scenario: Rendering basename for a file path When I render "{{ path | basename }}" with stdlib path "file" Then the stdlib output is "file" + Scenario: Dirname resolves to the workspace root + When I render "{{ path | dirname }}" with stdlib path "file" + Then the stdlib output equals the workspace root + + Scenario: relative_to returns the child component + Given the stdlib file "nested/file.txt" contains "nested" + When I render "{{ path | relative_to(path | dirname) }}" with stdlib path "nested/file.txt" + Then the stdlib output is "file.txt" + + Scenario: with_suffix rewrites extensions + When I render "{{ path | with_suffix('.log') }}" with stdlib path "file.tar.gz" + Then the stdlib output is the workspace path "file.tar.log" + + Scenario: expanduser expands the home directory + Given HOME points to the stdlib workspace root + When I render "{{ path | expanduser }}" with stdlib path "~/workspace" + Then the stdlib output is the workspace path "workspace" + + Scenario: contents reads a file + When I render "{{ path | contents }}" with stdlib path "file" + Then the stdlib output is "data" + + Scenario: linecount counts newline-delimited lines + When I render "{{ path | linecount }}" with stdlib path "lines.txt" + Then the stdlib output is "3" + + Scenario: size returns the byte length + When I render "{{ path | size }}" with stdlib path "file" + Then the stdlib output is "4" + Scenario: Size filter reports errors for missing files - Given a stdlib workspace When I render "{{ path | size }}" with stdlib path "missing" Then the stdlib error contains "not found" + + Scenario: hash computes the sha256 digest + When I render "{{ path | hash('sha256') }}" with stdlib path "file" + Then the stdlib output is "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7" + + Scenario: digest truncates the hash output + When I render "{{ path | digest(8, 'sha256') }}" with stdlib path "file" + Then the stdlib output is "3a6eb079" diff --git a/tests/features_unix/stdlib.feature b/tests/features_unix/stdlib.feature new file mode 100644 index 00000000..2bad24f1 --- /dev/null +++ b/tests/features_unix/stdlib.feature @@ -0,0 +1,6 @@ +@unix +Feature: Unix stdlib filters + Scenario: realpath resolves symlinks + Given a stdlib workspace + When I render "{{ path | realpath }}" with stdlib path "link" + Then the stdlib output is the workspace path "file" diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index 74fb9113..a2597639 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -64,6 +64,47 @@ fn dirname_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { assert_eq!(output, root.as_str()); } +#[rstest] +fn relative_to_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); + dir.create_dir_all("nested").expect("create nested dir"); + dir.write("nested/file.txt", b"data") + .expect("write nested file"); + let nested = root.join("nested/file.txt"); + let output = render( + &mut env, + "relative_to", + "{{ path | relative_to(path | dirname) }}", + &nested, + ); + assert_eq!(output, "file.txt"); +} + +#[rstest] +fn relative_to_filter_outside_root(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + register_template( + &mut env, + "relative_to_fail", + "{{ path | relative_to(root) }}", + ); + let template = env.get_template("relative_to_fail").expect("get template"); + let file = root.join("file"); + let other_root = root.join("other"); + let result = template.render(context!(path => file.as_str(), root => other_root.as_str())); + let err = result.expect_err("relative_to should reject unrelated paths"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("is not relative"), + "error should mention missing relationship: {err}" + ); +} + #[rstest] fn with_suffix_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { let (_temp, root) = filter_workspace; diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index 58ca7fba..e340c268 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -4,6 +4,15 @@ use cap_std::{ambient_authority, fs_utf8::Dir}; use cucumber::{given, then, when}; use minijinja::{Environment, context}; use netsuke::stdlib; +use std::ffi::OsStr; +use test_support::env::set_var; + +const LINES_FIXTURE: &str = concat!( + "one +", "two +", "three +" +); fn ensure_workspace(world: &mut CliWorld) -> Utf8PathBuf { if let Some(root) = &world.stdlib_root { @@ -13,6 +22,13 @@ fn ensure_workspace(world: &mut CliWorld) -> Utf8PathBuf { let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8"); let handle = Dir::open_ambient_dir(&root, ambient_authority()).expect("open workspace"); handle.write("file", b"data").expect("write file"); + handle + .write("lines.txt", LINES_FIXTURE.as_bytes()) + .expect("write lines fixture"); + #[cfg(unix)] + handle.symlink("file", "link").expect("create symlink"); + #[cfg(not(unix))] + handle.write("link", b"data").expect("write link fixture"); world.temp = Some(temp); world.stdlib_root = Some(root.clone()); root @@ -21,12 +37,7 @@ fn ensure_workspace(world: &mut CliWorld) -> Utf8PathBuf { fn render_template(world: &mut CliWorld, template: &str, path: &Utf8Path) { let mut env = Environment::new(); stdlib::register(&mut env); - env.add_template("scenario", template) - .expect("add template"); - let render = env - .get_template("scenario") - .expect("get template") - .render(context!(path => path.as_str())); + let render = env.render_str(template, context!(path => path.as_str())); match render { Ok(output) => { world.stdlib_output = Some(output); @@ -42,19 +53,60 @@ fn render_template(world: &mut CliWorld, template: &str, path: &Utf8Path) { #[given("a stdlib workspace")] fn stdlib_workspace(world: &mut CliWorld) { let root = ensure_workspace(world); - // record root to reuse in subsequent steps world.stdlib_root = Some(root); } -#[when(regex = r#"^I render "(.+)" with stdlib path "(.+)"$"#)] #[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] +#[given(regex = r#"^the stdlib file "(.+)" contains "(.+)"$"#)] +fn write_stdlib_file(world: &mut CliWorld, path: String, contents: String) { + let root = ensure_workspace(world); + let handle = Dir::open_ambient_dir(&root, ambient_authority()).expect("open workspace"); + let relative = Utf8Path::new(&path); + if let Some(parent) = relative.parent().filter(|p| !p.as_str().is_empty()) { + handle + .create_dir_all(parent) + .expect("create fixture directories"); + } + handle + .write(relative, contents.as_bytes()) + .expect("write stdlib file"); +} + +#[given("HOME points to the stdlib workspace root")] +fn home_points_to_stdlib_root(world: &mut CliWorld) { + let root = ensure_workspace(world); + let os_root = OsStr::new(root.as_str()); + let previous = set_var("HOME", os_root); + world.env_vars.entry("HOME".into()).or_insert(previous); + #[cfg(windows)] + { + let previous = set_var("USERPROFILE", os_root); + world + .env_vars + .entry("USERPROFILE".into()) + .or_insert(previous); + } + world.stdlib_root = Some(root); +} + +#[when(regex = r#"^I render "(.+)" with stdlib path "(.+)"$"#)] fn render_stdlib_template(world: &mut CliWorld, template: String, path: String) { let root = ensure_workspace(world); - let target = root.join(path); + let is_home_expansion = path.starts_with('~'); + let is_absolute = Utf8Path::new(&path).is_absolute(); + let target = if is_home_expansion || is_absolute { + Utf8PathBuf::from(path) + } else { + root.join(Utf8Path::new(&path)) + }; render_template(world, &template, &target); + // Cucumber supplies owned Strings, but we only need a borrowed view. + // Explicitly dropping satisfies clippy::needless_pass_by_value while making + // the ownership intent clear. + drop(template); } #[then(regex = r#"^the stdlib output is "(.+)"$"#)] @@ -82,3 +134,34 @@ fn assert_stdlib_error(world: &mut CliWorld, fragment: String) { "error `{error}` should contain `{fragment}`", ); } + +#[then("the stdlib output equals the workspace root")] +fn assert_stdlib_output_is_root(world: &mut CliWorld) { + let root = world + .stdlib_root + .as_ref() + .expect("expected stdlib workspace root"); + let output = world + .stdlib_output + .as_ref() + .expect("expected stdlib output"); + assert_eq!(output, root.as_str()); +} + +#[then(regex = r#"^the stdlib output is the workspace path "(.+)"$"#)] +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: String) { + let root = world + .stdlib_root + .as_ref() + .expect("expected stdlib workspace root"); + let output = world + .stdlib_output + .as_ref() + .expect("expected stdlib output"); + let expected = root.join(&relative); + assert_eq!(output, expected.as_str()); +} From 334b0601963ec84ad77b4db02e6a8678a0f2d33e Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 15:33:09 +0100 Subject: [PATCH 05/21] Quote legacy feature hint in hash errors --- src/stdlib/path/hash_utils.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stdlib/path/hash_utils.rs b/src/stdlib/path/hash_utils.rs index cc4b0cc5..faeb3620 100644 --- a/src/stdlib/path/hash_utils.rs +++ b/src/stdlib/path/hash_utils.rs @@ -24,7 +24,7 @@ pub(super) fn compute_hash(path: &Utf8Path, alg: &str) -> Result { Err(Error::new( ErrorKind::InvalidOperation, - "unsupported hash algorithm 'sha1' (enable feature legacy-digests)", + "unsupported hash algorithm 'sha1' (enable feature 'legacy-digests')", )) } } @@ -37,7 +37,7 @@ pub(super) fn compute_hash(path: &Utf8Path, alg: &str) -> Result { Err(Error::new( ErrorKind::InvalidOperation, - "unsupported hash algorithm 'md5' (enable feature legacy-digests)", + "unsupported hash algorithm 'md5' (enable feature 'legacy-digests')", )) } } From 301f271108b347b2138a53a1d9505c4ba30b8fd7 Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 20:02:52 +0100 Subject: [PATCH 06/21] Document stdlib path modules and use cap-std Add module-level docs for the path coordinator and the\nunderlying utilities to satisfy review feedback, and resolve\ncurrent_dir_utf8 via cap-std ambient directories. Document the\nCucumber step module for consistency with the project guidance. --- src/stdlib/path/mod.rs | 4 ++++ src/stdlib/path/path_utils.rs | 11 ++++++++--- tests/steps/stdlib_steps.rs | 4 ++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/stdlib/path/mod.rs b/src/stdlib/path/mod.rs index d8cd5672..e3781db2 100644 --- a/src/stdlib/path/mod.rs +++ b/src/stdlib/path/mod.rs @@ -1,3 +1,7 @@ +//! Entry point for stdlib path and file utilities. +//! +//! Wires up path and file filters and re-exports crate-private helpers for +//! registration from the stdlib coordinator. mod filters; mod fs_utils; mod hash_utils; diff --git a/src/stdlib/path/path_utils.rs b/src/stdlib/path/path_utils.rs index 7b5547af..6668275f 100644 --- a/src/stdlib/path/path_utils.rs +++ b/src/stdlib/path/path_utils.rs @@ -1,5 +1,11 @@ +//! Path utilities backing stdlib filters for UTF-8 paths: basename and dirname, +//! suffix rewriting, relative path resolution, canonical paths, and expanduser +//! with Windows HOME fallbacks using cap-std directories and consistent +//! template error mapping. use std::{env, io}; +use cap_std::{ambient_authority, fs_utf8::Dir}; + use camino::{Utf8Path, Utf8PathBuf}; use minijinja::{Error, ErrorKind}; @@ -127,9 +133,8 @@ fn is_root(path: &Utf8Path) -> bool { } fn current_dir_utf8() -> Result { - let cwd = env::current_dir()?; - Utf8PathBuf::from_path_buf(cwd) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "current dir is not valid UTF-8")) + let dir = Dir::open_ambient_dir(".", ambient_authority())?; + dir.canonicalize(Utf8Path::new(".")) } #[cfg(windows)] diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index e340c268..0d405879 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -1,3 +1,7 @@ +//! Cucumber step implementations for stdlib path and file filters. +//! +//! Sets up a temporary workspace, renders templates with stdlib registered, +//! and asserts outputs and errors. use crate::CliWorld; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; From ad28d8670d59200c2fc389bd520a759b1ff5b864 Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 20:03:01 +0100 Subject: [PATCH 07/21] Align camino version Set the root Cargo.toml dependency to match the existing 1.2.0 caret requirement so all manifests resolve the same release. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6300874e..c25e1382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] } serde_yml = "0.0.12" minijinja = "2.11.0" cap-std = { version = "3.4.4", features = ["fs_utf8"] } -camino = "1.1.12" +camino = "1.2.0" semver = { version = "1", features = ["serde"] } anyhow = "1" thiserror = "1" From 39f998e4ca8f20ddf8c7595292eaf25a3644edbd Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 26 Sep 2025 20:27:28 +0100 Subject: [PATCH 08/21] Document stdlib filters and tighten tests --- Cargo.toml | 8 +++--- src/stdlib/path/io_helpers.rs | 1 + src/stdlib/path/path_utils.rs | 7 +++-- tests/std_filter_tests.rs | 51 ++++++++++++++++++++--------------- tests/steps/stdlib_steps.rs | 31 +++++++++++---------- 5 files changed, 53 insertions(+), 45 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c25e1382..de89194f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,10 +27,10 @@ semver = { version = "1", features = ["serde"] } anyhow = "1" thiserror = "1" miette = { version = "7.6.0", features = ["fancy"] } -digest = "0.10" -sha2 = "0.10" -sha1 = { version = "0.10", optional = true } -md5 = { package = "md-5", version = "0.10", optional = true } +digest = "^0.10" +sha2 = "^0.10" +sha1 = { version = "^0.10", optional = true } +md5 = { package = "md-5", version = "^0.10", optional = true } itoa = "1" itertools = "0.12" glob = "0.3.3" diff --git a/src/stdlib/path/io_helpers.rs b/src/stdlib/path/io_helpers.rs index 09e828d6..032b2536 100644 --- a/src/stdlib/path/io_helpers.rs +++ b/src/stdlib/path/io_helpers.rs @@ -1,3 +1,4 @@ +//! IO error adapters for the stdlib path filters: translate `io::Error` into `minijinja::Error`. use std::io::{self, ErrorKind as IoErrorKind}; use camino::Utf8Path; diff --git a/src/stdlib/path/path_utils.rs b/src/stdlib/path/path_utils.rs index 6668275f..ffa10178 100644 --- a/src/stdlib/path/path_utils.rs +++ b/src/stdlib/path/path_utils.rs @@ -1,7 +1,6 @@ -//! Path utilities backing stdlib filters for UTF-8 paths: basename and dirname, -//! suffix rewriting, relative path resolution, canonical paths, and expanduser -//! with Windows HOME fallbacks using cap-std directories and consistent -//! template error mapping. +//! Path utilities backing stdlib filters for UTF-8 paths: basename/dirname, `with_suffix`, +//! `relative_to`, canonicalise/realpath, and expanduser with Windows HOME fallbacks. Uses cap-std +//! directory handles and consistent error mapping for template errors. use std::{env, io}; use cap_std::{ambient_authority, fs_utf8::Dir}; diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index a2597639..63d1b0b0 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -3,9 +3,39 @@ use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, ErrorKind, context}; use netsuke::stdlib; use rstest::{fixture, rstest}; +use std::cell::RefCell; use tempfile::tempdir; use test_support::{EnvVarGuard, env_lock::EnvLock}; +thread_local! { + static TEMPLATE_STORAGE: RefCell, Box)>> = const { RefCell::new(Vec::new()) }; +} + +fn register_template( + env: &mut Environment<'_>, + name: impl Into, + source: impl Into, +) { + TEMPLATE_STORAGE.with(|storage| { + let (name_ptr, source_ptr) = { + let mut storage = storage.borrow_mut(); + storage.push((name.into().into_boxed_str(), source.into().into_boxed_str())); + let (name, source) = storage.last().expect("template storage entry"); + ( + std::ptr::from_ref(name.as_ref()), + std::ptr::from_ref(source.as_ref()), + ) + }; + // SAFETY: the pointers originate from boxed strings stored in the + // thread-local registry. They remain valid for the duration of the + // process, so treating them as `'static` references is sound. + unsafe { + env.add_template(&*name_ptr, &*source_ptr) + .expect("template"); + } + }); +} + #[fixture] fn filter_workspace() -> (tempfile::TempDir, Utf8PathBuf) { let temp = tempdir().expect("tempdir"); @@ -33,27 +63,6 @@ fn render<'a>( .expect("render") } -fn register_template( - env: &mut Environment<'_>, - name: impl Into, - source: impl Into, -) { - let leaked_name = Box::leak(name.into().into_boxed_str()); - let leaked_source = Box::leak(source.into().into_boxed_str()); - env.add_template(leaked_name, leaked_source) - .expect("template"); -} - -#[rstest] -fn basename_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - let output = render(&mut env, "basename", "{{ path | basename }}", &file); - assert_eq!(output, "file"); -} - #[rstest] fn dirname_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { let (_temp, root) = filter_workspace; diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index 0d405879..0f28e6a6 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -126,6 +126,19 @@ fn assert_stdlib_output(world: &mut CliWorld, expected: String) { assert_eq!(output, &expected); } +fn stdlib_root_and_output(world: &CliWorld) -> (&Utf8Path, &str) { + let root = world + .stdlib_root + .as_ref() + .expect("expected stdlib workspace root") + .as_path(); + let output = world + .stdlib_output + .as_deref() + .expect("expected stdlib output"); + (root, output) +} + #[then(regex = r#"^the stdlib error contains "(.+)"$"#)] #[expect( clippy::needless_pass_by_value, @@ -141,14 +154,7 @@ fn assert_stdlib_error(world: &mut CliWorld, fragment: String) { #[then("the stdlib output equals the workspace root")] fn assert_stdlib_output_is_root(world: &mut CliWorld) { - let root = world - .stdlib_root - .as_ref() - .expect("expected stdlib workspace root"); - let output = world - .stdlib_output - .as_ref() - .expect("expected stdlib output"); + let (root, output) = stdlib_root_and_output(world); assert_eq!(output, root.as_str()); } @@ -158,14 +164,7 @@ fn assert_stdlib_output_is_root(world: &mut CliWorld) { reason = "Cucumber requires owned String arguments" )] fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: String) { - let root = world - .stdlib_root - .as_ref() - .expect("expected stdlib workspace root"); - let output = world - .stdlib_output - .as_ref() - .expect("expected stdlib output"); + let (root, output) = stdlib_root_and_output(world); let expected = root.join(&relative); assert_eq!(output, expected.as_str()); } From cabb64bdc6dbe19bb06cc181886ea5dfbfd670cc Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 11:05:17 +0100 Subject: [PATCH 09/21] Document stdlib path helpers --- src/stdlib/path/filters.rs | 1 + src/stdlib/path/fs_utils.rs | 1 + src/stdlib/path/hash_utils.rs | 1 + tests/std_filter_tests.rs | 2 ++ 4 files changed, 5 insertions(+) diff --git a/src/stdlib/path/filters.rs b/src/stdlib/path/filters.rs index f5d02fdd..79caa75e 100644 --- a/src/stdlib/path/filters.rs +++ b/src/stdlib/path/filters.rs @@ -1,3 +1,4 @@ +//! Filter registration wiring for stdlib path helpers. use camino::Utf8Path; use minijinja::{Environment, Error, ErrorKind}; diff --git a/src/stdlib/path/fs_utils.rs b/src/stdlib/path/fs_utils.rs index 481d576f..1afcd79c 100644 --- a/src/stdlib/path/fs_utils.rs +++ b/src/stdlib/path/fs_utils.rs @@ -1,3 +1,4 @@ +//! File-system helpers for stdlib path filters: open dirs and files via cap-std. use std::io; use camino::{Utf8Path, Utf8PathBuf}; diff --git a/src/stdlib/path/hash_utils.rs b/src/stdlib/path/hash_utils.rs index faeb3620..c3b42bd0 100644 --- a/src/stdlib/path/hash_utils.rs +++ b/src/stdlib/path/hash_utils.rs @@ -1,3 +1,4 @@ +//! Hash utilities for stdlib path filters: stream digests with cap-std handles. use std::io::Read; use camino::Utf8Path; diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index 63d1b0b0..df4a3b0f 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -1,3 +1,5 @@ +//! Unit tests for the stdlib path and file filters. +//! Exercises success, failure, and edge cases via rstest fixtures. use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, ErrorKind, context}; From 0b0c03b9b834a35ad7462590698c961e1af806f1 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 11:36:25 +0100 Subject: [PATCH 10/21] Clarify stdlib IO helper docs --- src/stdlib/path/io_helpers.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/stdlib/path/io_helpers.rs b/src/stdlib/path/io_helpers.rs index 032b2536..d2f4c0eb 100644 --- a/src/stdlib/path/io_helpers.rs +++ b/src/stdlib/path/io_helpers.rs @@ -1,4 +1,5 @@ -//! IO error adapters for the stdlib path filters: translate `io::Error` into `minijinja::Error`. +//! IO error adapters for the stdlib path filters. +//! Translate `io::Error` into `minijinja::Error` diagnostics with human-readable labels. use std::io::{self, ErrorKind as IoErrorKind}; use camino::Utf8Path; From 1e5e974488b4dc42fce6b2365b0b98fbb260dbd5 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 12:06:43 +0100 Subject: [PATCH 11/21] Refine stdlib filter docs and steps Clarify the io error adapter module comment to call out MiniJinja invalid-operation diagnostics and rework the stdlib Cucumber steps to accept Utf8PathBuf arguments where appropriate, reducing the string-heavy parameter ratio flagged in review. --- src/stdlib/path/io_helpers.rs | 2 +- tests/steps/stdlib_steps.rs | 34 ++++++++++++++-------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/stdlib/path/io_helpers.rs b/src/stdlib/path/io_helpers.rs index d2f4c0eb..8e3e70f4 100644 --- a/src/stdlib/path/io_helpers.rs +++ b/src/stdlib/path/io_helpers.rs @@ -1,5 +1,5 @@ //! IO error adapters for the stdlib path filters. -//! Translate `io::Error` into `minijinja::Error` diagnostics with human-readable labels. +//! Convert `io::Error` values into `MiniJinja` `InvalidOperation` diagnostics with human-readable labels. use std::io::{self, ErrorKind as IoErrorKind}; use camino::Utf8Path; diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index 0f28e6a6..18772ac0 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -62,13 +62,13 @@ fn stdlib_workspace(world: &mut CliWorld) { #[expect( clippy::needless_pass_by_value, - reason = "Cucumber requires owned String arguments" + reason = "Cucumber requires owned capture arguments" )] #[given(regex = r#"^the stdlib file "(.+)" contains "(.+)"$"#)] -fn write_stdlib_file(world: &mut CliWorld, path: String, contents: String) { +fn write_stdlib_file(world: &mut CliWorld, path: Utf8PathBuf, contents: String) { let root = ensure_workspace(world); let handle = Dir::open_ambient_dir(&root, ambient_authority()).expect("open workspace"); - let relative = Utf8Path::new(&path); + let relative = path.as_path(); if let Some(parent) = relative.parent().filter(|p| !p.as_str().is_empty()) { handle .create_dir_all(parent) @@ -97,26 +97,24 @@ fn home_points_to_stdlib_root(world: &mut CliWorld) { } #[when(regex = r#"^I render "(.+)" with stdlib path "(.+)"$"#)] -fn render_stdlib_template(world: &mut CliWorld, template: String, path: String) { +fn render_stdlib_template(world: &mut CliWorld, template: String, path: Utf8PathBuf) { let root = ensure_workspace(world); - let is_home_expansion = path.starts_with('~'); - let is_absolute = Utf8Path::new(&path).is_absolute(); + let path_str = path.as_str(); + let is_home_expansion = path_str.starts_with('~'); + let is_absolute = path.is_absolute(); let target = if is_home_expansion || is_absolute { - Utf8PathBuf::from(path) + path } else { - root.join(Utf8Path::new(&path)) + root.join(path_str) }; - render_template(world, &template, &target); - // Cucumber supplies owned Strings, but we only need a borrowed view. - // Explicitly dropping satisfies clippy::needless_pass_by_value while making - // the ownership intent clear. + render_template(world, template.as_str(), target.as_path()); drop(template); } #[then(regex = r#"^the stdlib output is "(.+)"$"#)] #[expect( clippy::needless_pass_by_value, - reason = "Cucumber requires owned String arguments" + reason = "Cucumber requires owned capture arguments" )] fn assert_stdlib_output(world: &mut CliWorld, expected: String) { let output = world @@ -142,7 +140,7 @@ fn stdlib_root_and_output(world: &CliWorld) -> (&Utf8Path, &str) { #[then(regex = r#"^the stdlib error contains "(.+)"$"#)] #[expect( clippy::needless_pass_by_value, - reason = "Cucumber requires owned String arguments" + reason = "Cucumber requires owned capture arguments" )] fn assert_stdlib_error(world: &mut CliWorld, fragment: String) { let error = world.stdlib_error.as_ref().expect("expected stdlib error"); @@ -159,12 +157,8 @@ fn assert_stdlib_output_is_root(world: &mut CliWorld) { } #[then(regex = r#"^the stdlib output is the workspace path "(.+)"$"#)] -#[expect( - clippy::needless_pass_by_value, - reason = "Cucumber requires owned String arguments" -)] -fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: String) { +fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: Utf8PathBuf) { let (root, output) = stdlib_root_and_output(world); - let expected = root.join(&relative); + let expected = root.join(relative); assert_eq!(output, expected.as_str()); } From 94632348e64c7d92168a7f245dda2c00f395117b Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 12:30:01 +0100 Subject: [PATCH 12/21] Document stdlib filter tests --- tests/std_filter_tests.rs | 4 ++-- tests/steps/stdlib_steps.rs | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index df4a3b0f..ed8efe13 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -1,5 +1,5 @@ -//! Unit tests for the stdlib path and file filters. -//! Exercises success, failure, and edge cases via rstest fixtures. +//! Unit tests covering the template stdlib path and file filters via rstest fixtures. +//! Verifies success, failure, and edge conditions for each filter and IO helper. use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, ErrorKind, context}; diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index 18772ac0..d41ecc89 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -1,7 +1,6 @@ -//! Cucumber step implementations for stdlib path and file filters. -//! -//! Sets up a temporary workspace, renders templates with stdlib registered, -//! and asserts outputs and errors. +//! Behavioural steps exercising the template stdlib path and file filters. +//! Prepare a temporary workspace, render templates with stdlib registration, +//! and assert expected outputs or `MiniJinja` errors. use crate::CliWorld; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; From 7693bcf04423af7ff4baa2d5a200e42b23c3e38e Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 13:04:03 +0100 Subject: [PATCH 13/21] Introduce stdlib step newtypes Wrap template paths, contents, and relative strings in helper structs so Cucumber steps convert owned inputs before reuse. Update the behavioural helper to accept borrowed newtypes, resolve paths via RelativePath, and restore lint expectations for string arguments. --- tests/steps/stdlib_steps.rs | 130 ++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 26 deletions(-) diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index d41ecc89..510b98bf 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -14,9 +14,79 @@ const LINES_FIXTURE: &str = concat!( "one ", "two ", "three -" +", ); +#[derive(Debug, Clone)] +struct TemplatePath(Utf8PathBuf); + +impl TemplatePath { + fn as_path(&self) -> &Utf8Path { + &self.0 + } +} + +impl From for TemplatePath { + fn from(value: String) -> Self { + Self(Utf8PathBuf::from(value)) + } +} + +impl From for TemplatePath { + fn from(value: Utf8PathBuf) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone)] +struct TemplateContent(String); + +impl TemplateContent { + fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for TemplateContent { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone)] +struct FileContent(String); + +impl FileContent { + fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl From for FileContent { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone)] +struct RelativePath(String); + +impl RelativePath { + fn as_str(&self) -> &str { + &self.0 + } + + fn to_path_buf(&self) -> Utf8PathBuf { + Utf8PathBuf::from(self.as_str()) + } +} + +impl From for RelativePath { + fn from(value: String) -> Self { + Self(value) + } +} + fn ensure_workspace(world: &mut CliWorld) -> Utf8PathBuf { if let Some(root) = &world.stdlib_root { return root.clone(); @@ -37,10 +107,10 @@ fn ensure_workspace(world: &mut CliWorld) -> Utf8PathBuf { root } -fn render_template(world: &mut CliWorld, template: &str, path: &Utf8Path) { +fn render_template(world: &mut CliWorld, template: &TemplateContent, path: &TemplatePath) { let mut env = Environment::new(); stdlib::register(&mut env); - let render = env.render_str(template, context!(path => path.as_str())); + let render = env.render_str(template.as_str(), context!(path => path.as_path().as_str())); match render { Ok(output) => { world.stdlib_output = Some(output); @@ -59,22 +129,23 @@ fn stdlib_workspace(world: &mut CliWorld) { world.stdlib_root = Some(root); } -#[expect( - clippy::needless_pass_by_value, - reason = "Cucumber requires owned capture arguments" -)] #[given(regex = r#"^the stdlib file "(.+)" contains "(.+)"$"#)] -fn write_stdlib_file(world: &mut CliWorld, path: Utf8PathBuf, contents: String) { +fn write_stdlib_file(world: &mut CliWorld, path: String, contents: String) { let root = ensure_workspace(world); let handle = Dir::open_ambient_dir(&root, ambient_authority()).expect("open workspace"); - let relative = path.as_path(); - if let Some(parent) = relative.parent().filter(|p| !p.as_str().is_empty()) { + let relative_path = TemplatePath::from(path); + let file_content = FileContent::from(contents); + if let Some(parent) = relative_path + .as_path() + .parent() + .filter(|p| !p.as_str().is_empty()) + { handle .create_dir_all(parent) .expect("create fixture directories"); } handle - .write(relative, contents.as_bytes()) + .write(relative_path.as_path(), file_content.as_bytes()) .expect("write stdlib file"); } @@ -95,26 +166,32 @@ fn home_points_to_stdlib_root(world: &mut CliWorld) { world.stdlib_root = Some(root); } +fn resolve_template_path(root: &Utf8Path, raw: &RelativePath) -> TemplatePath { + if raw.as_str().starts_with('~') { + return TemplatePath::from(raw.as_str().to_owned()); + } + let candidate = raw.to_path_buf(); + if candidate.is_absolute() { + TemplatePath::from(candidate) + } else { + TemplatePath::from(root.join(candidate)) + } +} + #[when(regex = r#"^I render "(.+)" with stdlib path "(.+)"$"#)] -fn render_stdlib_template(world: &mut CliWorld, template: String, path: Utf8PathBuf) { +fn render_stdlib_template(world: &mut CliWorld, template: String, path: String) { let root = ensure_workspace(world); - let path_str = path.as_str(); - let is_home_expansion = path_str.starts_with('~'); - let is_absolute = path.is_absolute(); - let target = if is_home_expansion || is_absolute { - path - } else { - root.join(path_str) - }; - render_template(world, template.as_str(), target.as_path()); - drop(template); + let template_content = TemplateContent::from(template); + let relative_path = RelativePath::from(path); + let target = resolve_template_path(root.as_path(), &relative_path); + render_template(world, &template_content, &target); } -#[then(regex = r#"^the stdlib output is "(.+)"$"#)] #[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned capture arguments" )] +#[then(regex = r#"^the stdlib output is "(.+)"$"#)] fn assert_stdlib_output(world: &mut CliWorld, expected: String) { let output = world .stdlib_output @@ -136,11 +213,11 @@ fn stdlib_root_and_output(world: &CliWorld) -> (&Utf8Path, &str) { (root, output) } -#[then(regex = r#"^the stdlib error contains "(.+)"$"#)] #[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned capture arguments" )] +#[then(regex = r#"^the stdlib error contains "(.+)"$"#)] fn assert_stdlib_error(world: &mut CliWorld, fragment: String) { let error = world.stdlib_error.as_ref().expect("expected stdlib error"); assert!( @@ -156,8 +233,9 @@ fn assert_stdlib_output_is_root(world: &mut CliWorld) { } #[then(regex = r#"^the stdlib output is the workspace path "(.+)"$"#)] -fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: Utf8PathBuf) { +fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: String) { let (root, output) = stdlib_root_and_output(world); - let expected = root.join(relative); + let relative_path = RelativePath::from(relative); + let expected = root.join(relative_path.to_path_buf()); assert_eq!(output, expected.as_str()); } From 925526175738b9784947ac8171d82fe87e06fe8c Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 13:04:51 +0100 Subject: [PATCH 14/21] Split stdlib filter tests into modules Break the oversized std_filter_tests integration suite into smaller modules so each file stays under 400 lines. Update Cargo features to use caret-style defaults without explicit hats while keeping hash coverage intact. --- Cargo.toml | 8 +- tests/std_filter_tests.rs | 560 +------------------------ tests/std_filter_tests/hash_filters.rs | 139 ++++++ tests/std_filter_tests/io_filters.rs | 84 ++++ tests/std_filter_tests/path_filters.rs | 285 +++++++++++++ tests/std_filter_tests/support.rs | 68 +++ 6 files changed, 588 insertions(+), 556 deletions(-) create mode 100644 tests/std_filter_tests/hash_filters.rs create mode 100644 tests/std_filter_tests/io_filters.rs create mode 100644 tests/std_filter_tests/path_filters.rs create mode 100644 tests/std_filter_tests/support.rs diff --git a/Cargo.toml b/Cargo.toml index de89194f..c25e1382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,10 +27,10 @@ semver = { version = "1", features = ["serde"] } anyhow = "1" thiserror = "1" miette = { version = "7.6.0", features = ["fancy"] } -digest = "^0.10" -sha2 = "^0.10" -sha1 = { version = "^0.10", optional = true } -md5 = { package = "md-5", version = "^0.10", optional = true } +digest = "0.10" +sha2 = "0.10" +sha1 = { version = "0.10", optional = true } +md5 = { package = "md-5", version = "0.10", optional = true } itoa = "1" itertools = "0.12" glob = "0.3.3" diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index ed8efe13..7944634d 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -1,552 +1,8 @@ -//! Unit tests covering the template stdlib path and file filters via rstest fixtures. -//! Verifies success, failure, and edge conditions for each filter and IO helper. -use camino::{Utf8Path, Utf8PathBuf}; -use cap_std::{ambient_authority, fs_utf8::Dir}; -use minijinja::{Environment, ErrorKind, context}; -use netsuke::stdlib; -use rstest::{fixture, rstest}; -use std::cell::RefCell; -use tempfile::tempdir; -use test_support::{EnvVarGuard, env_lock::EnvLock}; - -thread_local! { - static TEMPLATE_STORAGE: RefCell, Box)>> = const { RefCell::new(Vec::new()) }; -} - -fn register_template( - env: &mut Environment<'_>, - name: impl Into, - source: impl Into, -) { - TEMPLATE_STORAGE.with(|storage| { - let (name_ptr, source_ptr) = { - let mut storage = storage.borrow_mut(); - storage.push((name.into().into_boxed_str(), source.into().into_boxed_str())); - let (name, source) = storage.last().expect("template storage entry"); - ( - std::ptr::from_ref(name.as_ref()), - std::ptr::from_ref(source.as_ref()), - ) - }; - // SAFETY: the pointers originate from boxed strings stored in the - // thread-local registry. They remain valid for the duration of the - // process, so treating them as `'static` references is sound. - unsafe { - env.add_template(&*name_ptr, &*source_ptr) - .expect("template"); - } - }); -} - -#[fixture] -fn filter_workspace() -> (tempfile::TempDir, Utf8PathBuf) { - let temp = tempdir().expect("tempdir"); - let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8"); - let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); - dir.write("file", b"data").expect("file"); - #[cfg(unix)] - dir.symlink("file", "link").expect("symlink"); - #[cfg(not(unix))] - dir.write("link", b"data").expect("link copy"); - dir.write("lines.txt", b"one\ntwo\nthree\n").expect("lines"); - (temp, root) -} - -fn render<'a>( - env: &mut Environment<'a>, - name: &'a str, - template: &'a str, - path: &Utf8PathBuf, -) -> String { - env.add_template(name, template).expect("template"); - env.get_template(name) - .expect("get template") - .render(context!(path => path.as_str())) - .expect("render") -} - -#[rstest] -fn dirname_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - let output = render(&mut env, "dirname", "{{ path | dirname }}", &file); - assert_eq!(output, root.as_str()); -} - -#[rstest] -fn relative_to_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); - dir.create_dir_all("nested").expect("create nested dir"); - dir.write("nested/file.txt", b"data") - .expect("write nested file"); - let nested = root.join("nested/file.txt"); - let output = render( - &mut env, - "relative_to", - "{{ path | relative_to(path | dirname) }}", - &nested, - ); - assert_eq!(output, "file.txt"); -} - -#[rstest] -fn relative_to_filter_outside_root(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - register_template( - &mut env, - "relative_to_fail", - "{{ path | relative_to(root) }}", - ); - let template = env.get_template("relative_to_fail").expect("get template"); - let file = root.join("file"); - let other_root = root.join("other"); - let result = template.render(context!(path => file.as_str(), root => other_root.as_str())); - let err = result.expect_err("relative_to should reject unrelated paths"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("is not relative"), - "error should mention missing relationship: {err}" - ); -} - -#[rstest] -fn with_suffix_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file.tar.gz"); - Dir::open_ambient_dir(&root, ambient_authority()) - .expect("dir") - .write("file.tar.gz", b"data") - .expect("write"); - let first = render( - &mut env, - "suffix", - "{{ path | with_suffix('.log') }}", - &file, - ); - assert_eq!(first, root.join("file.tar.log").as_str()); - let second = render( - &mut env, - "suffix_alt", - "{{ path | with_suffix('.zip', 2) }}", - &file, - ); - assert_eq!(second, root.join("file.zip").as_str()); - let third = render( - &mut env, - "suffix_count_zero", - "{{ path | with_suffix('.bak', 0) }}", - &file, - ); - assert_eq!(third, root.join("file.tar.gz.bak").as_str()); -} - -#[rstest] -fn with_suffix_filter_without_separator(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - let output = render( - &mut env, - "suffix_plain", - "{{ path | with_suffix('.log') }}", - &file, - ); - assert_eq!(output, root.join("file.log").as_str()); -} - -#[rstest] -fn with_suffix_filter_empty_separator(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - env.add_template( - "suffix_empty_sep", - "{{ path | with_suffix('.log', 1, '') }}", - ) - .expect("template"); - let template = env.get_template("suffix_empty_sep").expect("get template"); - let file = root.join("file.tar.gz"); - let result = template.render(context!(path => file.as_str())); - let err = result.expect_err("with_suffix should reject empty separator"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("non-empty separator"), - "error should mention separator requirement", - ); -} - -#[rstest] -fn with_suffix_filter_excessive_count(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file.tar.gz"); - let output = render( - &mut env, - "suffix_excessive", - "{{ path | with_suffix('.bak', 5) }}", - &file, - ); - assert_eq!(output, root.join("file.bak").as_str()); -} - -#[cfg(unix)] -#[rstest] -fn realpath_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let link = root.join("link"); - let output = render(&mut env, "realpath", "{{ path | realpath }}", &link); - assert_eq!(output, root.join("file").as_str()); -} - -#[cfg(unix)] -#[rstest] -fn realpath_filter_missing_path(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - env.add_template("realpath_missing", "{{ path | realpath }}") - .expect("template"); - let template = env.get_template("realpath_missing").expect("get template"); - let missing = root.join("missing"); - let result = template.render(context!(path => missing.as_str())); - let err = result.expect_err("realpath should error for missing path"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("not found"), - "error should mention missing path", - ); -} - -#[cfg(unix)] -#[rstest] -fn realpath_filter_root_path(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let root_path = root - .ancestors() - .find(|candidate| candidate.parent().is_none()) - .map(Utf8Path::to_path_buf) - .expect("root ancestor"); - assert!( - !root_path.as_str().is_empty(), - "root path should not be empty", - ); - let output = render( - &mut env, - "realpath_root", - "{{ path | realpath }}", - &root_path, - ); - assert_eq!(output, root_path.as_str()); -} - -#[rstest] -fn contents_and_linecount_filters(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - let text = render(&mut env, "contents", "{{ path | contents }}", &file); - assert_eq!(text, "data"); - let lines = render( - &mut env, - "linecount", - "{{ path | linecount }}", - &root.join("lines.txt"), - ); - assert_eq!(lines.parse::().expect("usize"), 3); - - Dir::open_ambient_dir(&root, ambient_authority()) - .expect("dir") - .write("empty.txt", b"") - .expect("empty file"); - let empty_file = root.join("empty.txt"); - let empty_lines = render( - &mut env, - "empty_linecount", - "{{ path | linecount }}", - &empty_file, - ); - assert_eq!(empty_lines.parse::().expect("usize"), 0); -} - -#[rstest] -fn contents_filter_unsupported_encoding(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - env.add_template("contents_bad_encoding", "{{ path | contents('latin-1') }}") - .expect("template"); - let template = env - .get_template("contents_bad_encoding") - .expect("get template"); - let file = root.join("file"); - let result = template.render(context!(path => file.as_str())); - let err = result.expect_err("contents should error on unsupported encoding"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("unsupported encoding"), - "error should mention unsupported encoding", - ); -} - -#[rstest] -#[case( - "sha256", - "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7", - "3a6eb079" -)] -#[case( - "sha512", - "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876", - "77c7ce9a" -)] -#[case( - "sha256-empty", - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "e3b0c442" -)] -#[case( - "sha512-empty", - "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", - "cf83e135" -)] -#[cfg_attr( - feature = "legacy-digests", - case("sha1", "a17c9aaa61e80a1bf71d0d850af4e5baa9800bbd", "a17c9aaa",) -)] -#[cfg_attr( - feature = "legacy-digests", - case("sha1-empty", "da39a3ee5e6b4b0d3255bfef95601890afd80709", "da39a3ee",) -)] -#[cfg_attr( - feature = "legacy-digests", - case("md5", "8d777f385d3dfec8815d20f7496026dc", "8d777f38",) -)] -#[cfg_attr( - feature = "legacy-digests", - case("md5-empty", "d41d8cd98f00b204e9800998ecf8427e", "d41d8cd9",) -)] -fn hash_and_digest_filters( - filter_workspace: (tempfile::TempDir, Utf8PathBuf), - #[case] alg: &str, - #[case] expected_hash: &str, - #[case] expected_digest: &str, -) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); - - let (file, algorithm) = alg.strip_suffix("-empty").map_or_else( - || (root.join("file"), alg), - |stripped| { - let relative = format!("{stripped}_empty"); - dir.write(relative.as_str(), b"") - .expect("create empty file"); - (root.join(relative.as_str()), stripped) - }, - ); - - let hash_template_name = format!("hash_{alg}"); - let hash_template = format!("{{{{ path | hash('{algorithm}') }}}}"); - register_template(&mut env, hash_template_name.as_str(), hash_template); - let hash_result = env - .get_template(hash_template_name.as_str()) - .expect("get template") - .render(context!(path => file.as_str())) - .expect("render hash"); - assert_eq!(hash_result, expected_hash); - - let digest_template_name = format!("digest_{alg}"); - let digest_template = format!("{{{{ path | digest(8, '{algorithm}') }}}}"); - register_template(&mut env, digest_template_name.as_str(), digest_template); - let digest_result = env - .get_template(digest_template_name.as_str()) - .expect("get template") - .render(context!(path => file.as_str())) - .expect("render digest"); - assert_eq!(digest_result, expected_digest); -} - -#[cfg(not(feature = "legacy-digests"))] -#[rstest] -fn hash_filter_legacy_algorithms_disabled(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - - register_template(&mut env, "hash_sha1", "{{ path | hash('sha1') }}"); - let template = env.get_template("hash_sha1").expect("get template"); - let result = template.render(context!(path => root.join("file").as_str())); - let err = result.expect_err("hash should require the legacy-digests feature for sha1"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("enable feature 'legacy-digests'"), - "error should mention legacy feature: {err}", - ); -} - -#[rstest] -fn hash_filter_rejects_unknown_algorithm(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - - register_template(&mut env, "hash_unknown", "{{ path | hash('whirlpool') }}"); - let hash_template = env.get_template("hash_unknown").expect("get template"); - let hash_result = hash_template.render(context!(path => file.as_str())); - let hash_err = hash_result.expect_err("hash should reject unsupported algorithms"); - assert_eq!(hash_err.kind(), ErrorKind::InvalidOperation); - assert!( - hash_err - .to_string() - .contains("unsupported hash algorithm 'whirlpool'"), - "error should mention unsupported algorithm: {hash_err}", - ); - - register_template( - &mut env, - "digest_unknown", - "{{ path | digest(8, 'whirlpool') }}", - ); - let digest_template = env.get_template("digest_unknown").expect("get template"); - let digest_result = digest_template.render(context!(path => file.as_str())); - let digest_err = digest_result.expect_err("digest should reject unsupported algorithms"); - assert_eq!(digest_err.kind(), ErrorKind::InvalidOperation); - assert!( - digest_err - .to_string() - .contains("unsupported hash algorithm 'whirlpool'"), - "error should mention unsupported algorithm: {digest_err}", - ); -} - -#[rstest] -fn size_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - let size = render(&mut env, "size", "{{ path | size }}", &file); - assert_eq!(size.parse::().expect("u64"), 4); -} - -#[rstest] -fn size_filter_missing_file(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - register_template(&mut env, "size_missing", "{{ path | size }}"); - let missing = root.join("does_not_exist"); - let template = env.get_template("size_missing").expect("get template"); - let result = template.render(context!(path => missing.as_str())); - let err = result.expect_err("size should error for missing file"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("does_not_exist") || err.to_string().contains("not found"), - "error should mention missing file: {err}", - ); -} - -#[rstest] -fn expanduser_filter(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let _lock = EnvLock::acquire(); - let _home_guard = EnvVarGuard::set("HOME", root.as_str()); - let _profile_guard = EnvVarGuard::remove("USERPROFILE"); - let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let _path_guard = EnvVarGuard::remove("HOMEPATH"); - let _share_guard = EnvVarGuard::remove("HOMESHARE"); - let home = render( - &mut env, - "expanduser", - "{{ path | expanduser }}", - &Utf8PathBuf::from("~/workspace"), - ); - assert_eq!(home, root.join("workspace").as_str()); -} - -#[rstest] -fn expanduser_filter_non_tilde_path(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let file = root.join("file"); - let output = render( - &mut env, - "expanduser_plain", - "{{ path | expanduser }}", - &file, - ); - assert_eq!(output, file.as_str()); -} - -#[rstest] -fn expanduser_filter_missing_home(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, _root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let _lock = EnvLock::acquire(); - let _home_guard = EnvVarGuard::remove("HOME"); - let _profile_guard = EnvVarGuard::remove("USERPROFILE"); - let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let _path_guard = EnvVarGuard::remove("HOMEPATH"); - let _share_guard = EnvVarGuard::remove("HOMESHARE"); - env.add_template("expanduser_missing_home", "{{ path | expanduser }}") - .expect("template"); - let template = env - .get_template("expanduser_missing_home") - .expect("get template"); - let result = template.render(context!(path => "~/workspace")); - let err = result.expect_err("expanduser should error when HOME is unset"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string() - .contains("no home directory environment variables are set"), - "error should mention missing HOME", - ); -} - -#[rstest] -fn expanduser_filter_user_specific(filter_workspace: (tempfile::TempDir, Utf8PathBuf)) { - let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let _lock = EnvLock::acquire(); - let _home_guard = EnvVarGuard::set("HOME", root.as_str()); - let _profile_guard = EnvVarGuard::remove("USERPROFILE"); - let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let _path_guard = EnvVarGuard::remove("HOMEPATH"); - let _share_guard = EnvVarGuard::remove("HOMESHARE"); - env.add_template("expanduser_user_specific", "{{ path | expanduser }}") - .expect("template"); - let template = env - .get_template("expanduser_user_specific") - .expect("get template"); - let result = template.render(context!(path => "~otheruser/workspace")); - let err = result.expect_err("expanduser should reject ~user expansion"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string() - .contains("user-specific ~ expansion is unsupported"), - "error should mention unsupported user expansion", - ); -} +#[path = "std_filter_tests/support.rs"] +mod support; +#[path = "std_filter_tests/path_filters.rs"] +mod path_filters; +#[path = "std_filter_tests/io_filters.rs"] +mod io_filters; +#[path = "std_filter_tests/hash_filters.rs"] +mod hash_filters; diff --git a/tests/std_filter_tests/hash_filters.rs b/tests/std_filter_tests/hash_filters.rs new file mode 100644 index 00000000..bd293411 --- /dev/null +++ b/tests/std_filter_tests/hash_filters.rs @@ -0,0 +1,139 @@ +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, ErrorKind, context}; +use netsuke::stdlib; +use rstest::rstest; + +use super::support::{Workspace, filter_workspace, register_template}; + +#[rstest] +#[case( + "sha256", + "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7", + "3a6eb079" +)] +#[case( + "sha512", + "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876", + "77c7ce9a" +)] +#[case( + "sha256-empty", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "e3b0c442" +)] +#[case( + "sha512-empty", + "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", + "cf83e135" +)] +#[cfg_attr( + feature = "legacy-digests", + case("sha1", "a17c9aaa61e80a1bf71d0d850af4e5baa9800bbd", "a17c9aaa",) +)] +#[cfg_attr( + feature = "legacy-digests", + case("sha1-empty", "da39a3ee5e6b4b0d3255bfef95601890afd80709", "da39a3ee",) +)] +#[cfg_attr( + feature = "legacy-digests", + case("md5", "8d777f385d3dfec8815d20f7496026dc", "8d777f38",) +)] +#[cfg_attr( + feature = "legacy-digests", + case("md5-empty", "d41d8cd98f00b204e9800998ecf8427e", "d41d8cd9",) +)] +fn hash_and_digest_filters( + filter_workspace: Workspace, + #[case] alg: &str, + #[case] expected_hash: &str, + #[case] expected_digest: &str, +) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); + + let (file, algorithm) = alg.strip_suffix("-empty").map_or_else( + || (root.join("file"), alg), + |stripped| { + let relative = format!("{stripped}_empty"); + dir.write(relative.as_str(), b"") + .expect("create empty file"); + (root.join(relative.as_str()), stripped) + }, + ); + + let hash_template_name = format!("hash_{alg}"); + let hash_template = format!("{{{{ path | hash('{algorithm}') }}}}"); + register_template(&mut env, hash_template_name.as_str(), hash_template); + let hash_result = env + .get_template(hash_template_name.as_str()) + .expect("get template") + .render(context!(path => file.as_str())) + .expect("render hash"); + assert_eq!(hash_result, expected_hash); + + let digest_template_name = format!("digest_{alg}"); + let digest_template = format!("{{{{ path | digest(8, '{algorithm}') }}}}"); + register_template(&mut env, digest_template_name.as_str(), digest_template); + let digest_result = env + .get_template(digest_template_name.as_str()) + .expect("get template") + .render(context!(path => file.as_str())) + .expect("render digest"); + assert_eq!(digest_result, expected_digest); +} + +#[cfg(not(feature = "legacy-digests"))] +#[rstest] +fn hash_filter_legacy_algorithms_disabled(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + + register_template(&mut env, "hash_sha1", "{{ path | hash('sha1') }}"); + let template = env.get_template("hash_sha1").expect("get template"); + let result = template.render(context!(path => root.join("file").as_str())); + let err = result.expect_err("hash should require the legacy-digests feature for sha1"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("enable feature 'legacy-digests'"), + "error should mention legacy feature: {err}", + ); +} + +#[rstest] +fn hash_filter_rejects_unknown_algorithm(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + + register_template(&mut env, "hash_unknown", "{{ path | hash('whirlpool') }}"); + let hash_template = env.get_template("hash_unknown").expect("get template"); + let hash_result = hash_template.render(context!(path => file.as_str())); + let hash_err = hash_result.expect_err("hash should reject unsupported algorithms"); + assert_eq!(hash_err.kind(), ErrorKind::InvalidOperation); + assert!( + hash_err + .to_string() + .contains("unsupported hash algorithm 'whirlpool'"), + "error should mention unsupported algorithm: {hash_err}", + ); + + register_template( + &mut env, + "digest_unknown", + "{{ path | digest(8, 'whirlpool') }}", + ); + let digest_template = env.get_template("digest_unknown").expect("get template"); + let digest_result = digest_template.render(context!(path => file.as_str())); + let digest_err = digest_result.expect_err("digest should reject unsupported algorithms"); + assert_eq!(digest_err.kind(), ErrorKind::InvalidOperation); + assert!( + digest_err + .to_string() + .contains("unsupported hash algorithm 'whirlpool'"), + "error should mention unsupported algorithm: {digest_err}", + ); +} diff --git a/tests/std_filter_tests/io_filters.rs b/tests/std_filter_tests/io_filters.rs new file mode 100644 index 00000000..3f7e72c6 --- /dev/null +++ b/tests/std_filter_tests/io_filters.rs @@ -0,0 +1,84 @@ +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, ErrorKind, context}; +use netsuke::stdlib; +use rstest::rstest; + +use super::support::{Workspace, filter_workspace, render}; + +#[rstest] +fn contents_and_linecount_filters(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let text = render(&mut env, "contents", "{{ path | contents }}", &file); + assert_eq!(text, "data"); + let lines = render( + &mut env, + "linecount", + "{{ path | linecount }}", + &root.join("lines.txt"), + ); + assert_eq!(lines.parse::().expect("usize"), 3); + + Dir::open_ambient_dir(&root, ambient_authority()) + .expect("dir") + .write("empty.txt", b"") + .expect("empty file"); + let empty_file = root.join("empty.txt"); + let empty_lines = render( + &mut env, + "empty_linecount", + "{{ path | linecount }}", + &empty_file, + ); + assert_eq!(empty_lines.parse::().expect("usize"), 0); +} + +#[rstest] +fn contents_filter_unsupported_encoding(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template("contents_bad_encoding", "{{ path | contents('latin-1') }}") + .expect("template"); + let template = env + .get_template("contents_bad_encoding") + .expect("get template"); + let file = root.join("file"); + let result = template.render(context!(path => file.as_str())); + let err = result.expect_err("contents should error on unsupported encoding"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("unsupported encoding"), + "error should mention unsupported encoding", + ); +} + +#[rstest] +fn size_filter(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let size = render(&mut env, "size", "{{ path | size }}", &file); + assert_eq!(size.parse::().expect("u64"), 4); +} + +#[rstest] +fn size_filter_missing_file(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template("size_missing", "{{ path | size }}") + .expect("template"); + let template = env.get_template("size_missing").expect("get template"); + let missing = root.join("does_not_exist"); + let result = template.render(context!(path => missing.as_str())); + let err = result.expect_err("size should error for missing file"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("does_not_exist") || err.to_string().contains("not found"), + "error should mention missing file: {err}", + ); +} diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs new file mode 100644 index 00000000..cf51daa0 --- /dev/null +++ b/tests/std_filter_tests/path_filters.rs @@ -0,0 +1,285 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, ErrorKind, context}; +use netsuke::stdlib; +use rstest::rstest; + +use super::support::{ + EnvLock, EnvVarGuard, Workspace, filter_workspace, register_template, render, +}; + +#[rstest] +fn dirname_filter(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render(&mut env, "dirname", "{{ path | dirname }}", &file); + assert_eq!(output, root.as_str()); +} + +#[rstest] +fn relative_to_filter(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); + dir.create_dir_all("nested").expect("create nested dir"); + dir.write("nested/file.txt", b"data") + .expect("write nested file"); + let nested = root.join("nested/file.txt"); + let output = render( + &mut env, + "relative_to", + "{{ path | relative_to(path | dirname) }}", + &nested, + ); + assert_eq!(output, "file.txt"); +} + +#[rstest] +fn relative_to_filter_outside_root(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + register_template( + &mut env, + "relative_to_fail", + "{{ path | relative_to(root) }}", + ); + let template = env.get_template("relative_to_fail").expect("get template"); + let file = root.join("file"); + let other_root = root.join("other"); + let result = template.render(context!(path => file.as_str(), root => other_root.as_str())); + let err = result.expect_err("relative_to should reject unrelated paths"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("is not relative"), + "error should mention missing relationship: {err}" + ); +} + +#[rstest] +fn with_suffix_filter(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file.tar.gz"); + Dir::open_ambient_dir(&root, ambient_authority()) + .expect("dir") + .write("file.tar.gz", b"data") + .expect("write"); + let first = render( + &mut env, + "suffix", + "{{ path | with_suffix('.log') }}", + &file, + ); + assert_eq!(first, root.join("file.tar.log").as_str()); + let second = render( + &mut env, + "suffix_alt", + "{{ path | with_suffix('.zip', 2) }}", + &file, + ); + assert_eq!(second, root.join("file.zip").as_str()); + let third = render( + &mut env, + "suffix_count_zero", + "{{ path | with_suffix('.bak', 0) }}", + &file, + ); + assert_eq!(third, root.join("file.tar.gz.bak").as_str()); +} + +#[rstest] +fn with_suffix_filter_without_separator(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render( + &mut env, + "suffix_plain", + "{{ path | with_suffix('.log') }}", + &file, + ); + assert_eq!(output, root.join("file.log").as_str()); +} + +#[rstest] +fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template( + "suffix_empty_sep", + "{{ path | with_suffix('.log', 1, '') }}", + ) + .expect("template"); + let template = env.get_template("suffix_empty_sep").expect("get template"); + let file = root.join("file.tar.gz"); + let result = template.render(context!(path => file.as_str())); + let err = result.expect_err("with_suffix should reject empty separator"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("non-empty separator"), + "error should mention separator requirement", + ); +} + +#[rstest] +fn with_suffix_filter_excessive_count(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file.tar.gz"); + let output = render( + &mut env, + "suffix_excessive", + "{{ path | with_suffix('.bak', 5) }}", + &file, + ); + assert_eq!(output, root.join("file.bak").as_str()); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let link = root.join("link"); + let output = render(&mut env, "realpath", "{{ path | realpath }}", &link); + assert_eq!(output, root.join("file").as_str()); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter_missing_path(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + env.add_template("realpath_missing", "{{ path | realpath }}") + .expect("template"); + let template = env.get_template("realpath_missing").expect("get template"); + let missing = root.join("missing"); + let result = template.render(context!(path => missing.as_str())); + let err = result.expect_err("realpath should error for missing path"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string().contains("not found"), + "error should mention missing path", + ); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter_root_path(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let root_path = root + .ancestors() + .find(|candidate| candidate.parent().is_none()) + .map(Utf8Path::to_path_buf) + .expect("root ancestor"); + assert!( + !root_path.as_str().is_empty(), + "root path should not be empty", + ); + let output = render( + &mut env, + "realpath_root", + "{{ path | realpath }}", + &root_path, + ); + assert_eq!(output, root_path.as_str()); +} + +#[rstest] +fn expanduser_filter(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let _lock = EnvLock::acquire(); + let _home_guard = EnvVarGuard::set("HOME", root.as_str()); + let _profile_guard = EnvVarGuard::remove("USERPROFILE"); + let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let _path_guard = EnvVarGuard::remove("HOMEPATH"); + let _share_guard = EnvVarGuard::remove("HOMESHARE"); + let home = render( + &mut env, + "expanduser", + "{{ path | expanduser }}", + &Utf8PathBuf::from("~/workspace"), + ); + assert_eq!(home, root.join("workspace").as_str()); +} + +#[rstest] +fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let file = root.join("file"); + let output = render( + &mut env, + "expanduser_plain", + "{{ path | expanduser }}", + &file, + ); + assert_eq!(output, file.as_str()); +} + +#[rstest] +fn expanduser_filter_missing_home(filter_workspace: Workspace) { + let (_temp, _root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let _lock = EnvLock::acquire(); + let _home_guard = EnvVarGuard::remove("HOME"); + let _profile_guard = EnvVarGuard::remove("USERPROFILE"); + let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let _path_guard = EnvVarGuard::remove("HOMEPATH"); + let _share_guard = EnvVarGuard::remove("HOMESHARE"); + env.add_template("expanduser_missing_home", "{{ path | expanduser }}") + .expect("template"); + let template = env + .get_template("expanduser_missing_home") + .expect("get template"); + let result = template.render(context!(path => "~/workspace")); + let err = result.expect_err("expanduser should error when HOME is unset"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string() + .contains("no home directory environment variables are set"), + "error should mention missing HOME", + ); +} + +#[rstest] +fn expanduser_filter_user_specific(filter_workspace: Workspace) { + let (_temp, root) = filter_workspace; + let mut env = Environment::new(); + stdlib::register(&mut env); + let _lock = EnvLock::acquire(); + let _home_guard = EnvVarGuard::set("HOME", root.as_str()); + let _profile_guard = EnvVarGuard::remove("USERPROFILE"); + let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let _path_guard = EnvVarGuard::remove("HOMEPATH"); + let _share_guard = EnvVarGuard::remove("HOMESHARE"); + env.add_template("expanduser_user_specific", "{{ path | expanduser }}") + .expect("template"); + let template = env + .get_template("expanduser_user_specific") + .expect("get template"); + let result = template.render(context!(path => "~otheruser/workspace")); + let err = result.expect_err("expanduser should reject ~user expansion"); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + err.to_string() + .contains("user-specific ~ expansion is unsupported"), + "error should mention unsupported user expansion", + ); +} diff --git a/tests/std_filter_tests/support.rs b/tests/std_filter_tests/support.rs new file mode 100644 index 00000000..2b0f44e9 --- /dev/null +++ b/tests/std_filter_tests/support.rs @@ -0,0 +1,68 @@ +use std::cell::RefCell; + +use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, context}; +use rstest::fixture; +use tempfile::tempdir; + +pub(crate) use test_support::{EnvVarGuard, env_lock::EnvLock}; + +pub(crate) type Workspace = (tempfile::TempDir, Utf8PathBuf); + +thread_local! { + static TEMPLATE_STORAGE: RefCell, Box)>> = const { RefCell::new(Vec::new()) }; +} + +pub(crate) fn register_template( + env: &mut Environment<'_>, + name: impl Into, + source: impl Into, +) { + TEMPLATE_STORAGE.with(|storage| { + let (name_ptr, source_ptr) = { + let mut storage = storage.borrow_mut(); + storage.push((name.into().into_boxed_str(), source.into().into_boxed_str())); + let (name, source) = storage.last().expect("template storage entry"); + ( + std::ptr::from_ref(name.as_ref()), + std::ptr::from_ref(source.as_ref()), + ) + }; + // SAFETY: the pointers originate from boxed strings stored in the + // thread-local registry. They remain valid for the duration of the + // process, so treating them as `'static` references is sound. + unsafe { + env.add_template(&*name_ptr, &*source_ptr) + .expect("template"); + } + }); +} + +#[fixture] +pub(crate) fn filter_workspace() -> Workspace { + let temp = tempdir().expect("tempdir"); + let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8"); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); + dir.write("file", b"data").expect("file"); + #[cfg(unix)] + dir.symlink("file", "link").expect("symlink"); + #[cfg(not(unix))] + dir.write("link", b"data").expect("link copy"); + dir.write("lines.txt", b"one\ntwo\nthree\n").expect("lines"); + (temp, root) +} + +pub(crate) fn render<'a>( + env: &mut Environment<'a>, + name: &'a str, + template: &'a str, + path: &Utf8PathBuf, +) -> String { + env.add_template(name, template).expect("template"); + env.get_template(name) + .expect("get template") + .render(context!(path => path.as_str())) + .expect("render") +} + From 24a037d3c5c8854cffa3f70e5b6d616a00adfedb Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 13:25:25 +0100 Subject: [PATCH 15/21] Deduplicate stdlib path filter tests --- tests/std_filter_tests.rs | 12 ++--- tests/std_filter_tests/path_filters.rs | 68 +++++++------------------- tests/std_filter_tests/support.rs | 49 ++++++++++++++++++- 3 files changed, 72 insertions(+), 57 deletions(-) diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index 7944634d..8c89f61f 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -1,8 +1,8 @@ -#[path = "std_filter_tests/support.rs"] -mod support; -#[path = "std_filter_tests/path_filters.rs"] -mod path_filters; -#[path = "std_filter_tests/io_filters.rs"] -mod io_filters; #[path = "std_filter_tests/hash_filters.rs"] mod hash_filters; +#[path = "std_filter_tests/io_filters.rs"] +mod io_filters; +#[path = "std_filter_tests/path_filters.rs"] +mod path_filters; +#[path = "std_filter_tests/support.rs"] +mod support; diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs index cf51daa0..838a613c 100644 --- a/tests/std_filter_tests/path_filters.rs +++ b/tests/std_filter_tests/path_filters.rs @@ -1,18 +1,16 @@ use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; -use minijinja::{Environment, ErrorKind, context}; -use netsuke::stdlib; +use minijinja::{ErrorKind, context}; use rstest::rstest; use super::support::{ - EnvLock, EnvVarGuard, Workspace, filter_workspace, register_template, render, + HomeEnvGuard, Workspace, filter_workspace, register_template, render, stdlib_env, }; #[rstest] fn dirname_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let file = root.join("file"); let output = render(&mut env, "dirname", "{{ path | dirname }}", &file); assert_eq!(output, root.as_str()); @@ -21,8 +19,7 @@ fn dirname_filter(filter_workspace: Workspace) { #[rstest] fn relative_to_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); dir.create_dir_all("nested").expect("create nested dir"); dir.write("nested/file.txt", b"data") @@ -40,8 +37,7 @@ fn relative_to_filter(filter_workspace: Workspace) { #[rstest] fn relative_to_filter_outside_root(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); register_template( &mut env, "relative_to_fail", @@ -62,8 +58,7 @@ fn relative_to_filter_outside_root(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let file = root.join("file.tar.gz"); Dir::open_ambient_dir(&root, ambient_authority()) .expect("dir") @@ -95,8 +90,7 @@ fn with_suffix_filter(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter_without_separator(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let file = root.join("file"); let output = render( &mut env, @@ -110,8 +104,7 @@ fn with_suffix_filter_without_separator(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); env.add_template( "suffix_empty_sep", "{{ path | with_suffix('.log', 1, '') }}", @@ -131,8 +124,7 @@ fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter_excessive_count(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let file = root.join("file.tar.gz"); let output = render( &mut env, @@ -147,8 +139,7 @@ fn with_suffix_filter_excessive_count(filter_workspace: Workspace) { #[rstest] fn realpath_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let link = root.join("link"); let output = render(&mut env, "realpath", "{{ path | realpath }}", &link); assert_eq!(output, root.join("file").as_str()); @@ -158,8 +149,7 @@ fn realpath_filter(filter_workspace: Workspace) { #[rstest] fn realpath_filter_missing_path(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); env.add_template("realpath_missing", "{{ path | realpath }}") .expect("template"); let template = env.get_template("realpath_missing").expect("get template"); @@ -177,8 +167,7 @@ fn realpath_filter_missing_path(filter_workspace: Workspace) { #[rstest] fn realpath_filter_root_path(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let root_path = root .ancestors() .find(|candidate| candidate.parent().is_none()) @@ -200,14 +189,8 @@ fn realpath_filter_root_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let _lock = EnvLock::acquire(); - let _home_guard = EnvVarGuard::set("HOME", root.as_str()); - let _profile_guard = EnvVarGuard::remove("USERPROFILE"); - let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let _path_guard = EnvVarGuard::remove("HOMEPATH"); - let _share_guard = EnvVarGuard::remove("HOMESHARE"); + let mut env = stdlib_env(); + let _env_guard = HomeEnvGuard::home_only(&root); let home = render( &mut env, "expanduser", @@ -220,8 +203,7 @@ fn expanduser_filter(filter_workspace: Workspace) { #[rstest] fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); + let mut env = stdlib_env(); let file = root.join("file"); let output = render( &mut env, @@ -235,14 +217,8 @@ fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter_missing_home(filter_workspace: Workspace) { let (_temp, _root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let _lock = EnvLock::acquire(); - let _home_guard = EnvVarGuard::remove("HOME"); - let _profile_guard = EnvVarGuard::remove("USERPROFILE"); - let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let _path_guard = EnvVarGuard::remove("HOMEPATH"); - let _share_guard = EnvVarGuard::remove("HOMESHARE"); + let mut env = stdlib_env(); + let _env_guard = HomeEnvGuard::unset(); env.add_template("expanduser_missing_home", "{{ path | expanduser }}") .expect("template"); let template = env @@ -261,14 +237,8 @@ fn expanduser_filter_missing_home(filter_workspace: Workspace) { #[rstest] fn expanduser_filter_user_specific(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = Environment::new(); - stdlib::register(&mut env); - let _lock = EnvLock::acquire(); - let _home_guard = EnvVarGuard::set("HOME", root.as_str()); - let _profile_guard = EnvVarGuard::remove("USERPROFILE"); - let _drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let _path_guard = EnvVarGuard::remove("HOMEPATH"); - let _share_guard = EnvVarGuard::remove("HOMESHARE"); + let mut env = stdlib_env(); + let _env_guard = HomeEnvGuard::home_only(&root); env.add_template("expanduser_user_specific", "{{ path | expanduser }}") .expect("template"); let template = env diff --git a/tests/std_filter_tests/support.rs b/tests/std_filter_tests/support.rs index 2b0f44e9..79f6a9ff 100644 --- a/tests/std_filter_tests/support.rs +++ b/tests/std_filter_tests/support.rs @@ -1,8 +1,9 @@ use std::cell::RefCell; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, context}; +use netsuke::stdlib; use rstest::fixture; use tempfile::tempdir; @@ -39,6 +40,12 @@ pub(crate) fn register_template( }); } +pub(crate) fn stdlib_env() -> Environment<'static> { + let mut env = Environment::new(); + stdlib::register(&mut env); + env +} + #[fixture] pub(crate) fn filter_workspace() -> Workspace { let temp = tempdir().expect("tempdir"); @@ -53,6 +60,45 @@ pub(crate) fn filter_workspace() -> Workspace { (temp, root) } +pub(crate) struct HomeEnvGuard { + _lock: EnvLock, + _home: EnvVarGuard, + _profile: EnvVarGuard, + _drive: EnvVarGuard, + _path: EnvVarGuard, + _share: EnvVarGuard, +} + +impl HomeEnvGuard { + fn new(home: Option<&str>) -> Self { + let lock = EnvLock::acquire(); + let home_guard = home.map_or_else( + || EnvVarGuard::remove("HOME"), + |value| EnvVarGuard::set("HOME", value), + ); + let profile_guard = EnvVarGuard::remove("USERPROFILE"); + let drive_guard = EnvVarGuard::remove("HOMEDRIVE"); + let path_guard = EnvVarGuard::remove("HOMEPATH"); + let share_guard = EnvVarGuard::remove("HOMESHARE"); + Self { + _lock: lock, + _home: home_guard, + _profile: profile_guard, + _drive: drive_guard, + _path: path_guard, + _share: share_guard, + } + } + + pub(crate) fn home_only(root: &Utf8Path) -> Self { + Self::new(Some(root.as_str())) + } + + pub(crate) fn unset() -> Self { + Self::new(None) + } +} + pub(crate) fn render<'a>( env: &mut Environment<'a>, name: &'a str, @@ -65,4 +111,3 @@ pub(crate) fn render<'a>( .render(context!(path => path.as_str())) .expect("render") } - From 151110568637cf686b074d76e2d9cb9d6e0cb7cb Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 13:47:04 +0100 Subject: [PATCH 16/21] Refactor stdlib path filter tests --- tests/std_filter_tests/path_filters.rs | 171 +++++++++++++++---------- tests/std_filter_tests/support.rs | 41 +----- 2 files changed, 104 insertions(+), 108 deletions(-) diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs index 838a613c..3d220331 100644 --- a/tests/std_filter_tests/path_filters.rs +++ b/tests/std_filter_tests/path_filters.rs @@ -1,16 +1,59 @@ use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; -use minijinja::{ErrorKind, context}; +use minijinja::{Environment, ErrorKind, context}; use rstest::rstest; +use serde_json::json; use super::support::{ - HomeEnvGuard, Workspace, filter_workspace, register_template, render, stdlib_env, + EnvLock, EnvVarGuard, Workspace, filter_workspace, register_template, render, stdlib_env, }; +/// Helper for tests requiring environment variable manipulation +fn with_clean_env_vars(home_value: Option<&str>, test_fn: F) -> R +where + F: FnOnce() -> R, +{ + let _lock = EnvLock::acquire(); + let _home = home_value.map_or_else( + || EnvVarGuard::remove("HOME"), + |value| EnvVarGuard::set("HOME", value), + ); + let _profile = EnvVarGuard::remove("USERPROFILE"); + let _drive = EnvVarGuard::remove("HOMEDRIVE"); + let _path = EnvVarGuard::remove("HOMEPATH"); + let _share = EnvVarGuard::remove("HOMESHARE"); + test_fn() +} + +/// Helper for standard filter environment setup +fn setup_filter_env() -> Environment<'static> { + stdlib_env() +} + +/// Helper for error testing with custom template +fn assert_template_error( + env: &mut Environment<'_>, + template_name: &str, + template_content: &str, + context_data: serde_json::Value, + expected_kind: ErrorKind, + error_contains: &str, +) { + register_template(env, template_name, template_content); + let template = env.get_template(template_name).expect("get template"); + let result = template.render(context_data); + let err = result.expect_err("template rendering should fail"); + assert_eq!(err.kind(), expected_kind); + assert!( + err.to_string().contains(error_contains), + "error should mention {error_contains}: {err}" + ); +} + #[rstest] fn dirname_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); + let mut env = setup_filter_env(); let file = root.join("file"); let output = render(&mut env, "dirname", "{{ path | dirname }}", &file); assert_eq!(output, root.as_str()); @@ -19,7 +62,7 @@ fn dirname_filter(filter_workspace: Workspace) { #[rstest] fn relative_to_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); + let mut env = setup_filter_env(); let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); dir.create_dir_all("nested").expect("create nested dir"); dir.write("nested/file.txt", b"data") @@ -37,21 +80,19 @@ fn relative_to_filter(filter_workspace: Workspace) { #[rstest] fn relative_to_filter_outside_root(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - register_template( + let mut env = setup_filter_env(); + let file = root.join("file"); + let other_root = root.join("other"); + assert_template_error( &mut env, "relative_to_fail", "{{ path | relative_to(root) }}", - ); - let template = env.get_template("relative_to_fail").expect("get template"); - let file = root.join("file"); - let other_root = root.join("other"); - let result = template.render(context!(path => file.as_str(), root => other_root.as_str())); - let err = result.expect_err("relative_to should reject unrelated paths"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("is not relative"), - "error should mention missing relationship: {err}" + json!({ + "path": file.as_str(), + "root": other_root.as_str(), + }), + ErrorKind::InvalidOperation, + "is not relative", ); } @@ -104,20 +145,17 @@ fn with_suffix_filter_without_separator(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - env.add_template( + let mut env = setup_filter_env(); + let file = root.join("file.tar.gz"); + assert_template_error( + &mut env, "suffix_empty_sep", "{{ path | with_suffix('.log', 1, '') }}", - ) - .expect("template"); - let template = env.get_template("suffix_empty_sep").expect("get template"); - let file = root.join("file.tar.gz"); - let result = template.render(context!(path => file.as_str())); - let err = result.expect_err("with_suffix should reject empty separator"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("non-empty separator"), - "error should mention separator requirement", + json!({ + "path": file.as_str(), + }), + ErrorKind::InvalidOperation, + "non-empty separator", ); } @@ -189,21 +227,22 @@ fn realpath_filter_root_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let _env_guard = HomeEnvGuard::home_only(&root); - let home = render( - &mut env, - "expanduser", - "{{ path | expanduser }}", - &Utf8PathBuf::from("~/workspace"), - ); - assert_eq!(home, root.join("workspace").as_str()); + with_clean_env_vars(Some(root.as_str()), || { + let mut env = setup_filter_env(); + let home = render( + &mut env, + "expanduser", + "{{ path | expanduser }}", + &Utf8PathBuf::from("~/workspace"), + ); + assert_eq!(home, root.join("workspace").as_str()); + }); } #[rstest] fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); + let mut env = setup_filter_env(); let file = root.join("file"); let output = render( &mut env, @@ -217,39 +256,35 @@ fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter_missing_home(filter_workspace: Workspace) { let (_temp, _root) = filter_workspace; - let mut env = stdlib_env(); - let _env_guard = HomeEnvGuard::unset(); - env.add_template("expanduser_missing_home", "{{ path | expanduser }}") - .expect("template"); - let template = env - .get_template("expanduser_missing_home") - .expect("get template"); - let result = template.render(context!(path => "~/workspace")); - let err = result.expect_err("expanduser should error when HOME is unset"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string() - .contains("no home directory environment variables are set"), - "error should mention missing HOME", - ); + with_clean_env_vars(None, || { + let mut env = setup_filter_env(); + assert_template_error( + &mut env, + "expanduser_missing_home", + "{{ path | expanduser }}", + json!({ + "path": "~/workspace", + }), + ErrorKind::InvalidOperation, + "no home directory environment variables are set", + ); + }); } #[rstest] fn expanduser_filter_user_specific(filter_workspace: Workspace) { let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let _env_guard = HomeEnvGuard::home_only(&root); - env.add_template("expanduser_user_specific", "{{ path | expanduser }}") - .expect("template"); - let template = env - .get_template("expanduser_user_specific") - .expect("get template"); - let result = template.render(context!(path => "~otheruser/workspace")); - let err = result.expect_err("expanduser should reject ~user expansion"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string() - .contains("user-specific ~ expansion is unsupported"), - "error should mention unsupported user expansion", - ); + with_clean_env_vars(Some(root.as_str()), || { + let mut env = setup_filter_env(); + assert_template_error( + &mut env, + "expanduser_user_specific", + "{{ path | expanduser }}", + json!({ + "path": "~otheruser/workspace", + }), + ErrorKind::InvalidOperation, + "user-specific ~ expansion is unsupported", + ); + }); } diff --git a/tests/std_filter_tests/support.rs b/tests/std_filter_tests/support.rs index 79f6a9ff..8d710e30 100644 --- a/tests/std_filter_tests/support.rs +++ b/tests/std_filter_tests/support.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, context}; use netsuke::stdlib; @@ -60,45 +60,6 @@ pub(crate) fn filter_workspace() -> Workspace { (temp, root) } -pub(crate) struct HomeEnvGuard { - _lock: EnvLock, - _home: EnvVarGuard, - _profile: EnvVarGuard, - _drive: EnvVarGuard, - _path: EnvVarGuard, - _share: EnvVarGuard, -} - -impl HomeEnvGuard { - fn new(home: Option<&str>) -> Self { - let lock = EnvLock::acquire(); - let home_guard = home.map_or_else( - || EnvVarGuard::remove("HOME"), - |value| EnvVarGuard::set("HOME", value), - ); - let profile_guard = EnvVarGuard::remove("USERPROFILE"); - let drive_guard = EnvVarGuard::remove("HOMEDRIVE"); - let path_guard = EnvVarGuard::remove("HOMEPATH"); - let share_guard = EnvVarGuard::remove("HOMESHARE"); - Self { - _lock: lock, - _home: home_guard, - _profile: profile_guard, - _drive: drive_guard, - _path: path_guard, - _share: share_guard, - } - } - - pub(crate) fn home_only(root: &Utf8Path) -> Self { - Self::new(Some(root.as_str())) - } - - pub(crate) fn unset() -> Self { - Self::new(None) - } -} - pub(crate) fn render<'a>( env: &mut Environment<'a>, name: &'a str, From 2c7499238aa15108ae3ef122fb23cfcff645f575 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 14:36:32 +0100 Subject: [PATCH 17/21] Refactor path filter tests around helpers Rebuild the stdlib path filter tests to use shared environment\nsetup and a structured TemplateErrorSpec so the duplication and\nargument-count review comments are resolved. Document the contents\nfilter's ambient file-system access inline. --- src/stdlib/path/filters.rs | 1 + tests/std_filter_tests/path_filters.rs | 411 +++++++++++++------------ 2 files changed, 219 insertions(+), 193 deletions(-) diff --git a/src/stdlib/path/filters.rs b/src/stdlib/path/filters.rs index 79caa75e..2adbf1fc 100644 --- a/src/stdlib/path/filters.rs +++ b/src/stdlib/path/filters.rs @@ -39,6 +39,7 @@ pub(crate) fn register_filters(env: &mut Environment<'_>) { env.add_filter("size", |raw: String| -> Result { fs_utils::file_size(Utf8Path::new(&raw)) }); + // Templates using `contents` read from the ambient file system; enable the stdlib only for trusted templates. env.add_filter( "contents", |raw: String, encoding: Option| -> Result { diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs index 3d220331..86ddf05b 100644 --- a/tests/std_filter_tests/path_filters.rs +++ b/tests/std_filter_tests/path_filters.rs @@ -1,8 +1,8 @@ use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; -use minijinja::{Environment, ErrorKind, context}; +use minijinja::{Environment, ErrorKind}; use rstest::rstest; -use serde_json::json; +use serde_json::{Value, json}; use super::support::{ EnvLock, EnvVarGuard, Workspace, filter_workspace, register_template, render, stdlib_env, @@ -30,261 +30,286 @@ fn setup_filter_env() -> Environment<'static> { stdlib_env() } +fn with_filter_env(workspace: Workspace, test_fn: F) +where + F: FnOnce(&Utf8Path, &mut Environment<'static>), +{ + let (_temp, root) = workspace; + let mut env = setup_filter_env(); + test_fn(&root, &mut env); +} + +struct TemplateErrorExpectation<'a> { + kind: ErrorKind, + contains: &'a str, +} + +struct TemplateErrorSpec<'a> { + name: &'a str, + template: &'a str, + context: Value, + expectation: TemplateErrorExpectation<'a>, +} + /// Helper for error testing with custom template -fn assert_template_error( - env: &mut Environment<'_>, - template_name: &str, - template_content: &str, - context_data: serde_json::Value, - expected_kind: ErrorKind, - error_contains: &str, -) { - register_template(env, template_name, template_content); - let template = env.get_template(template_name).expect("get template"); - let result = template.render(context_data); +fn assert_template_error(env: &mut Environment<'_>, spec: TemplateErrorSpec<'_>) { + register_template(env, spec.name, spec.template); + let template = env.get_template(spec.name).expect("get template"); + let TemplateErrorSpec { + context, + expectation, + .. + } = spec; + let result = template.render(context); let err = result.expect_err("template rendering should fail"); - assert_eq!(err.kind(), expected_kind); + assert_eq!(err.kind(), expectation.kind); assert!( - err.to_string().contains(error_contains), - "error should mention {error_contains}: {err}" + err.to_string().contains(expectation.contains), + "error should mention {}: {err}", + expectation.contains ); } #[rstest] fn dirname_filter(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = setup_filter_env(); - let file = root.join("file"); - let output = render(&mut env, "dirname", "{{ path | dirname }}", &file); - assert_eq!(output, root.as_str()); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file"); + let output = render(env, "dirname", "{{ path | dirname }}", &file); + assert_eq!(output, root.as_str()); + }); } #[rstest] fn relative_to_filter(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = setup_filter_env(); - let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("dir"); - dir.create_dir_all("nested").expect("create nested dir"); - dir.write("nested/file.txt", b"data") - .expect("write nested file"); - let nested = root.join("nested/file.txt"); - let output = render( - &mut env, - "relative_to", - "{{ path | relative_to(path | dirname) }}", - &nested, - ); - assert_eq!(output, "file.txt"); + with_filter_env(filter_workspace, |root, env| { + let dir = Dir::open_ambient_dir(root, ambient_authority()).expect("dir"); + dir.create_dir_all("nested").expect("create nested dir"); + dir.write("nested/file.txt", b"data") + .expect("write nested file"); + let nested = root.join("nested/file.txt"); + let output = render( + env, + "relative_to", + "{{ path | relative_to(path | dirname) }}", + &nested, + ); + assert_eq!(output, "file.txt"); + }); } #[rstest] fn relative_to_filter_outside_root(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = setup_filter_env(); - let file = root.join("file"); - let other_root = root.join("other"); - assert_template_error( - &mut env, - "relative_to_fail", - "{{ path | relative_to(root) }}", - json!({ - "path": file.as_str(), - "root": other_root.as_str(), - }), - ErrorKind::InvalidOperation, - "is not relative", - ); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file"); + let other_root = root.join("other"); + assert_template_error( + env, + TemplateErrorSpec { + name: "relative_to_fail", + template: "{{ path | relative_to(root) }}", + context: json!({ + "path": file.as_str(), + "root": other_root.as_str(), + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "is not relative", + }, + }, + ); + }); } #[rstest] fn with_suffix_filter(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let file = root.join("file.tar.gz"); - Dir::open_ambient_dir(&root, ambient_authority()) - .expect("dir") - .write("file.tar.gz", b"data") - .expect("write"); - let first = render( - &mut env, - "suffix", - "{{ path | with_suffix('.log') }}", - &file, - ); - assert_eq!(first, root.join("file.tar.log").as_str()); - let second = render( - &mut env, - "suffix_alt", - "{{ path | with_suffix('.zip', 2) }}", - &file, - ); - assert_eq!(second, root.join("file.zip").as_str()); - let third = render( - &mut env, - "suffix_count_zero", - "{{ path | with_suffix('.bak', 0) }}", - &file, - ); - assert_eq!(third, root.join("file.tar.gz.bak").as_str()); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file.tar.gz"); + Dir::open_ambient_dir(root, ambient_authority()) + .expect("dir") + .write("file.tar.gz", b"data") + .expect("write"); + let first = render(env, "suffix", "{{ path | with_suffix('.log') }}", &file); + assert_eq!(first, root.join("file.tar.log").as_str()); + let second = render( + env, + "suffix_alt", + "{{ path | with_suffix('.zip', 2) }}", + &file, + ); + assert_eq!(second, root.join("file.zip").as_str()); + let third = render( + env, + "suffix_count_zero", + "{{ path | with_suffix('.bak', 0) }}", + &file, + ); + assert_eq!(third, root.join("file.tar.gz.bak").as_str()); + }); } #[rstest] fn with_suffix_filter_without_separator(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let file = root.join("file"); - let output = render( - &mut env, - "suffix_plain", - "{{ path | with_suffix('.log') }}", - &file, - ); - assert_eq!(output, root.join("file.log").as_str()); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file"); + let output = render( + env, + "suffix_plain", + "{{ path | with_suffix('.log') }}", + &file, + ); + assert_eq!(output, root.join("file.log").as_str()); + }); } #[rstest] fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = setup_filter_env(); - let file = root.join("file.tar.gz"); - assert_template_error( - &mut env, - "suffix_empty_sep", - "{{ path | with_suffix('.log', 1, '') }}", - json!({ - "path": file.as_str(), - }), - ErrorKind::InvalidOperation, - "non-empty separator", - ); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file.tar.gz"); + assert_template_error( + env, + TemplateErrorSpec { + name: "suffix_empty_sep", + template: "{{ path | with_suffix('.log', 1, '') }}", + context: json!({ + "path": file.as_str(), + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "non-empty separator", + }, + }, + ); + }); } #[rstest] fn with_suffix_filter_excessive_count(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let file = root.join("file.tar.gz"); - let output = render( - &mut env, - "suffix_excessive", - "{{ path | with_suffix('.bak', 5) }}", - &file, - ); - assert_eq!(output, root.join("file.bak").as_str()); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file.tar.gz"); + let output = render( + env, + "suffix_excessive", + "{{ path | with_suffix('.bak', 5) }}", + &file, + ); + assert_eq!(output, root.join("file.bak").as_str()); + }); } #[cfg(unix)] #[rstest] fn realpath_filter(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let link = root.join("link"); - let output = render(&mut env, "realpath", "{{ path | realpath }}", &link); - assert_eq!(output, root.join("file").as_str()); + with_filter_env(filter_workspace, |root, env| { + let link = root.join("link"); + let output = render(env, "realpath", "{{ path | realpath }}", &link); + assert_eq!(output, root.join("file").as_str()); + }); } #[cfg(unix)] #[rstest] fn realpath_filter_missing_path(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - env.add_template("realpath_missing", "{{ path | realpath }}") - .expect("template"); - let template = env.get_template("realpath_missing").expect("get template"); - let missing = root.join("missing"); - let result = template.render(context!(path => missing.as_str())); - let err = result.expect_err("realpath should error for missing path"); - assert_eq!(err.kind(), ErrorKind::InvalidOperation); - assert!( - err.to_string().contains("not found"), - "error should mention missing path", - ); + with_filter_env(filter_workspace, |root, env| { + let missing = root.join("missing"); + assert_template_error( + env, + TemplateErrorSpec { + name: "realpath_missing", + template: "{{ path | realpath }}", + context: json!({ + "path": missing.as_str(), + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "not found", + }, + }, + ); + }); } #[cfg(unix)] #[rstest] fn realpath_filter_root_path(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = stdlib_env(); - let root_path = root - .ancestors() - .find(|candidate| candidate.parent().is_none()) - .map(Utf8Path::to_path_buf) - .expect("root ancestor"); - assert!( - !root_path.as_str().is_empty(), - "root path should not be empty", - ); - let output = render( - &mut env, - "realpath_root", - "{{ path | realpath }}", - &root_path, - ); - assert_eq!(output, root_path.as_str()); + with_filter_env(filter_workspace, |root, env| { + let root_path = root + .ancestors() + .find(|candidate| candidate.parent().is_none()) + .map(Utf8Path::to_path_buf) + .expect("root ancestor"); + assert!( + !root_path.as_str().is_empty(), + "root path should not be empty", + ); + let output = render(env, "realpath_root", "{{ path | realpath }}", &root_path); + assert_eq!(output, root_path.as_str()); + }); } #[rstest] fn expanduser_filter(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - with_clean_env_vars(Some(root.as_str()), || { - let mut env = setup_filter_env(); - let home = render( - &mut env, - "expanduser", - "{{ path | expanduser }}", - &Utf8PathBuf::from("~/workspace"), - ); - assert_eq!(home, root.join("workspace").as_str()); + with_filter_env(filter_workspace, |root, env| { + with_clean_env_vars(Some(root.as_str()), || { + let home = render( + env, + "expanduser", + "{{ path | expanduser }}", + &Utf8PathBuf::from("~/workspace"), + ); + assert_eq!(home, root.join("workspace").as_str()); + }); }); } #[rstest] fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - let mut env = setup_filter_env(); - let file = root.join("file"); - let output = render( - &mut env, - "expanduser_plain", - "{{ path | expanduser }}", - &file, - ); - assert_eq!(output, file.as_str()); + with_filter_env(filter_workspace, |root, env| { + let file = root.join("file"); + let output = render(env, "expanduser_plain", "{{ path | expanduser }}", &file); + assert_eq!(output, file.as_str()); + }); } #[rstest] fn expanduser_filter_missing_home(filter_workspace: Workspace) { - let (_temp, _root) = filter_workspace; - with_clean_env_vars(None, || { - let mut env = setup_filter_env(); - assert_template_error( - &mut env, - "expanduser_missing_home", - "{{ path | expanduser }}", - json!({ - "path": "~/workspace", - }), - ErrorKind::InvalidOperation, - "no home directory environment variables are set", - ); + with_filter_env(filter_workspace, |_root, env| { + with_clean_env_vars(None, || { + assert_template_error( + env, + TemplateErrorSpec { + name: "expanduser_missing_home", + template: "{{ path | expanduser }}", + context: json!({ + "path": "~/workspace", + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "no home directory environment variables are set", + }, + }, + ); + }); }); } #[rstest] fn expanduser_filter_user_specific(filter_workspace: Workspace) { - let (_temp, root) = filter_workspace; - with_clean_env_vars(Some(root.as_str()), || { - let mut env = setup_filter_env(); - assert_template_error( - &mut env, - "expanduser_user_specific", - "{{ path | expanduser }}", - json!({ - "path": "~otheruser/workspace", - }), - ErrorKind::InvalidOperation, - "user-specific ~ expansion is unsupported", - ); + with_filter_env(filter_workspace, |root, env| { + with_clean_env_vars(Some(root.as_str()), || { + assert_template_error( + env, + TemplateErrorSpec { + name: "expanduser_user_specific", + template: "{{ path | expanduser }}", + context: json!({ + "path": "~otheruser/workspace", + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "user-specific ~ expansion is unsupported", + }, + }, + ); + }); }); } From 958c55f278b73c7a8de2ad5ee9d8b93f95f4ce40 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 14:38:46 +0100 Subject: [PATCH 18/21] Document stdlib path filter modules Align module headers and design notes with the review guidance, clarifying ambient file-system access for the contents filter and the cucumber steps. --- docs/netsuke-design.md | 9 ++++++--- src/stdlib/path/filters.rs | 6 +++++- src/stdlib/path/fs_utils.rs | 3 ++- tests/steps/stdlib_steps.rs | 7 ++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index b295844a..ef66bdc7 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -869,12 +869,15 @@ place of `camelCase` so naming remains consistent with `snake_case` and Implementation notes: -- Filters use `cap-std` directories for all filesystem work, avoiding ambient - authority. +- Filters rely on `cap-std` directories opened with ambient authority for + file-system work. Callers must ensure that templates granted access to the + stdlib are trusted to read from the process' working tree. - `realpath` canonicalises the parent directory before joining the resolved entry so results are absolute and symlink-free. - `contents` and `linecount` currently support UTF-8 input; other encodings are - rejected with an explicit error. + rejected with an explicit error. `contents` streams data from the ambient + file-system, so consumers should guard access carefully when evaluating + untrusted templates. - `hash` and `digest` accept `sha256` (default) and `sha512`. Legacy algorithms `sha1` and `md5` are cryptographically broken and are disabled by default; enabling them requires the `legacy-digests` Cargo feature and should diff --git a/src/stdlib/path/filters.rs b/src/stdlib/path/filters.rs index 2adbf1fc..5dbf2466 100644 --- a/src/stdlib/path/filters.rs +++ b/src/stdlib/path/filters.rs @@ -1,4 +1,8 @@ -//! Filter registration wiring for stdlib path helpers. +//! Registration of stdlib path and file filters for `MiniJinja`. +//! +//! Exposes filters such as `basename`, `dirname`, `with_suffix`, +//! `relative_to`, `realpath`, `expanduser`, `size`, `contents`, +//! `linecount`, `hash`, and `digest`. use camino::Utf8Path; use minijinja::{Environment, Error, ErrorKind}; diff --git a/src/stdlib/path/fs_utils.rs b/src/stdlib/path/fs_utils.rs index 1afcd79c..3c2b7b48 100644 --- a/src/stdlib/path/fs_utils.rs +++ b/src/stdlib/path/fs_utils.rs @@ -1,4 +1,5 @@ -//! File-system helpers for stdlib path filters: open dirs and files via cap-std. +//! UTF-8 file-system helpers for stdlib filters using cap-std Dir handles: metadata queries, +//! opening files for streaming, and safe error translation. use std::io; use camino::{Utf8Path, Utf8PathBuf}; diff --git a/tests/steps/stdlib_steps.rs b/tests/steps/stdlib_steps.rs index 510b98bf..73bd81ed 100644 --- a/tests/steps/stdlib_steps.rs +++ b/tests/steps/stdlib_steps.rs @@ -1,6 +1,7 @@ -//! Behavioural steps exercising the template stdlib path and file filters. -//! Prepare a temporary workspace, render templates with stdlib registration, -//! and assert expected outputs or `MiniJinja` errors. +//! Cucumber step implementations for stdlib path and file filters. +//! +//! Sets up a temporary workspace, renders templates with stdlib registered, +//! and asserts outputs and errors. use crate::CliWorld; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; From 2410861337745a359e83e73b24fa2a4707c644a7 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 15:40:29 +0100 Subject: [PATCH 19/21] Refine stdlib path filter test helpers --- tests/std_filter_tests/path_filters.rs | 209 ++++++++++++++----------- 1 file changed, 121 insertions(+), 88 deletions(-) diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs index 86ddf05b..9592a04a 100644 --- a/tests/std_filter_tests/path_filters.rs +++ b/tests/std_filter_tests/path_filters.rs @@ -70,6 +70,64 @@ fn assert_template_error(env: &mut Environment<'_>, spec: TemplateErrorSpec<'_>) ); } +fn assert_filter_error_with_env( + filter_workspace: Workspace, + home_value: Option<&str>, + spec_builder: F, +) where + F: for<'a> FnOnce(&'a Utf8Path) -> TemplateErrorSpec<'a>, +{ + with_filter_env(filter_workspace, |root, env| { + let home = home_value.map(|value| { + if value.is_empty() { + root.as_str() + } else { + value + } + }); + with_clean_env_vars(home, || { + let spec = spec_builder(root); + assert_template_error(env, spec); + }); + }); +} + +fn assert_filter_error_simple(filter_workspace: Workspace, spec_builder: F) +where + F: for<'a> FnOnce(&'a Utf8Path) -> TemplateErrorSpec<'a>, +{ + with_filter_env(filter_workspace, |root, env| { + let spec = spec_builder(root); + assert_template_error(env, spec); + }); +} + +fn assert_filter_success_with_env( + filter_workspace: Workspace, + home_value: Option<&str>, + name: &'static str, + template: &'static str, + path: &Utf8PathBuf, + expected: F, +) where + F: FnOnce(&Utf8Path) -> String, +{ + with_filter_env(filter_workspace, |root, env| { + let home = home_value.map(|value| { + if value.is_empty() { + root.as_str() + } else { + value + } + }); + with_clean_env_vars(home, || { + let result = render(env, name, template, path); + let expected_value = expected(root); + assert_eq!(result, expected_value); + }); + }); +} + #[rstest] fn dirname_filter(filter_workspace: Workspace) { with_filter_env(filter_workspace, |root, env| { @@ -99,24 +157,21 @@ fn relative_to_filter(filter_workspace: Workspace) { #[rstest] fn relative_to_filter_outside_root(filter_workspace: Workspace) { - with_filter_env(filter_workspace, |root, env| { + assert_filter_error_simple(filter_workspace, |root| { let file = root.join("file"); let other_root = root.join("other"); - assert_template_error( - env, - TemplateErrorSpec { - name: "relative_to_fail", - template: "{{ path | relative_to(root) }}", - context: json!({ - "path": file.as_str(), - "root": other_root.as_str(), - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "is not relative", - }, + TemplateErrorSpec { + name: "relative_to_fail", + template: "{{ path | relative_to(root) }}", + context: json!({ + "path": file.as_str(), + "root": other_root.as_str(), + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "is not relative", }, - ); + } }); } @@ -163,22 +218,19 @@ fn with_suffix_filter_without_separator(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { - with_filter_env(filter_workspace, |root, env| { + assert_filter_error_simple(filter_workspace, |root| { let file = root.join("file.tar.gz"); - assert_template_error( - env, - TemplateErrorSpec { - name: "suffix_empty_sep", - template: "{{ path | with_suffix('.log', 1, '') }}", - context: json!({ - "path": file.as_str(), - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "non-empty separator", - }, + TemplateErrorSpec { + name: "suffix_empty_sep", + template: "{{ path | with_suffix('.log', 1, '') }}", + context: json!({ + "path": file.as_str(), + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "non-empty separator", }, - ); + } }); } @@ -209,22 +261,19 @@ fn realpath_filter(filter_workspace: Workspace) { #[cfg(unix)] #[rstest] fn realpath_filter_missing_path(filter_workspace: Workspace) { - with_filter_env(filter_workspace, |root, env| { + assert_filter_error_simple(filter_workspace, |root| { let missing = root.join("missing"); - assert_template_error( - env, - TemplateErrorSpec { - name: "realpath_missing", - template: "{{ path | realpath }}", - context: json!({ - "path": missing.as_str(), - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "not found", - }, + TemplateErrorSpec { + name: "realpath_missing", + template: "{{ path | realpath }}", + context: json!({ + "path": missing.as_str(), + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "not found", }, - ); + } }); } @@ -248,17 +297,15 @@ fn realpath_filter_root_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter(filter_workspace: Workspace) { - with_filter_env(filter_workspace, |root, env| { - with_clean_env_vars(Some(root.as_str()), || { - let home = render( - env, - "expanduser", - "{{ path | expanduser }}", - &Utf8PathBuf::from("~/workspace"), - ); - assert_eq!(home, root.join("workspace").as_str()); - }); - }); + let path = Utf8PathBuf::from("~/workspace"); + assert_filter_success_with_env( + filter_workspace, + Some(""), + "expanduser", + "{{ path | expanduser }}", + &path, + |root| root.join("workspace").as_str().to_owned(), + ); } #[rstest] @@ -272,44 +319,30 @@ fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter_missing_home(filter_workspace: Workspace) { - with_filter_env(filter_workspace, |_root, env| { - with_clean_env_vars(None, || { - assert_template_error( - env, - TemplateErrorSpec { - name: "expanduser_missing_home", - template: "{{ path | expanduser }}", - context: json!({ - "path": "~/workspace", - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "no home directory environment variables are set", - }, - }, - ); - }); + assert_filter_error_with_env(filter_workspace, None, |_root| TemplateErrorSpec { + name: "expanduser_missing_home", + template: "{{ path | expanduser }}", + context: json!({ + "path": "~/workspace", + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "no home directory environment variables are set", + }, }); } #[rstest] fn expanduser_filter_user_specific(filter_workspace: Workspace) { - with_filter_env(filter_workspace, |root, env| { - with_clean_env_vars(Some(root.as_str()), || { - assert_template_error( - env, - TemplateErrorSpec { - name: "expanduser_user_specific", - template: "{{ path | expanduser }}", - context: json!({ - "path": "~otheruser/workspace", - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "user-specific ~ expansion is unsupported", - }, - }, - ); - }); + assert_filter_error_with_env(filter_workspace, Some(""), |_root| TemplateErrorSpec { + name: "expanduser_user_specific", + template: "{{ path | expanduser }}", + context: json!({ + "path": "~otheruser/workspace", + }), + expectation: TemplateErrorExpectation { + kind: ErrorKind::InvalidOperation, + contains: "user-specific ~ expansion is unsupported", + }, }); } From 86b7754e492dee959c3742f1f388b9ea4f1b758a Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 16:29:09 +0100 Subject: [PATCH 20/21] Consolidate path filter error tests --- tests/std_filter_tests/path_filters.rs | 168 +++++++++++++++++-------- 1 file changed, 116 insertions(+), 52 deletions(-) diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs index 9592a04a..8dc0c790 100644 --- a/tests/std_filter_tests/path_filters.rs +++ b/tests/std_filter_tests/path_filters.rs @@ -128,6 +128,70 @@ fn assert_filter_success_with_env( }); } +/// Test data for filter error tests +struct FilterErrorTest { + name: &'static str, + template: &'static str, + context: Value, + error_kind: ErrorKind, + error_contains: &'static str, + env_setup: Option, +} + +#[derive(Debug, Clone, Copy)] +enum EnvironmentSetup { + SetHome, + RemoveHome, +} + +/// Unified helper for all filter error tests +fn test_filter_error(filter_workspace: Workspace, test: FilterErrorTest) { + let FilterErrorTest { + name, + template, + context, + error_kind, + error_contains, + env_setup, + } = test; + + if let Some(EnvironmentSetup::SetHome) = env_setup { + assert_filter_error_with_env(filter_workspace, Some(""), move |_root| TemplateErrorSpec { + name, + template, + context: context.clone(), + expectation: TemplateErrorExpectation { + kind: error_kind, + contains: error_contains, + }, + }); + return; + } + + if let Some(EnvironmentSetup::RemoveHome) = env_setup { + assert_filter_error_with_env(filter_workspace, None, move |_root| TemplateErrorSpec { + name, + template, + context: context.clone(), + expectation: TemplateErrorExpectation { + kind: error_kind, + contains: error_contains, + }, + }); + return; + } + + assert_filter_error_simple(filter_workspace, move |_root| TemplateErrorSpec { + name, + template, + context, + expectation: TemplateErrorExpectation { + kind: error_kind, + contains: error_contains, + }, + }); +} + #[rstest] fn dirname_filter(filter_workspace: Workspace) { with_filter_env(filter_workspace, |root, env| { @@ -157,22 +221,20 @@ fn relative_to_filter(filter_workspace: Workspace) { #[rstest] fn relative_to_filter_outside_root(filter_workspace: Workspace) { - assert_filter_error_simple(filter_workspace, |root| { - let file = root.join("file"); - let other_root = root.join("other"); - TemplateErrorSpec { + test_filter_error( + filter_workspace, + FilterErrorTest { name: "relative_to_fail", template: "{{ path | relative_to(root) }}", context: json!({ - "path": file.as_str(), - "root": other_root.as_str(), + "path": "/some/outside/path", + "root": "workspace", }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "is not relative", - }, - } - }); + error_kind: ErrorKind::InvalidOperation, + error_contains: "is not relative", + env_setup: None, + }, + ); } #[rstest] @@ -218,20 +280,19 @@ fn with_suffix_filter_without_separator(filter_workspace: Workspace) { #[rstest] fn with_suffix_filter_empty_separator(filter_workspace: Workspace) { - assert_filter_error_simple(filter_workspace, |root| { - let file = root.join("file.tar.gz"); - TemplateErrorSpec { + test_filter_error( + filter_workspace, + FilterErrorTest { name: "suffix_empty_sep", template: "{{ path | with_suffix('.log', 1, '') }}", context: json!({ - "path": file.as_str(), + "path": "file.tar.gz", }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "non-empty separator", - }, - } - }); + error_kind: ErrorKind::InvalidOperation, + error_contains: "non-empty separator", + env_setup: None, + }, + ); } #[rstest] @@ -261,20 +322,19 @@ fn realpath_filter(filter_workspace: Workspace) { #[cfg(unix)] #[rstest] fn realpath_filter_missing_path(filter_workspace: Workspace) { - assert_filter_error_simple(filter_workspace, |root| { - let missing = root.join("missing"); - TemplateErrorSpec { + test_filter_error( + filter_workspace, + FilterErrorTest { name: "realpath_missing", template: "{{ path | realpath }}", context: json!({ - "path": missing.as_str(), + "path": "missing_file.txt", }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "not found", - }, - } - }); + error_kind: ErrorKind::InvalidOperation, + error_contains: "not found", + env_setup: None, + }, + ); } #[cfg(unix)] @@ -319,30 +379,34 @@ fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { #[rstest] fn expanduser_filter_missing_home(filter_workspace: Workspace) { - assert_filter_error_with_env(filter_workspace, None, |_root| TemplateErrorSpec { - name: "expanduser_missing_home", - template: "{{ path | expanduser }}", - context: json!({ - "path": "~/workspace", - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "no home directory environment variables are set", + test_filter_error( + filter_workspace, + FilterErrorTest { + name: "expanduser_missing_home", + template: "{{ path | expanduser }}", + context: json!({ + "path": "~/workspace", + }), + error_kind: ErrorKind::InvalidOperation, + error_contains: "no home directory environment variables are set", + env_setup: Some(EnvironmentSetup::RemoveHome), }, - }); + ); } #[rstest] fn expanduser_filter_user_specific(filter_workspace: Workspace) { - assert_filter_error_with_env(filter_workspace, Some(""), |_root| TemplateErrorSpec { - name: "expanduser_user_specific", - template: "{{ path | expanduser }}", - context: json!({ - "path": "~otheruser/workspace", - }), - expectation: TemplateErrorExpectation { - kind: ErrorKind::InvalidOperation, - contains: "user-specific ~ expansion is unsupported", + test_filter_error( + filter_workspace, + FilterErrorTest { + name: "expanduser_user_specific", + template: "{{ path | expanduser }}", + context: json!({ + "path": "~otheruser/workspace", + }), + error_kind: ErrorKind::InvalidOperation, + error_contains: "user-specific ~ expansion is unsupported", + env_setup: Some(EnvironmentSetup::SetHome), }, - }); + ); } From f9c9f16d60b764af0b6018c4386db710eba3b38c Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 16:32:56 +0100 Subject: [PATCH 21/21] Simplify stdlib template helper and hash dispatch --- Cargo.lock | 14 +++++++ Cargo.toml | 2 +- src/stdlib/path/hash_utils.rs | 67 ++++++++++++++++--------------- tests/std_filter_tests/support.rs | 27 ++----------- 4 files changed, 53 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2429a3a1..55d37d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -916,6 +916,12 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "miette" version = "7.6.0" @@ -952,6 +958,8 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e60ac08614cc09062820e51d5d94c2fce16b94ea4e5003bb81b99a95f84e876" dependencies = [ + "memo-map", + "self_cell", "serde", ] @@ -1487,6 +1495,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "self_cell" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" + [[package]] name = "semver" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index c25e1382..9c35b89a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ legacy-digests = ["sha1", "md5"] clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_yml = "0.0.12" -minijinja = "2.11.0" +minijinja = { version = "2.11.0", features = ["loader"] } cap-std = { version = "3.4.4", features = ["fs_utf8"] } camino = "1.2.0" semver = { version = "1", features = ["serde"] } diff --git a/src/stdlib/path/hash_utils.rs b/src/stdlib/path/hash_utils.rs index c3b42bd0..4f6ee70f 100644 --- a/src/stdlib/path/hash_utils.rs +++ b/src/stdlib/path/hash_utils.rs @@ -1,4 +1,8 @@ -//! Hash utilities for stdlib path filters: stream digests with cap-std handles. +//! Hash utilities for stdlib path filters. +//! +//! Streams SHA-256 and SHA-512 digests via cap-std handles, +//! enables SHA-1 and MD5 behind the `legacy-digests` feature, +//! and always returns lowercase hexadecimal output. use std::io::Read; use camino::Utf8Path; @@ -13,42 +17,41 @@ use sha2::{Sha256, Sha512}; use super::{fs_utils, io_helpers::io_to_error}; pub(super) fn compute_hash(path: &Utf8Path, alg: &str) -> Result { - match alg.to_ascii_lowercase().as_str() { - "sha256" => hash_stream::(path), - "sha512" => hash_stream::(path), - "sha1" => { - #[cfg(feature = "legacy-digests")] - { - hash_stream::(path) - } - #[cfg(not(feature = "legacy-digests"))] - { - Err(Error::new( - ErrorKind::InvalidOperation, - "unsupported hash algorithm 'sha1' (enable feature 'legacy-digests')", - )) - } + if alg.eq_ignore_ascii_case("sha256") { + hash_stream::(path) + } else if alg.eq_ignore_ascii_case("sha512") { + hash_stream::(path) + } else if alg.eq_ignore_ascii_case("sha1") { + #[cfg(feature = "legacy-digests")] + { + hash_stream::(path) } - "md5" => { - #[cfg(feature = "legacy-digests")] - { - hash_stream::(path) - } - #[cfg(not(feature = "legacy-digests"))] - { - Err(Error::new( - ErrorKind::InvalidOperation, - "unsupported hash algorithm 'md5' (enable feature 'legacy-digests')", - )) - } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'sha1' (enable feature 'legacy-digests')", + )) } - other => Err(Error::new( + } else if alg.eq_ignore_ascii_case("md5") { + #[cfg(feature = "legacy-digests")] + { + hash_stream::(path) + } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'md5' (enable feature 'legacy-digests')", + )) + } + } else { + Err(Error::new( ErrorKind::InvalidOperation, - format!("unsupported hash algorithm '{other}'"), - )), + format!("unsupported hash algorithm '{alg}'"), + )) } } - pub(super) fn compute_digest(path: &Utf8Path, len: usize, alg: &str) -> Result { let mut hash = compute_hash(path, alg)?; if len < hash.len() { diff --git a/tests/std_filter_tests/support.rs b/tests/std_filter_tests/support.rs index 8d710e30..d90d3723 100644 --- a/tests/std_filter_tests/support.rs +++ b/tests/std_filter_tests/support.rs @@ -1,5 +1,3 @@ -use std::cell::RefCell; - use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, context}; @@ -11,33 +9,14 @@ pub(crate) use test_support::{EnvVarGuard, env_lock::EnvLock}; pub(crate) type Workspace = (tempfile::TempDir, Utf8PathBuf); -thread_local! { - static TEMPLATE_STORAGE: RefCell, Box)>> = const { RefCell::new(Vec::new()) }; -} - pub(crate) fn register_template( env: &mut Environment<'_>, name: impl Into, source: impl Into, ) { - TEMPLATE_STORAGE.with(|storage| { - let (name_ptr, source_ptr) = { - let mut storage = storage.borrow_mut(); - storage.push((name.into().into_boxed_str(), source.into().into_boxed_str())); - let (name, source) = storage.last().expect("template storage entry"); - ( - std::ptr::from_ref(name.as_ref()), - std::ptr::from_ref(source.as_ref()), - ) - }; - // SAFETY: the pointers originate from boxed strings stored in the - // thread-local registry. They remain valid for the duration of the - // process, so treating them as `'static` references is sound. - unsafe { - env.add_template(&*name_ptr, &*source_ptr) - .expect("template"); - } - }); + let name = name.into(); + let source = source.into(); + env.add_template_owned(name, source).expect("template"); } pub(crate) fn stdlib_env() -> Environment<'static> {