From c4ab10dc502b45c3864c018cc471997974ac0562 Mon Sep 17 00:00:00 2001 From: zdk Date: Mon, 17 Aug 2026 15:19:23 +0700 Subject: [PATCH] fix: sanitize .lf filter env, UTF-8 truncation panic, error context; add CI - .lf shell:/python: filters now run with env_clear + allowlist (sanitized_env moved to lowfat-core, re-exported by lowfat-plugin) - proc_token_budget: back cut up to char boundary (panic on multibyte) - main: print {e:#} so anyhow context chain reaches users - add CI workflow: fmt, clippy -D warnings, tests on ubuntu + macos - clear all clippy warnings; regression tests for env leak + UTF-8 - CONTRIBUTING: docker one-liner for Linux runs, five crates not four --- .github/workflows/ci.yml | 28 ++++++++++++++++ CONTRIBUTING.md | 8 ++++- crates/lowfat-compress/src/code.rs | 4 +-- crates/lowfat-compress/src/markdown.rs | 2 +- crates/lowfat-core/src/env.rs | 40 +++++++++++++++++++++++ crates/lowfat-core/src/lf.rs | 45 +++++++++++++++----------- crates/lowfat-core/src/lib.rs | 1 + crates/lowfat-core/src/pipeline.rs | 36 +++++++++++++-------- crates/lowfat-core/src/structured.rs | 1 + crates/lowfat-core/src/tee.rs | 2 +- crates/lowfat-core/src/tokens.rs | 2 +- crates/lowfat-plugin/src/security.rs | 38 ++-------------------- crates/lowfat/src/commands/audit.rs | 4 +-- crates/lowfat/src/commands/plugin.rs | 2 +- crates/lowfat/src/main.rs | 2 +- 15 files changed, 138 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 crates/lowfat-core/src/env.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2967fa3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aab67c0..3c69212 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,13 @@ cargo build cargo test --workspace ``` -The workspace has four crates: `lowfat-core`, `lowfat-plugin`, `lowfat-runner`, and the `lowfat` CLI. +To verify Linux from a Mac, run the suite in Docker (CI runs both): + +```sh +docker run --rm -v "$PWD":/work -w /work -e CARGO_TARGET_DIR=/tmp/target rust:1 cargo test --workspace +``` + +The workspace has five crates: `lowfat-core`, `lowfat-compress`, `lowfat-plugin`, `lowfat-runner`, and the `lowfat` CLI. ## Pull requests diff --git a/crates/lowfat-compress/src/code.rs b/crates/lowfat-compress/src/code.rs index 106d39f..7e4c592 100644 --- a/crates/lowfat-compress/src/code.rs +++ b/crates/lowfat-compress/src/code.rs @@ -61,11 +61,11 @@ fn strip_block_comments(content: &str, spec: &LangSpec) -> String { continue; } if let Some(q) = docstring { - if trimmed.starts_with(q) { + if let Some(rest) = trimmed.strip_prefix(q) { result.push_str(line); result.push('\n'); // Opener with no closer on the same line → swallow until it closes. - if !trimmed[q.len()..].contains(q) { + if !rest.contains(q) { in_doc = true; } continue; diff --git a/crates/lowfat-compress/src/markdown.rs b/crates/lowfat-compress/src/markdown.rs index eebd458..093ea23 100644 --- a/crates/lowfat-compress/src/markdown.rs +++ b/crates/lowfat-compress/src/markdown.rs @@ -103,7 +103,7 @@ fn full(content: &str) -> String { result.push_str(line); result.push('\n'); } else if table_rows == 11 { - result.push_str(&format!("| ... [more rows] |\n")); + result.push_str("| ... [more rows] |\n"); } continue; } else { diff --git a/crates/lowfat-core/src/env.rs b/crates/lowfat-core/src/env.rs new file mode 100644 index 0000000..94c4202 --- /dev/null +++ b/crates/lowfat-core/src/env.rs @@ -0,0 +1,40 @@ +//! Env allowlist for plugin subprocesses. Secrets (AWS keys, tokens, +//! API keys) must never reach plugin code — start from env_clear() and +//! pass only these through. + +use std::collections::HashSet; + +const SAFE_ENV_VARS: &[&str] = &[ + "LOWFAT_LEVEL", + "LOWFAT_COMMAND", + "LOWFAT_SUBCOMMAND", + "LOWFAT_EXIT_CODE", + "PATH", + "HOME", + "USER", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMPDIR", + "GIT_DIR", + "GIT_WORK_TREE", + "DOCKER_HOST", + "KUBECONFIG", + "GOPATH", + "GOROOT", + "CARGO_HOME", + "RUSTUP_HOME", + "NODE_PATH", + "NPM_CONFIG_PREFIX", + "VIRTUAL_ENV", + "PYTHONPATH", +]; + +pub fn sanitized_env() -> Vec<(String, String)> { + let safe: HashSet<&str> = SAFE_ENV_VARS.iter().copied().collect(); + std::env::vars() + .filter(|(k, _)| safe.contains(k.as_str())) + .collect() +} diff --git a/crates/lowfat-core/src/lf.rs b/crates/lowfat-core/src/lf.rs index 74c866b..b757366 100644 --- a/crates/lowfat-core/src/lf.rs +++ b/crates/lowfat-core/src/lf.rs @@ -562,10 +562,7 @@ impl<'a> Parser<'a> { /// Stops at first significant line whose indent <= parent_indent. fn parse_indented_ops(&mut self, parent_indent: usize) -> Result> { let mut ops = Vec::new(); - loop { - let Some(line) = self.peek_significant() else { - break; - }; + while let Some(line) = self.peek_significant() { if line.indent <= parent_indent { break; } @@ -598,10 +595,7 @@ impl<'a> Parser<'a> { fn parse_cascade(&mut self, parent_indent: usize) -> Result> { let mut branches: Vec = Vec::new(); let mut arm_indent: Option = None; - loop { - let Some(line) = self.peek_significant() else { - break; - }; + while let Some(line) = self.peek_significant() { if line.indent <= parent_indent { break; } @@ -721,10 +715,7 @@ impl<'a> Parser<'a> { let mut branches: Vec = Vec::new(); let mut arm_indent: Option = None; - loop { - let Some(line) = self.peek_significant() else { - break; - }; + while let Some(line) = self.peek_significant() { if line.indent <= parent_indent { break; } @@ -890,7 +881,7 @@ impl<'a> Parser<'a> { self.pos += 1; } // Trim trailing blank lines (they belong to the gap, not the body). - while collected.last().map_or(false, |l| l.text.is_empty()) { + while collected.last().is_some_and(|l| l.text.is_empty()) { collected.pop(); } if collected.is_empty() { @@ -917,10 +908,7 @@ impl<'a> Parser<'a> { fn parse_split_branches(&mut self, parent_indent: usize) -> Result<(Vec, Vec)> { let mut pre = Vec::new(); let mut post = Vec::new(); - loop { - let Some(line) = self.peek_significant() else { - break; - }; + while let Some(line) = self.peek_significant() { if line.indent != parent_indent { break; } @@ -1762,6 +1750,8 @@ fn run_filter_child( ctx: &ExecCtx, ) -> Result { let mut child = cmd + .env_clear() + .envs(crate::env::sanitized_env()) .env("level", ctx.level.to_string()) .env("sub", ctx.sub) .env("exit", ctx.exit_code.to_string()) @@ -2033,7 +2023,7 @@ diff, ultra: compact 30 else-shell: awk 'NF' | head -50 assert!(matches!(&ops[0], Op::MacroCall { name, .. } if name == "compact")); match &ops[1] { Op::OrShell(s) => assert_eq!(s, "awk 'NF' | head -50"), - _ => panic!("expected OrShell, got {:?}", &ops[1]), + _ => panic!("expected OrShell, got {:?}", ops[1]), } } @@ -2361,6 +2351,19 @@ build: assert_eq!(out, "build:ultra\n"); } + #[test] + fn exec_shell_does_not_see_parent_secrets() { + std::env::set_var("LF_TEST_FAKE_SECRET", "leaked"); + let rs = parse_ok( + r#" +build: + shell: printf '[%s]' "$LF_TEST_FAKE_SECRET" +"#, + ); + let out = execute(&rs, &ctx("build", Level::Full), "").unwrap(); + assert_eq!(out, "[]\n"); + } + #[test] fn exec_else_shell_uses_raw_input() { let rs = parse_ok( @@ -3012,7 +3015,11 @@ plan: #[test] fn absolute_include_path_rejected() { let d = tempfile::tempdir().unwrap(); - let root = write(d.path(), "main.lf", "include /etc/passwd.lf\n*:\n head 1\n"); + let root = write( + d.path(), + "main.lf", + "include /etc/passwd.lf\n*:\n head 1\n", + ); let err = format!("{:#}", load(&root).unwrap_err()); assert!(err.contains("must be relative"), "got: {err}"); } diff --git a/crates/lowfat-core/src/lib.rs b/crates/lowfat-core/src/lib.rs index 8758bc1..feb1299 100644 --- a/crates/lowfat-core/src/lib.rs +++ b/crates/lowfat-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod db; +pub mod env; pub mod level; pub mod lf; pub mod pipeline; diff --git a/crates/lowfat-core/src/pipeline.rs b/crates/lowfat-core/src/pipeline.rs index 2eb03c5..56d0fff 100644 --- a/crates/lowfat-core/src/pipeline.rs +++ b/crates/lowfat-core/src/pipeline.rs @@ -105,7 +105,7 @@ impl Pipeline { .split('|') .map(|s| s.trim()) .filter(|s| !s.is_empty()) - .map(|raw| parse_pipeline_stage(raw)) + .map(parse_pipeline_stage) .collect(); Pipeline { stages } } @@ -219,17 +219,15 @@ pub fn proc_strip_ansi(text: &str) -> String { let mut result = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { - if ch == '\x1b' { - if chars.peek() == Some(&'[') { + if ch == '\x1b' && chars.peek() == Some(&'[') { + chars.next(); + while let Some(&c) = chars.peek() { chars.next(); - while let Some(&c) = chars.peek() { - chars.next(); - if c.is_ascii_alphabetic() { - break; - } + if c.is_ascii_alphabetic() { + break; } - continue; } + continue; } result.push(ch); } @@ -257,8 +255,12 @@ pub fn proc_token_budget(text: &str, max_tokens: usize) -> String { return text.to_string(); } let ratio = max_tokens as f64 / current as f64; - let target_chars = (text.len() as f64 * ratio) as usize; - let mut result = text[..target_chars.min(text.len())].to_string(); + // Byte offset from a ratio — back up to a char boundary before slicing. + let mut cut = ((text.len() as f64 * ratio) as usize).min(text.len()); + while !text.is_char_boundary(cut) { + cut -= 1; + } + let mut result = text[..cut].to_string(); if let Some(pos) = result.rfind('\n') { result.truncate(pos); } @@ -385,7 +387,7 @@ pub fn proc_cut(text: &str, spec: &str) -> String { for &(start, end) in &ranges { let end = end.min(n); for i in start..=end { - if let Some(&field) = parts.get(i.checked_sub(1).unwrap_or(0)) { + if let Some(&field) = parts.get(i.saturating_sub(1)) { if i >= 1 { selected.push(field); } @@ -419,7 +421,7 @@ pub fn apply_builtin( Some(proc_truncate(text, limit)) } "token-budget" => { - let budget = param.unwrap_or_else(|| match level { + let budget = param.unwrap_or(match level { Level::Lite => 2000, Level::Full => 1000, Level::Ultra => 500, @@ -585,6 +587,14 @@ mod tests { assert!(result.contains("truncated to")); } + #[test] + fn token_budget_multibyte_no_panic() { + // cut lands mid-char without the boundary backoff (issue: panic on ✓/→/emoji) + let input = "✓".repeat(200); + let result = proc_token_budget(&input, 20); + assert!(result.contains("truncated to")); + } + #[test] fn dedup_blank() { let input = "line1\n\n\n\nline2\n\nline3"; diff --git a/crates/lowfat-core/src/structured.rs b/crates/lowfat-core/src/structured.rs index 3d4e637..22e8d27 100644 --- a/crates/lowfat-core/src/structured.rs +++ b/crates/lowfat-core/src/structured.rs @@ -29,6 +29,7 @@ fn caps(level: Level) -> (usize, usize) { } } +#[cfg(test)] fn is_valid_json(text: &str) -> bool { serde_json::from_str::(text.trim()).is_ok() } diff --git a/crates/lowfat-core/src/tee.rs b/crates/lowfat-core/src/tee.rs index 148c5e7..cebd49e 100644 --- a/crates/lowfat-core/src/tee.rs +++ b/crates/lowfat-core/src/tee.rs @@ -13,7 +13,7 @@ pub fn save_on_failure(tee_dir: &Path, label: &str, raw: &str, exit_code: i32) { return; } - let safe_label = label.replace(' ', "_").replace('/', "_"); + let safe_label = label.replace([' ', '/'], "_"); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) diff --git a/crates/lowfat-core/src/tokens.rs b/crates/lowfat-core/src/tokens.rs index 0c338da..ea02ce6 100644 --- a/crates/lowfat-core/src/tokens.rs +++ b/crates/lowfat-core/src/tokens.rs @@ -1,7 +1,7 @@ /// Estimate token count from text. ~4 chars = 1 token. /// Matches bash: `(len + 3) / 4` pub fn estimate_tokens(s: &str) -> usize { - (s.len() + 3) / 4 + s.len().div_ceil(4) } #[cfg(test)] diff --git a/crates/lowfat-plugin/src/security.rs b/crates/lowfat-plugin/src/security.rs index ae791b6..d72dce2 100644 --- a/crates/lowfat-plugin/src/security.rs +++ b/crates/lowfat-plugin/src/security.rs @@ -1,7 +1,6 @@ //! Plugin security: path traversal checks, hook validation, env sanitization, trust. use crate::manifest::PluginManifest; -use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; @@ -158,45 +157,14 @@ pub fn untrust_plugin(plugin_name: &str, lowfat_home: &Path) -> anyhow::Result<( // --- Environment sanitization --- -const SAFE_ENV_VARS: &[&str] = &[ - "LOWFAT_LEVEL", - "LOWFAT_COMMAND", - "LOWFAT_SUBCOMMAND", - "LOWFAT_EXIT_CODE", - "PATH", - "HOME", - "USER", - "SHELL", - "LANG", - "LC_ALL", - "LC_CTYPE", - "TERM", - "TMPDIR", - "GIT_DIR", - "GIT_WORK_TREE", - "DOCKER_HOST", - "KUBECONFIG", - "GOPATH", - "GOROOT", - "CARGO_HOME", - "RUSTUP_HOME", - "NODE_PATH", - "NPM_CONFIG_PREFIX", - "VIRTUAL_ENV", - "PYTHONPATH", -]; - -pub fn sanitized_env() -> Vec<(String, String)> { - let safe: HashSet<&str> = SAFE_ENV_VARS.iter().copied().collect(); - std::env::vars() - .filter(|(k, _)| safe.contains(k.as_str())) - .collect() -} +// Allowlist lives in lowfat-core so the .lf exec path can use it too. +pub use lowfat_core::env::sanitized_env; #[cfg(test)] mod tests { use super::*; use crate::manifest::PluginManifest; + use std::collections::HashSet; fn minimal_manifest(entry: &str) -> PluginManifest { let toml = format!( diff --git a/crates/lowfat/src/commands/audit.rs b/crates/lowfat/src/commands/audit.rs index 8a51e99..2f004a1 100644 --- a/crates/lowfat/src/commands/audit.rs +++ b/crates/lowfat/src/commands/audit.rs @@ -15,8 +15,8 @@ pub fn run(limit: usize) -> Result<()> { println!("Recent plugin activity:"); println!( - " {:20} {:20} {:8} {:10} {:12} {}", - "timestamp", "plugin", "runtime", "command", "action", "details" + " {:20} {:20} {:8} {:10} {:12} details", + "timestamp", "plugin", "runtime", "command", "action" ); println!(" {}", "-".repeat(90)); diff --git a/crates/lowfat/src/commands/plugin.rs b/crates/lowfat/src/commands/plugin.rs index 7db48cf..6ce094a 100644 --- a/crates/lowfat/src/commands/plugin.rs +++ b/crates/lowfat/src/commands/plugin.rs @@ -38,7 +38,7 @@ pub fn collect_bench_rows(plugin: &DiscoveredPlugin) -> Result> { let mut entries: Vec<_> = std::fs::read_dir(&samples_dir)? .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().map_or(false, |ext| ext == "txt")) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "txt")) .collect(); entries.sort_by_key(|e| e.path()); diff --git a/crates/lowfat/src/main.rs b/crates/lowfat/src/main.rs index 08edecf..280de33 100644 --- a/crates/lowfat/src/main.rs +++ b/crates/lowfat/src/main.rs @@ -309,7 +309,7 @@ fn main() { }; if let Err(e) = result { - eprintln!("lowfat: {e}"); + eprintln!("lowfat: {e:#}"); std::process::exit(1); } }