diff --git a/Cargo.lock b/Cargo.lock index 76397f21..55d37d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -900,12 +900,28 @@ 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" 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" @@ -942,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", ] @@ -1019,10 +1037,12 @@ dependencies = [ "clap", "clap_mangen", "cucumber", + "digest", "glob", "insta", "itertools 0.12.1", "itoa", + "md-5", "miette", "minijinja", "mockable", @@ -1036,6 +1056,7 @@ dependencies = [ "serde_json_canonicalizer", "serde_yml", "serial_test", + "sha1", "sha2", "shell-quote", "shlex", @@ -1474,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" @@ -1566,6 +1593,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..9c35b89a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,18 +12,25 @@ include = [ ] license = "ISC" +[features] +default = [] +legacy-digests = ["sha1", "md5"] + [dependencies] 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.1.12" +camino = "1.2.0" 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..ef66bdc7 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,27 @@ 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 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. `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 + 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/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.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/filters.rs b/src/stdlib/path/filters.rs new file mode 100644 index 00000000..5dbf2466 --- /dev/null +++ b/src/stdlib/path/filters.rs @@ -0,0 +1,78 @@ +//! 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}; + +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)) + }); + // 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 { + 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..3c2b7b48 --- /dev/null +++ b/src/stdlib/path/fs_utils.rs @@ -0,0 +1,81 @@ +//! 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}; +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..4f6ee70f --- /dev/null +++ b/src/stdlib/path/hash_utils.rs @@ -0,0 +1,90 @@ +//! 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; +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 { + 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) + } + #[cfg(not(feature = "legacy-digests"))] + { + Err(Error::new( + ErrorKind::InvalidOperation, + "unsupported hash algorithm 'sha1' (enable feature 'legacy-digests')", + )) + } + } 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 '{alg}'"), + )) + } +} +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 new file mode 100644 index 00000000..8e3e70f4 --- /dev/null +++ b/src/stdlib/path/io_helpers.rs @@ -0,0 +1,94 @@ +//! IO error adapters for the stdlib path filters. +//! Convert `io::Error` values into `MiniJinja` `InvalidOperation` diagnostics with human-readable labels. +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", + } +} + +#[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..e3781db2 --- /dev/null +++ b/src/stdlib/path/mod.rs @@ -0,0 +1,12 @@ +//! 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; +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..ffa10178 --- /dev/null +++ b/src/stdlib/path/path_utils.rs @@ -0,0 +1,155 @@ +//! 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}; + +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 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) { + 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 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 dir = Dir::open_ambient_dir(".", ambient_authority())?; + dir.canonicalize(Utf8Path::new(".")) +} + +#[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..fc748e07 --- /dev/null +++ b/tests/features/stdlib.feature @@ -0,0 +1,49 @@ +Feature: Template stdlib filters + 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 + 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 new file mode 100644 index 00000000..8c89f61f --- /dev/null +++ b/tests/std_filter_tests.rs @@ -0,0 +1,8 @@ +#[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/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..8dc0c790 --- /dev/null +++ b/tests/std_filter_tests/path_filters.rs @@ -0,0 +1,412 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, ErrorKind}; +use rstest::rstest; +use serde_json::{Value, json}; + +use super::support::{ + 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() +} + +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<'_>, 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(), expectation.kind); + assert!( + err.to_string().contains(expectation.contains), + "error should mention {}: {err}", + expectation.contains + ); +} + +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); + }); + }); +} + +/// 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| { + 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) { + 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) { + test_filter_error( + filter_workspace, + FilterErrorTest { + name: "relative_to_fail", + template: "{{ path | relative_to(root) }}", + context: json!({ + "path": "/some/outside/path", + "root": "workspace", + }), + error_kind: ErrorKind::InvalidOperation, + error_contains: "is not relative", + env_setup: None, + }, + ); +} + +#[rstest] +fn with_suffix_filter(filter_workspace: Workspace) { + 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) { + 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) { + test_filter_error( + filter_workspace, + FilterErrorTest { + name: "suffix_empty_sep", + template: "{{ path | with_suffix('.log', 1, '') }}", + context: json!({ + "path": "file.tar.gz", + }), + error_kind: ErrorKind::InvalidOperation, + error_contains: "non-empty separator", + env_setup: None, + }, + ); +} + +#[rstest] +fn with_suffix_filter_excessive_count(filter_workspace: Workspace) { + 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) { + 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) { + test_filter_error( + filter_workspace, + FilterErrorTest { + name: "realpath_missing", + template: "{{ path | realpath }}", + context: json!({ + "path": "missing_file.txt", + }), + error_kind: ErrorKind::InvalidOperation, + error_contains: "not found", + env_setup: None, + }, + ); +} + +#[cfg(unix)] +#[rstest] +fn realpath_filter_root_path(filter_workspace: Workspace) { + 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 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] +fn expanduser_filter_non_tilde_path(filter_workspace: Workspace) { + 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) { + 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) { + 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), + }, + ); +} diff --git a/tests/std_filter_tests/support.rs b/tests/std_filter_tests/support.rs new file mode 100644 index 00000000..d90d3723 --- /dev/null +++ b/tests/std_filter_tests/support.rs @@ -0,0 +1,53 @@ +use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::{Environment, context}; +use netsuke::stdlib; +use rstest::fixture; +use tempfile::tempdir; + +pub(crate) use test_support::{EnvVarGuard, env_lock::EnvLock}; + +pub(crate) type Workspace = (tempfile::TempDir, Utf8PathBuf); + +pub(crate) fn register_template( + env: &mut Environment<'_>, + name: impl Into, + source: impl Into, +) { + let name = name.into(); + let source = source.into(); + env.add_template_owned(name, source).expect("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"); + 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") +} 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..73bd81ed --- /dev/null +++ b/tests/steps/stdlib_steps.rs @@ -0,0 +1,242 @@ +//! 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}; +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 +", +); + +#[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(); + } + 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"); + 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 +} + +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.as_str(), context!(path => path.as_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); + world.stdlib_root = Some(root); +} + +#[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_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_path.as_path(), file_content.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); +} + +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: String) { + let root = ensure_workspace(world); + 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); +} + +#[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 + .as_ref() + .expect("expected stdlib output"); + 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) +} + +#[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!( + error.contains(&fragment), + "error `{error}` should contain `{fragment}`", + ); +} + +#[then("the stdlib output equals the workspace root")] +fn assert_stdlib_output_is_root(world: &mut CliWorld) { + let (root, output) = stdlib_root_and_output(world); + assert_eq!(output, root.as_str()); +} + +#[then(regex = r#"^the stdlib output is the workspace path "(.+)"$"#)] +fn assert_stdlib_output_is_workspace_path(world: &mut CliWorld, relative: String) { + let (root, output) = stdlib_root_and_output(world); + let relative_path = RelativePath::from(relative); + let expected = root.join(relative_path.to_path_buf()); + assert_eq!(output, expected.as_str()); +}