diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index ae475befbd..2d6461ac22 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -8,6 +8,7 @@ - `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. - `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization - `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module) +- `pipe_cmd.rs` (`rtk pipe -f `) applies one named filter to stdin. `` resolves to a built-in Rust filter first, then (if none matches) to a built-in or trusted-custom TOML filter from `core/toml_filter`; Rust names win on collision. Unknown or untrusted names error and list the available filters, and `RTK_NO_TOML=1` disables TOML resolution. ## Cross-command diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 563d54a10f..00fa0460a5 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -3,12 +3,20 @@ use std::io::Read; use crate::core::guard::never_worse; use crate::core::stream::RAW_CAP; +use crate::core::toml_filter::{self, CompiledFilter}; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; const MAX_PIPE_MATCHES: usize = CAP_WARNINGS; const MAX_PIPE_FILES: usize = CAP_WARNINGS; const MAX_PIPE_DIRS: usize = CAP_LIST; +/// A resolved pipe filter: either a built-in Rust function or a loaded +/// (built-in or trusted custom) TOML filter. +pub enum Filter { + Rust(fn(&str) -> String), + Toml(&'static CompiledFilter), +} + pub fn resolve_filter(name: &str) -> Option String> { match name { "cargo-test" | "cargo" => Some(crate::cmds::rust::cargo_cmd::filter_cargo_test), @@ -235,12 +243,40 @@ fn identity_filter(input: &str) -> String { input.to_string() } -fn apply_filter(filter_fn: fn(&str) -> String, input: &str) -> String { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| filter_fn(input))) - .unwrap_or_else(|_| { - eprintln!("[rtk] warning: filter panicked — passing through raw output"); - input.to_string() - }) +fn apply_filter(filter: &Filter, input: &str) -> String { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match filter { + Filter::Rust(f) => f(input), + Filter::Toml(cf) => toml_filter::apply_filter(cf, input), + })) + .unwrap_or_else(|_| { + eprintln!("[rtk] warning: filter panicked — passing through raw output"); + input.to_string() + }) +} + +const BUILTIN_PIPE_FILTERS: &str = "cargo-test (alias cargo), pytest, go-test, go-build, tsc, \ + vitest, grep, rg, find, fd, git-log, git-diff, git-status, log, mypy, ruff-check, \ + ruff-format, prettier, phpunit, pest, paratest, php-test, ecs, phpstan, pint"; + +fn unknown_filter_message(name: &str) -> String { + let toml_names = if toml_filter::toml_disabled() { + Vec::new() + } else { + toml_filter::loaded_filter_names() + }; + if toml_names.is_empty() { + format!( + "Unknown filter '{}'. Built-in: {}", + name, BUILTIN_PIPE_FILTERS + ) + } else { + format!( + "Unknown filter '{}'. Built-in: {}. TOML (built-in + trusted custom): {}", + name, + BUILTIN_PIPE_FILTERS, + toml_names.join(", ") + ) + } } pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { @@ -259,20 +295,24 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::bail!("stdin exceeds {} byte limit", RAW_CAP); } - let filter_fn = match filter_name { - Some(name) => resolve_filter(name).ok_or_else(|| { - anyhow::anyhow!( - "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ - tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - log, mypy, ruff-check, ruff-format, prettier, phpunit, pest, \ - paratest, php-test, ecs, phpstan, pint", - name - ) - })?, - None => auto_detect_filter(&buf), + let filter = match filter_name { + // Built-in Rust filters take precedence over a TOML filter of the same + // name (mirrors the hook's resolve order). Fall back to the TOML + // registry by name unless the TOML engine is disabled (RTK_NO_TOML=1). + Some(name) => match resolve_filter(name) { + Some(f) => Filter::Rust(f), + None if toml_filter::toml_disabled() => { + anyhow::bail!("{}", unknown_filter_message(name)) + } + None => match toml_filter::find_filter_by_name(name) { + Some(cf) => Filter::Toml(cf), + None => anyhow::bail!("{}", unknown_filter_message(name)), + }, + }, + None => Filter::Rust(auto_detect_filter(&buf)), }; - let output = apply_filter(filter_fn, &buf); + let output = apply_filter(&filter, &buf); let shown = never_worse(&buf, &output); print!("{}", shown); Ok(()) @@ -548,10 +588,32 @@ mod tests { panic!("filter bug"); } let input = "some output\n"; - let result = super::apply_filter(panicking_filter, input); + let result = super::apply_filter(&Filter::Rust(panicking_filter), input); assert_eq!(result, input); } + #[test] + fn test_unknown_filter_message_lists_builtin() { + let msg = super::unknown_filter_message("nope"); + assert!(msg.contains("Unknown filter 'nope'"), "msg={}", msg); + assert!(msg.contains("cargo-test"), "msg={}", msg); + } + + #[test] + fn test_toml_filter_resolves_by_name() { + // A built-in TOML filter must be resolvable by name and dispatch through + // the TOML pipeline (built-in filters are always trusted). + let cf = + toml_filter::find_filter_by_name("make").expect("built-in 'make' filter must load"); + let out = super::apply_filter(&Filter::Toml(cf), "make[1]: Entering directory '/x'\nreal"); + assert!(!out.is_empty(), "out={}", out); + } + + #[test] + fn test_unknown_toml_filter_name_returns_none() { + assert!(toml_filter::find_filter_by_name("definitely-not-a-filter").is_none()); + } + fn count_tokens(s: &str) -> usize { s.split_whitespace().count() } diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index b5a9be6ca8..366b600cac 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -801,6 +801,25 @@ pub fn find_matching_filter(command: &str) -> Option<&'static CompiledFilter> { result } +/// Find a loaded filter by its exact name, ignoring `match_command`. +/// +/// Only built-in TOML filters and trusted project/global custom filters are +/// present in the registry, so untrusted custom filters are never returned. +/// On duplicate names the load order wins (project > global > built-in), +/// matching the precedence used by `find_matching_filter`. +pub fn find_filter_by_name(name: &str) -> Option<&'static CompiledFilter> { + REGISTRY.filters.iter().find(|f| f.name == name) +} + +/// Sorted, de-duplicated names of every loaded filter (built-in + trusted +/// custom). Used to surface available filter names in error messages. +pub fn loaded_filter_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = REGISTRY.filters.iter().map(|f| f.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + names +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/tests/pipe_custom_filter_test.rs b/tests/pipe_custom_filter_test.rs new file mode 100644 index 0000000000..ad975e0936 --- /dev/null +++ b/tests/pipe_custom_filter_test.rs @@ -0,0 +1,123 @@ +#![cfg(unix)] +//! #2179: `rtk pipe -f ` must accept built-in TOML filters and trusted +//! project/global custom filters, not just the hard-coded Rust filter names. + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn write_probe_filter(dir: &std::path::Path) { + let rtk_dir = dir.join(".rtk"); + std::fs::create_dir_all(&rtk_dir).unwrap(); + std::fs::write( + rtk_dir.join("filters.toml"), + "schema_version = 1\n\ + [filters.probe-echo]\n\ + description = \"probe echo filter\"\n\ + match_command = \"^echo\"\n\ + strip_lines_matching = [\"^noise\"]\n", + ) + .unwrap(); +} + +const PROBE_INPUT: &str = "PROBE keep\nnoise a\nnoise b\nnoise c\nnoise d\nnoise e\n"; + +/// Run `rtk pipe -f ` in `dir` with `stdin`, returning (stdout, stderr, success). +fn run_pipe(dir: &std::path::Path, name: &str, stdin: &str, trust: bool) -> (String, String, bool) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rtk")); + cmd.args(["pipe", "-f", name]) + .current_dir(dir) + .env("HOME", dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if trust { + // Trust project filters without mutating the real trust store. + cmd.env("RTK_TRUST_PROJECT_FILTERS", "1").env("CI", "1"); + } + let mut child = cmd.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.success(), + ) +} + +#[test] +fn trusted_custom_filter_is_applied() { + let dir = tempfile::tempdir().unwrap(); + write_probe_filter(dir.path()); + let (stdout, _stderr, ok) = run_pipe(dir.path(), "probe-echo", PROBE_INPUT, true); + assert!(ok, "pipe should succeed for trusted custom filter"); + assert!(stdout.contains("PROBE keep"), "stdout={stdout}"); + assert!( + !stdout.contains("noise"), + "noise lines should be stripped: {stdout}" + ); +} + +#[test] +fn untrusted_custom_filter_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + write_probe_filter(dir.path()); + let (_stdout, stderr, ok) = run_pipe(dir.path(), "probe-echo", PROBE_INPUT, false); + assert!(!ok, "untrusted custom filter must not be accepted"); + assert!( + stderr.contains("Unknown filter 'probe-echo'"), + "stderr={stderr}" + ); +} + +#[test] +fn builtin_toml_filter_works_without_trust() { + // Built-in TOML filters (e.g. `make`) are always trusted and usable via -f. + let dir = tempfile::tempdir().unwrap(); + let input = "make[1]: Entering directory '/x'\ngcc -c foo.c\nmake[1]: Leaving directory '/x'\n"; + let (stdout, _stderr, ok) = run_pipe(dir.path(), "make", input, false); + assert!(ok, "built-in TOML filter should work without trust"); + assert!(!stdout.is_empty(), "stdout={stdout}"); +} + +#[test] +fn rtk_no_toml_disables_toml_filters_in_pipe() { + // RTK_NO_TOML=1 must bypass the TOML engine, so even a built-in TOML + // filter name becomes unknown via pipe -f. + let dir = tempfile::tempdir().unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rtk")); + cmd.args(["pipe", "-f", "make"]) + .current_dir(dir.path()) + .env("HOME", dir.path()) + .env("RTK_NO_TOML", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"make[1]: Entering directory '/x'\n") + .unwrap(); + let out = child.wait_with_output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "RTK_NO_TOML must reject TOML filters" + ); + assert!(stderr.contains("Unknown filter 'make'"), "stderr={stderr}"); +} + +#[test] +fn builtin_rust_filter_still_works() { + let dir = tempfile::tempdir().unwrap(); + let input = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub fn helper() {}\n"; + let (stdout, _stderr, ok) = run_pipe(dir.path(), "grep", input, false); + assert!(ok, "built-in Rust filter must keep working"); + assert!(stdout.contains("main.rs"), "stdout={stdout}"); +}