From 5241cd6acfa26f37888c9b346e7151e5d01d5dc3 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Thu, 6 Aug 2026 13:52:59 +0800 Subject: [PATCH 1/2] feat(terminal): redact copied command blocks --- src-tauri/src/ai/redact_rules.rs | 26 +- src-tauri/src/commands/command_block.rs | 73 ++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sync.rs | 1 + src-tauri/src/db/command_block_redact_rule.rs | 86 ++++ src-tauri/src/db/mod.rs | 1 + src-tauri/src/db/schema.rs | 117 +++++- src-tauri/src/lib.rs | 4 + src-tauri/src/redaction.rs | 52 +++ src-tauri/src/server.rs | 17 + src-tauri/src/sync/config.rs | 203 ++++++++- .../components/CommandBlockSettings.svelte | 393 +++++++++++++++++- src/lib/components/SyncScreen.svelte | 1 + src/lib/components/TerminalPane.svelte | 24 +- src/lib/i18n/locales/en.ts | 19 + src/lib/i18n/locales/zh.ts | 19 + src/lib/stores/app.svelte.ts | 89 ++++ src/lib/terminal/block-content.test.ts | 15 + src/lib/terminal/block-content.ts | 13 +- src/lib/terminal/block-to-image.test.ts | 102 +++++ src/lib/terminal/block-to-image.ts | 140 ++++++- .../terminal/command-block-redaction.test.ts | 55 +++ src/lib/terminal/command-block-redaction.ts | 142 +++++++ src/lib/terminal/prompt.test.ts | 37 ++ src/lib/terminal/prompt.ts | 44 ++ 25 files changed, 1633 insertions(+), 41 deletions(-) create mode 100644 src-tauri/src/commands/command_block.rs create mode 100644 src-tauri/src/db/command_block_redact_rule.rs create mode 100644 src-tauri/src/redaction.rs create mode 100644 src/lib/terminal/command-block-redaction.test.ts create mode 100644 src/lib/terminal/command-block-redaction.ts create mode 100644 src/lib/terminal/prompt.test.ts create mode 100644 src/lib/terminal/prompt.ts diff --git a/src-tauri/src/ai/redact_rules.rs b/src-tauri/src/ai/redact_rules.rs index 4460e2fc..ea819230 100644 --- a/src-tauri/src/ai/redact_rules.rs +++ b/src-tauri/src/ai/redact_rules.rs @@ -34,36 +34,12 @@ pub fn list(db: &Db) -> AppResult> { .collect()) } -/// 一个正则能否**零宽匹配**(匹配空串,或只由 `^`/`$`/`\b` 等断言构成)。 -/// 这类规则在 `replace_all` 时会在每个位置插入 replacement,造成灾难性的 -/// over-replacement / 文本膨胀。`Regex::new` 不拦这些(它们语法合法),所以 save -/// 时单独用 regex-syntax 的 `minimum_len()` 判最小匹配长度,==0 即零宽。 -fn matches_empty(pattern: &str) -> bool { - regex_syntax::Parser::new() - .parse(pattern) - // parse 失败的分支走不到(上游 Regex::new 已校验过可编译);兜底视为非零宽, - // 不误拒一条引擎接受的规则。 - .map(|hir| hir.properties().minimum_len() == Some(0)) - .unwrap_or(false) -} - /// 保存(新增或编辑)。两道 fail-fast 校验,绝不让坏规则入库 —— 否则建会话时 /// `compiled()` 会静默跳过它,用户以为脱敏生效其实没生效,这才是真正危险的 false sense。 /// 1. 正则可编译(坏语法 → redact_invalid_regex) /// 2. 非零宽匹配(`""`/`^`/`a*`/`\b` 等 → redact_zero_width_pattern) pub fn save(db: &Db, rec: &RedactRuleRecord) -> AppResult<()> { - RedactRule::new(&rec.pattern, &rec.replacement).map_err(|e| { - AppError::config( - "redact_invalid_regex", - json!({ "pattern": rec.pattern, "error": e.to_string() }), - ) - })?; - if matches_empty(&rec.pattern) { - return Err(AppError::config( - "redact_zero_width_pattern", - json!({ "pattern": rec.pattern }), - )); - } + crate::redaction::validate_pattern(&rec.pattern)?; ai_redact_rule::upsert( db, &ai_redact_rule::RedactRuleRow { diff --git a/src-tauri/src/commands/command_block.rs b/src-tauri/src/commands/command_block.rs new file mode 100644 index 00000000..b7b66bba --- /dev/null +++ b/src-tauri/src/commands/command_block.rs @@ -0,0 +1,73 @@ +use tauri::State; + +use crate::db::command_block_redact_rule::{self, RedactRuleRow}; +use crate::error::AppResult; +use crate::state::AppState; + +pub fn list_redact_rules(db: &crate::db::Db) -> AppResult> { + command_block_redact_rule::list(db) +} + +pub fn save_redact_rule(db: &crate::db::Db, rule: &RedactRuleRow) -> AppResult<()> { + crate::redaction::validate_pattern(&rule.pattern)?; + command_block_redact_rule::upsert(db, rule) +} + +pub fn delete_redact_rule(db: &crate::db::Db, id: &str) -> AppResult<()> { + command_block_redact_rule::delete(db, id) +} + +#[tauri::command] +pub fn command_block_list_redact_rules( + state: State<'_, AppState>, +) -> AppResult> { + list_redact_rules(&state.db) +} + +#[tauri::command] +pub fn command_block_save_redact_rule( + state: State<'_, AppState>, + id: String, + pattern: String, + replacement: String, +) -> AppResult<()> { + save_redact_rule( + &state.db, + &RedactRuleRow { + id, + pattern, + replacement, + }, + ) +} + +#[tauri::command] +pub fn command_block_delete_redact_rule(state: State<'_, AppState>, id: String) -> AppResult<()> { + delete_redact_rule(&state.db, &id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_rejects_invalid_and_zero_width_regex() { + let db = crate::db::Db::open_in_memory().unwrap(); + for (id, pattern, code) in [ + ("invalid", "(", "redact_invalid_regex"), + ("empty", "", "redact_zero_width_pattern"), + ] { + let err = save_redact_rule( + &db, + &RedactRuleRow { + id: id.into(), + pattern: pattern.into(), + replacement: "".into(), + }, + ) + .unwrap_err(); + assert_eq!(err.code(), code); + assert!(!list_redact_rules(&db).unwrap().iter().any(|r| r.id == id)); + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index cabb006d..038d8548 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ #[cfg(desktop)] pub mod cli; +pub mod command_block; pub mod discovery; pub mod external; pub mod forward; diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index 4976d6ff..6b6f5c2f 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -729,6 +729,7 @@ mod tests { "highlights", "snippets", "ai_redact_rules", + "command_block_redaction", "ai_command_blacklist", "ai", ] { diff --git a/src-tauri/src/db/command_block_redact_rule.rs b/src-tauri/src/db/command_block_redact_rule.rs new file mode 100644 index 00000000..34654074 --- /dev/null +++ b/src-tauri/src/db/command_block_redact_rule.rs @@ -0,0 +1,86 @@ +//! Command-block copy redaction persistence. Schema v26 creates this as a snapshot +//! of `ai_redact_rules`; subsequent edits are deliberately independent. + +use rusqlite::params; + +use crate::error::AppResult; + +use super::Db; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct RedactRuleRow { + pub id: String, + pub pattern: String, + pub replacement: String, +} + +pub fn list(db: &Db) -> AppResult> { + let conn = db.lock()?; + let mut stmt = conn.prepare( + "SELECT id, pattern, replacement + FROM command_block_redact_rules + ORDER BY created_at, id", + )?; + let rows = stmt + .query_map([], |row| { + Ok(RedactRuleRow { + id: row.get(0)?, + pattern: row.get(1)?, + replacement: row.get(2)?, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +pub fn upsert(db: &Db, rule: &RedactRuleRow) -> AppResult<()> { + let conn = db.lock()?; + let now = chrono::Utc::now().timestamp_millis(); + conn.execute( + "INSERT INTO command_block_redact_rules + (id, pattern, replacement, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4) + ON CONFLICT(id) DO UPDATE SET + pattern = excluded.pattern, + replacement = excluded.replacement, + updated_at = excluded.updated_at", + params![rule.id, rule.pattern, rule.replacement, now], + )?; + Ok(()) +} + +pub fn delete(db: &Db, id: &str) -> AppResult<()> { + let conn = db.lock()?; + conn.execute("DELETE FROM command_block_redact_rules WHERE id = ?1", [id])?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rules_are_independent_from_ai_rules() { + let db = Db::open_in_memory().unwrap(); + assert_eq!(list(&db).unwrap().len(), 8); + + delete(&db, "ip-10").unwrap(); + upsert( + &db, + &RedactRuleRow { + id: "custom".into(), + pattern: "secret".into(), + replacement: "".into(), + }, + ) + .unwrap(); + + let image_rules = list(&db).unwrap(); + assert!(!image_rules.iter().any(|r| r.id == "ip-10")); + assert!(image_rules.iter().any(|r| r.id == "custom")); + + let ai_rules = crate::db::ai_redact_rule::list(&db).unwrap(); + assert!(ai_rules.iter().any(|r| r.id == "ip-10")); + assert!(!ai_rules.iter().any(|r| r.id == "custom")); + } +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 4614d4e7..3b4fb829 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -2,6 +2,7 @@ pub mod ai_command_blacklist; pub mod ai_conversation; pub mod ai_redact_rule; pub mod ai_skill; +pub mod command_block_redact_rule; pub mod credential; pub mod forward; pub mod group; diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index e1d24600..2182d0f0 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -2,7 +2,7 @@ use rusqlite::{params, Connection}; use crate::error::AppResult; -const SCHEMA_VERSION: u32 = 25; +const SCHEMA_VERSION: u32 = 26; fn column_exists(conn: &Connection, table: &str, col: &str) -> AppResult { let mut stmt = conn.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")?; @@ -542,6 +542,43 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { } } + if version < 26 { + // Command-block copy redaction starts as a snapshot of the user's current AI + // redaction rules. From this point on the two rule sets are independent. + // INSERT .. SELECT avoids a second hard-coded copy of the defaults and + // also preserves edits made by users upgrading from an older schema. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ai_redact_rules ( + id TEXT PRIMARY KEY, + pattern TEXT NOT NULL, + replacement TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS command_block_redact_rules ( + id TEXT PRIMARY KEY, + pattern TEXT NOT NULL, + replacement TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO command_block_redact_rules + (id, pattern, replacement, created_at, updated_at) + SELECT id, pattern, replacement, created_at, updated_at + FROM ai_redact_rules + WHERE NOT EXISTS ( + SELECT 1 FROM command_block_redact_rules + ); + INSERT OR IGNORE INTO settings (key, value) VALUES + ('command_block_prompt_redact_enabled', 'true'), + ('command_block_prompt_replacement', 'anonymous@rssh');", + )?; + } + if version < SCHEMA_VERSION { conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; } @@ -553,6 +590,84 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { mod tests { use super::*; + #[test] + fn migration_26_seeds_command_block_redaction_defaults() { + let conn = Connection::open_in_memory().unwrap(); + + migrate(&conn).unwrap(); + + let rules: Vec<(String, String, String)> = conn + .prepare( + "SELECT id, pattern, replacement + FROM command_block_redact_rules + ORDER BY created_at, id", + ) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rules.len(), 8); + assert_eq!(rules[0].0, "ip-10"); + assert_eq!(rules[7].0, "hex"); + + let enabled: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'command_block_prompt_redact_enabled'", + [], + |row| row.get(0), + ) + .unwrap(); + let replacement: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'command_block_prompt_replacement'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(enabled, "true"); + assert_eq!(replacement, "anonymous@rssh"); + } + + #[test] + fn migration_26_snapshots_the_users_current_ai_rules() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + conn.execute_batch( + "DROP TABLE command_block_redact_rules; + DELETE FROM settings WHERE key IN ( + 'command_block_prompt_redact_enabled', + 'command_block_prompt_replacement' + ); + DELETE FROM ai_redact_rules WHERE id = 'ip-10'; + UPDATE ai_redact_rules + SET replacement = '' + WHERE id = 'aws-key'; + INSERT INTO ai_redact_rules + (id, pattern, replacement, created_at, updated_at) + VALUES ('user-rule', 'private', '', 100, 100);", + ) + .unwrap(); + conn.pragma_update(None, "user_version", 25u32).unwrap(); + + migrate(&conn).unwrap(); + + let rules: Vec<(String, String)> = conn + .prepare( + "SELECT id, replacement FROM command_block_redact_rules ORDER BY created_at, id", + ) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert!(!rules.iter().any(|(id, _)| id == "ip-10")); + assert!(rules + .iter() + .any(|(id, replacement)| id == "aws-key" && replacement == "")); + assert!(rules.iter().any(|(id, _)| id == "user-rule")); + } + #[test] fn migration_25_moves_existing_forward_to_rule_table() { let conn = Connection::open_in_memory().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5890fea..a01fe328 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod emitter; pub mod error; pub mod migration; pub mod models; +mod redaction; pub mod secret; mod ssh; pub use ssh::bastion; @@ -221,6 +222,9 @@ pub fn run() { commands::settings::read_recording, commands::settings::secret_backend, commands::settings::list_fonts, + commands::command_block::command_block_list_redact_rules, + commands::command_block::command_block_save_redact_rule, + commands::command_block::command_block_delete_redact_rule, // SSH session commands::session::ssh_connect, commands::session::ssh_write, diff --git a/src-tauri/src/redaction.rs b/src-tauri/src/redaction.rs new file mode 100644 index 00000000..cb3e42e4 --- /dev/null +++ b/src-tauri/src/redaction.rs @@ -0,0 +1,52 @@ +//! Shared validation for user-managed regular-expression redaction rules. + +use serde_json::json; + +use crate::error::{AppError, AppResult}; + +fn matches_empty(pattern: &str) -> bool { + regex_syntax::Parser::new() + .parse(pattern) + .map(|hir| hir.properties().minimum_len() == Some(0)) + .unwrap_or(false) +} + +pub fn validate_pattern(pattern: &str) -> AppResult<()> { + regex::Regex::new(pattern).map_err(|e| { + AppError::config( + "redact_invalid_regex", + json!({ "pattern": pattern, "error": e.to_string() }), + ) + })?; + if matches_empty(pattern) { + return Err(AppError::config( + "redact_zero_width_pattern", + json!({ "pattern": pattern }), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_invalid_and_zero_width_patterns() { + assert_eq!( + validate_pattern("(").unwrap_err().code(), + "redact_invalid_regex" + ); + for pattern in ["", "^", "$", "a*", r"\b", r"x*y*"] { + assert_eq!( + validate_pattern(pattern).unwrap_err().code(), + "redact_zero_width_pattern" + ); + } + } + + #[test] + fn accepts_nonempty_pattern() { + validate_pattern(r"secret-\d+").unwrap(); + } +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 2458ebed..f7bf4fa8 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -358,6 +358,23 @@ fn dispatch( }; ok(r) } + "command_block_list_redact_rules" => { + ok(crate::commands::command_block::list_redact_rules(&state.db)) + } + "command_block_save_redact_rule" => ok(crate::commands::command_block::save_redact_rule( + &state.db, + &crate::db::command_block_redact_rule::RedactRuleRow { + id: arg(&args, "id")?, + pattern: arg(&args, "pattern")?, + replacement: arg(&args, "replacement")?, + }, + )), + "command_block_delete_redact_rule" => { + ok(crate::commands::command_block::delete_redact_rule( + &state.db, + &arg::(&args, "id")?, + )) + } "set_sync_auto_pull" => ok(crate::commands::sync::set_sync_auto_pull_impl( state, arg(&args, "provider")?, diff --git a/src-tauri/src/sync/config.rs b/src-tauri/src/sync/config.rs index bf1f7e96..bcf190e8 100644 --- a/src-tauri/src/sync/config.rs +++ b/src-tauri/src/sync/config.rs @@ -25,11 +25,12 @@ use std::path::Path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::db::ai_command_blacklist::{self, BlacklistRow}; use crate::db::ai_redact_rule::{self, RedactRuleRow}; +use crate::db::command_block_redact_rule; use crate::db::{ credential, forward, group, highlight, profile, serial_profile, snippet, telnet_profile, Db, }; @@ -62,6 +63,25 @@ struct LegacyForwardImport { rule: ForwardRule, } +#[derive(Debug, Serialize, Deserialize)] +struct CommandBlockRedaction { + prompt_enabled: bool, + prompt_replacement: String, + rules: Vec, +} + +fn command_block_redaction(db: &Db) -> AppResult { + let prompt_enabled = crate::db::settings::get(db, "command_block_prompt_redact_enabled")? + .is_none_or(|value| value != "false"); + let prompt_replacement = crate::db::settings::get(db, "command_block_prompt_replacement")? + .unwrap_or_else(|| "anonymous@rssh".into()); + Ok(CommandBlockRedaction { + prompt_enabled, + prompt_replacement, + rules: command_block_redact_rule::list(db)?, + }) +} + fn parse_forward_import(value: Value) -> serde_json::Result { if value.get("rules").is_some() { return serde_json::from_value(value); @@ -327,6 +347,63 @@ pub fn merge_import(db: &Db, ss: &dyn SecretStore, data_dir: &Path, data: &Value } } } + // Command-block copy redaction is one category: prompt policy + independent + // rules. New payloads use the generic key; the old image-specific key stays + // readable for backups produced before text copying shared the policy. A + // missing category leaves local configuration untouched. + let command_block_redaction = data + .get("command_block_redaction") + .map(|value| ("command_block_redaction", value)) + .or_else(|| { + data.get("command_block_image_redaction") + .map(|value| ("command_block_image_redaction", value)) + }); + if let Some((category, value)) = command_block_redaction { + match serde_json::from_value::(value.clone()) { + Ok(incoming) => { + if let Err(e) = crate::db::settings::set( + db, + "command_block_prompt_redact_enabled", + if incoming.prompt_enabled { + "true" + } else { + "false" + }, + ) { + errors.push(ImportError { + kind: "command_block_prompt_redaction", + name: None, + code: e.code().to_string(), + }); + } + if let Err(e) = crate::db::settings::set( + db, + "command_block_prompt_replacement", + &incoming.prompt_replacement, + ) { + errors.push(ImportError { + kind: "command_block_prompt_redaction", + name: None, + code: e.code().to_string(), + }); + } + for rule in incoming.rules { + if let Err(e) = crate::commands::command_block::save_redact_rule(db, &rule) { + errors.push(ImportError { + kind: "command_block_redact_rule", + name: Some(rule.id), + code: e.code().to_string(), + }); + } + } + } + Err(_) => errors.push(ImportError { + kind: category, + name: None, + code: "parse_failed".into(), + }), + } + } // ai_command_blacklist — identity = name; additive upsert (never deletes) if let Some(arr) = data["ai_command_blacklist"].as_array() { for item in arr { @@ -473,6 +550,7 @@ pub struct SyncPrefs { highlights: bool, snippets: bool, ai_redact: bool, + command_block_redact: bool, ai_blacklist: bool, ai: bool, /// Group filter shared by profiles / forwards / serial_profiles. @@ -519,6 +597,7 @@ pub fn read_sync_prefs(db: &Db) -> AppResult { skills: flag("sync_include_skills")?, highlights: flag("sync_include_highlights")?, ai_redact: flag("sync_include_ai_redact")?, + command_block_redact: flag("sync_include_command_block_redact")?, ai_blacklist: flag("sync_include_ai_blacklist")?, snippets: flag("sync_include_snippets")?, ai: flag("sync_include_ai")?, @@ -675,6 +754,12 @@ fn build_payload_inner( if on(|p| p.ai_redact) { out.insert("ai_redact_rules".into(), to_val(ai_redact_rule::list(db)?)?); } + if on(|p| p.command_block_redact) { + out.insert( + "command_block_redaction".into(), + to_val(command_block_redaction(db)?)?, + ); + } if on(|p| p.ai_blacklist) { out.insert( "ai_command_blacklist".into(), @@ -1303,6 +1388,122 @@ mod tests { ); } + #[test] + fn local_export_includes_command_block_redaction() { + let (db, ss, dir) = fixture(); + crate::db::settings::set(&db, "command_block_prompt_redact_enabled", "false").unwrap(); + crate::db::settings::set(&db, "command_block_prompt_replacement", "masked-prompt").unwrap(); + + let out = build_payload(&db, &ss, dir.path(), &ExportMode::LocalBackup).unwrap(); + let category = &out["command_block_redaction"]; + assert_eq!(category["prompt_enabled"], false); + assert_eq!(category["prompt_replacement"], "masked-prompt"); + assert_eq!(category["rules"].as_array().unwrap().len(), 8); + assert!(out.get("command_block_image_redaction").is_none()); + } + + #[test] + fn command_block_redaction_import_is_additive() { + let (db, ss, dir) = fixture(); + let data = json!({ + "version": 1, + "command_block_redaction": { + "prompt_enabled": false, + "prompt_replacement": "remote-prompt", + "rules": [{"id": "remote", "pattern": "secret", "replacement": ""}] + } + }); + + merge_import(&db, &ss, dir.path(), &data).unwrap(); + + assert_eq!( + crate::db::settings::get(&db, "command_block_prompt_redact_enabled") + .unwrap() + .as_deref(), + Some("false") + ); + assert_eq!( + crate::db::settings::get(&db, "command_block_prompt_replacement") + .unwrap() + .as_deref(), + Some("remote-prompt") + ); + let rules = command_block_redact_rule::list(&db).unwrap(); + assert_eq!(rules.len(), 9, "existing defaults are not deleted"); + assert!(rules.iter().any(|rule| rule.id == "remote")); + } + + #[test] + fn legacy_command_block_image_redaction_import_is_supported() { + let (db, ss, dir) = fixture(); + let data = json!({ + "version": 1, + "command_block_image_redaction": { + "prompt_enabled": false, + "prompt_replacement": "legacy-prompt", + "rules": [] + } + }); + + merge_import(&db, &ss, dir.path(), &data).unwrap(); + + assert_eq!( + crate::db::settings::get(&db, "command_block_prompt_replacement") + .unwrap() + .as_deref(), + Some("legacy-prompt") + ); + } + + #[test] + fn old_payload_keeps_local_command_block_redaction() { + let (db, ss, dir) = fixture(); + crate::db::settings::set(&db, "command_block_prompt_replacement", "local-prompt").unwrap(); + command_block_redact_rule::delete(&db, "ip-10").unwrap(); + + merge_import(&db, &ss, dir.path(), &json!({"version": 1})).unwrap(); + + assert_eq!( + crate::db::settings::get(&db, "command_block_prompt_replacement") + .unwrap() + .as_deref(), + Some("local-prompt") + ); + assert!(!command_block_redact_rule::list(&db) + .unwrap() + .iter() + .any(|rule| rule.id == "ip-10")); + } + + #[test] + fn command_block_redaction_import_rejects_invalid_rule() { + let (db, ss, dir) = fixture(); + let data = json!({ + "version": 1, + "command_block_redaction": { + "prompt_enabled": true, + "prompt_replacement": "anonymous@rssh", + "rules": [{"id": "bad", "pattern": "(", "replacement": ""}] + } + }); + + merge_import(&db, &ss, dir.path(), &data).unwrap_err(); + assert!(!command_block_redact_rule::list(&db) + .unwrap() + .iter() + .any(|rule| rule.id == "bad")); + } + + #[test] + fn remote_push_can_omit_command_block_redaction() { + let (db, ss, dir) = fixture(); + crate::db::settings::set(&db, "sync_include_command_block_redact", "0").unwrap(); + let prefs = read_sync_prefs(&db).unwrap(); + + let out = build_payload(&db, &ss, dir.path(), &ExportMode::RemotePush(prefs)).unwrap(); + assert!(out.get("command_block_redaction").is_none()); + } + #[test] fn merge_ai_blacklist_is_additive() { let (db, ss, dir) = fixture(); diff --git a/src/lib/components/CommandBlockSettings.svelte b/src/lib/components/CommandBlockSettings.svelte index 1ddde470..8680b7c0 100644 --- a/src/lib/components/CommandBlockSettings.svelte +++ b/src/lib/components/CommandBlockSettings.svelte @@ -1,16 +1,51 @@
@@ -68,6 +202,134 @@
{/if} + + +
+
+
+
+ {t("settings.shell.command_block_prompt_redact")} +
+
+ {t("settings.shell.command_block_prompt_redact_desc")} +
+
+ +
+ + {#if promptEnabled} +
+
+
+ + +
+ +
+ {/if} +
+ +
+
+ {t("settings.shell.command_block_redact_hint")} + {#if redactionReady && !editingRule} + + {/if} +
+ + {#if redactNote} + + {/if} + + {#if !editingRule} +
+ {#if redactionLoading} +
{t("common.loading")}
+ {:else if redactionReady} + {#each redactRules as rule (rule.id)} + + {/each} + {/if} + {#if redactionReady && redactRules.length === 0} +
{t("settings.shell.command_block_redact_empty")}
+ {/if} +
+ {:else} +
+
+ + +
+
+ + +
+
+ + {#if !isNewRule} + + {/if} + +
+
+ {/if} +
diff --git a/src/lib/components/SyncScreen.svelte b/src/lib/components/SyncScreen.svelte index 9db41676..7f8fc641 100644 --- a/src/lib/components/SyncScreen.svelte +++ b/src/lib/components/SyncScreen.svelte @@ -38,6 +38,7 @@ { key: "sync_include_snippets", label: "sync.categories.snippets" }, { key: "sync_include_skills", label: "sync.categories.skills" }, { key: "sync_include_ai_redact", label: "sync.categories.ai_redact" }, + { key: "sync_include_command_block_redact", label: "sync.categories.command_block_redact" }, { key: "sync_include_ai_blacklist", label: "sync.categories.ai_blacklist" }, { key: "sync_include_ai", label: "sync.categories.ai" }, ]; diff --git a/src/lib/components/TerminalPane.svelte b/src/lib/components/TerminalPane.svelte index 7efb8991..651a4196 100644 --- a/src/lib/components/TerminalPane.svelte +++ b/src/lib/components/TerminalPane.svelte @@ -21,7 +21,8 @@ import {createFoldStore, type FoldStore} from "../terminal/folds.ts"; import {createPaintScheduler, type PaintScheduler} from "../terminal/paint-scheduler.ts"; - import {extractBlocksText} from "../terminal/block-content.ts"; + import {extractBlockTexts, extractBlocksText} from "../terminal/block-content.ts"; + import {redactCommandBlockTexts} from "../terminal/command-block-redaction.ts"; import {setupTouchScroll} from "../terminal/touch-scroll.ts"; import {setupXtermIme229Workaround} from "../terminal/xterm-ime-229-workaround.ts"; import {createReservedSessionAttempt} from "../terminal/reserved-session-attempt.ts"; @@ -407,12 +408,15 @@ if (!terminal || blocks.length === 0) return; // 传 foldStore:折叠块走 saved body,否则会被拉到 cursorAbs 把后续 // 命令输出全卷进来(PR #24 reviewer 发现的 bug) - const text = extractBlocksText(terminal, blocks, foldStore); - if (!text) return; + const blockTexts = extractBlockTexts(terminal, blocks, foldStore); + if (blockTexts.length === 0) return; // arboard (not navigator.clipboard) so this process — not WebKitGTK — // owns the X11 CLIPBOARD selection. Otherwise a later arboard-based // paste (clipboard_read) deadlocks on its own WebView and times out. try { + const redaction = await app.loadCommandBlockRedaction(true); + const text = redactCommandBlockTexts(blockTexts, redaction).join("\n"); + if (!text) return; await writeClipboard(text); clearBlockSelection(); } catch (error) { @@ -435,10 +439,16 @@ const term = terminal; const fs = foldStore; try { - const pngPromise = renderBlocksToBlob(term, blocks, {}, fs).then((b) => { - if (!b) throw new Error("render produced no blob"); - return b; - }); + // Re-read the policy for each copy. This is infrequent, keeps sync + // imports immediately visible, and fails closed if DB/regex loading + // fails. ClipboardItem receives the promise while the user gesture + // is still active, so the async read does not break clipboard access. + const pngPromise = app.loadCommandBlockRedaction(true) + .then((redaction) => renderBlocksToBlob(term, blocks, {}, fs, redaction)) + .then((blob) => { + if (!blob) throw new Error("render produced no blob"); + return blob; + }); navigator.clipboard .write([new ClipboardItem({ "image/png": pngPromise })]) .then(() => clearBlockSelection()) diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 7ec90560..2a33ef57 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -189,6 +189,24 @@ const en = { "settings.shell.command_block_tip_cmd_click": "Cmd / Ctrl + click: toggle a block in or out of the selection", "settings.shell.command_block_tip_right_click": "Right-click the bar: fold / unfold, copy as text, copy as image (batch copy when multiple are selected)", "settings.shell.command_block_tip_clear": "Esc or click elsewhere: clear the selection", + "settings.shell.command_block_copy_redaction": "COMMAND-BLOCK COPY REDACTION", + "settings.shell.command_block_prompt_redact": "REDACT PROMPT", + "settings.shell.command_block_prompt_redact_desc": "Recognize the shell prompt on each command block's first row and replace it when copying as text or image. Enter-based block boundaries are unchanged.", + "settings.shell.command_block_prompt_replacement": "REPLACE PROMPT WITH", + "settings.shell.command_block_redact_hint": "Applied locally to commands and output before copying command blocks as text or image. These rules are independent from AI redaction rules.", + "settings.shell.command_block_redact_new": "+ New", + "settings.shell.command_block_redact_empty": "No redaction rules — copied command blocks will only apply the prompt setting above.", + "settings.shell.command_block_redact_pattern": "PATTERN", + "settings.shell.command_block_redact_replacement": "REPLACE WITH", + "settings.shell.command_block_redact_pattern_placeholder": "regex, e.g. AKIA[0-9A-Z]{16}", + "settings.shell.command_block_redact_replacement_placeholder": "e.g. ", + "settings.shell.command_block_redact_delete": "Delete", + "settings.shell.command_block_redact_delete_confirm": "Click again to confirm", + "settings.shell.command_block_redact_cancel": "Cancel", + "settings.shell.command_block_redact_error_empty": "pattern / replacement cannot be empty", + "settings.shell.command_block_redact_error_load": "Failed to load command-block copy redaction settings: {error}", + "settings.shell.command_block_redact_error_save": "Failed to save command-block copy redaction settings: {error}", + "settings.shell.command_block_redact_error_delete": "Failed to delete command-block copy redaction rule: {error}", "terminal.block.menu.fold": "Fold", "terminal.block.menu.unfold": "Unfold", "terminal.block.menu.copy_text": "Copy as text", @@ -1014,6 +1032,7 @@ const en = { "sync.categories.highlights": "Keyword highlights", "sync.categories.snippets": "Command snippets", "sync.categories.ai_redact": "AI redaction rules", + "sync.categories.command_block_redact": "Command-block copy redaction rules", "sync.categories.ai_blacklist": "AI command blacklist", "sync.categories.ai": "AI config", "sync.categories.profiles": "Profile/Forward/Serial", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 5f658726..ececf5f0 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -191,6 +191,24 @@ const zh: Messages = { "settings.shell.command_block_tip_cmd_click": "Cmd / Ctrl + 点击:将该命令块加入或移出选中集", "settings.shell.command_block_tip_right_click": "右键侧栏:折叠 / 展开、复制为文本、复制为图片(多选时可批量复制)", "settings.shell.command_block_tip_clear": "Esc 或点击其他位置:清空选中", + "settings.shell.command_block_copy_redaction": "命令块复制脱敏", + "settings.shell.command_block_prompt_redact": "Prompt 脱敏", + "settings.shell.command_block_prompt_redact_desc": "识别每个命令块首行的 shell prompt,并在复制为文本或图片时替换;不改变按 Enter 划分命令块的逻辑。", + "settings.shell.command_block_prompt_replacement": "Prompt 替换为", + "settings.shell.command_block_redact_hint": "复制命令块为文本或图片前,在本地按这些正则替换命令和输出。规则与 AI 脱敏规则相互独立。", + "settings.shell.command_block_redact_new": "+ 新建", + "settings.shell.command_block_redact_empty": "没有脱敏规则 — 复制命令块时只会按上方设置处理 Prompt。", + "settings.shell.command_block_redact_pattern": "正则", + "settings.shell.command_block_redact_replacement": "替换为", + "settings.shell.command_block_redact_pattern_placeholder": "正则,如 AKIA[0-9A-Z]{16}", + "settings.shell.command_block_redact_replacement_placeholder": "如 ", + "settings.shell.command_block_redact_delete": "删除", + "settings.shell.command_block_redact_delete_confirm": "再点一次确认删除", + "settings.shell.command_block_redact_cancel": "取消", + "settings.shell.command_block_redact_error_empty": "正则 / 替换不能为空", + "settings.shell.command_block_redact_error_load": "加载命令块复制脱敏设置失败:{error}", + "settings.shell.command_block_redact_error_save": "保存命令块复制脱敏设置失败:{error}", + "settings.shell.command_block_redact_error_delete": "删除命令块复制脱敏规则失败:{error}", "terminal.block.menu.fold": "折叠", "terminal.block.menu.unfold": "展开", "terminal.block.menu.copy_text": "复制文本", @@ -1017,6 +1035,7 @@ const zh: Messages = { "sync.categories.highlights": "关键词高亮", "sync.categories.snippets": "命令片段", "sync.categories.ai_redact": "AI 脱敏规则", + "sync.categories.command_block_redact": "命令块复制脱敏规则", "sync.categories.ai_blacklist": "AI 命令黑名单", "sync.categories.ai": "AI 配置", "sync.categories.profiles": "Profile/端口转发/串口(按分组)", diff --git a/src/lib/stores/app.svelte.ts b/src/lib/stores/app.svelte.ts index 3898b010..48c09f35 100644 --- a/src/lib/stores/app.svelte.ts +++ b/src/lib/stores/app.svelte.ts @@ -2,6 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import * as ai from "../ai/store.svelte.ts"; import { errMsg } from "../i18n/index.svelte.ts"; import { isIOS, isMobile } from "../platform.ts"; +import type { + CommandBlockRedactionRule as RedactionRule, + CommandBlockRedactionSettings as RedactionSettings, +} from "../terminal/command-block-redaction.ts"; import type { ViewportSnapshot } from "../terminal/viewport-snapshot.ts"; import { toast } from "./toast.svelte.ts"; @@ -761,6 +765,91 @@ export async function setAutoColorBlocks(v: boolean) { await invoke("set_setting", { key: "command_block_auto_color", value: String(v) }); } +/* ─── Command-block copy redaction ─── */ +export interface CommandBlockRedactRule extends RedactionRule { + id: string; +} + +export interface CommandBlockRedactionSettings extends RedactionSettings { + rules: CommandBlockRedactRule[]; +} + +let _commandBlockRedaction = $state({ + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [], +}); +let _commandBlockRedactionLoaded = false; +let _commandBlockRedactionLoad: Promise | null = null; + +function commandBlockRedactionValue(): CommandBlockRedactionSettings { + return { + promptEnabled: _commandBlockRedaction.promptEnabled, + promptReplacement: _commandBlockRedaction.promptReplacement, + rules: _commandBlockRedaction.rules.map((rule) => ({ ...rule })), + }; +} + +export function commandBlockRedaction(): CommandBlockRedactionSettings { + return commandBlockRedactionValue(); +} + +export async function loadCommandBlockRedaction( + force = false, +): Promise { + if (!force && _commandBlockRedactionLoaded) return commandBlockRedactionValue(); + if (_commandBlockRedactionLoad) return _commandBlockRedactionLoad; + + _commandBlockRedactionLoad = (async () => { + const [enabled, replacement, rules] = await Promise.all([ + invoke("get_setting", { key: "command_block_prompt_redact_enabled" }), + invoke("get_setting", { key: "command_block_prompt_replacement" }), + invoke("command_block_list_redact_rules"), + ]); + _commandBlockRedaction = { + promptEnabled: enabled !== "false", + promptReplacement: replacement ?? "anonymous@rssh", + rules, + }; + _commandBlockRedactionLoaded = true; + return commandBlockRedactionValue(); + })(); + + try { + return await _commandBlockRedactionLoad; + } finally { + _commandBlockRedactionLoad = null; + } +} + +export async function setCommandBlockPromptRedactEnabled(value: boolean) { + await invoke("set_setting", { + key: "command_block_prompt_redact_enabled", + value: String(value), + }); + _commandBlockRedaction.promptEnabled = value; + _commandBlockRedactionLoaded = true; +} + +export async function setCommandBlockPromptReplacement(value: string) { + await invoke("set_setting", { + key: "command_block_prompt_replacement", + value, + }); + _commandBlockRedaction.promptReplacement = value; + _commandBlockRedactionLoaded = true; +} + +export async function saveCommandBlockRedactRule(rule: CommandBlockRedactRule) { + await invoke("command_block_save_redact_rule", rule); + await loadCommandBlockRedaction(true); +} + +export async function deleteCommandBlockRedactRule(id: string) { + await invoke("command_block_delete_redact_rule", { id }); + await loadCommandBlockRedaction(true); +} + /* ─── Copy selected terminal text on selection ─── */ // Default false: never silently touch the clipboard unless the user opts in, // so encoding is inverted vs commandBlockBar (only an explicit "true" enables). diff --git a/src/lib/terminal/block-content.test.ts b/src/lib/terminal/block-content.test.ts index e9cb211a..344178d0 100644 --- a/src/lib/terminal/block-content.test.ts +++ b/src/lib/terminal/block-content.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + extractBlockTexts, extractBlocksText, extractRangeLines, resolveBlockLines, @@ -223,6 +224,20 @@ describe("extractBlocksText", () => { expect(text).toBe("$ pwd\n/tmp\n$ whoami\nlinus"); }); + it("keeps multi-block boundaries available to copy-time policies", () => { + const term = fakeTerm([ + lineFromSpec("$ pwd "), + lineFromSpec("/tmp "), + lineFromSpec("$ whoami "), + lineFromSpec("linus "), + ]); + const texts = extractBlockTexts(term, [ + fakeBlock(2, 2, 3), + fakeBlock(1, 0, 1), + ]); + expect(texts).toEqual(["$ pwd\n/tmp", "$ whoami\nlinus"]); + }); + it("merges wrapped output within a block", () => { const term = fakeTerm([ lineFromSpec("$ echo aaaa "), diff --git a/src/lib/terminal/block-content.ts b/src/lib/terminal/block-content.ts index 43202c9b..edaa87aa 100644 --- a/src/lib/terminal/block-content.ts +++ b/src/lib/terminal/block-content.ts @@ -89,16 +89,25 @@ export function extractBlocksText( blocks: ReadonlyArray, foldStore?: FoldLookup, ): string { + return extractBlockTexts(term, blocks, foldStore).join("\n"); +} + +/** 抽取纯文本但保留块边界,供需要逐块处理 Prompt 的复制策略使用。 */ +export function extractBlockTexts( + term: Terminal, + blocks: ReadonlyArray, + foldStore?: FoldLookup, +): string[] { const sorted = [...blocks] .filter((b) => !b.start.isDisposed) .sort((a, b) => a.id - b.id); - if (sorted.length === 0) return ""; + if (sorted.length === 0) return []; const parts: string[] = []; for (const block of sorted) { const lines = resolveBlockLines(term, block, foldStore); parts.push(linesToLogicalText(lines).join("\n")); } - return parts.join("\n"); + return parts; } /** IBufferLine 数组 → 逻辑行字符串数组。处理软换行合并、CJK、行末 trim。 */ diff --git a/src/lib/terminal/block-to-image.test.ts b/src/lib/terminal/block-to-image.test.ts index 7ee29e1d..f41fff65 100644 --- a/src/lib/terminal/block-to-image.test.ts +++ b/src/lib/terminal/block-to-image.test.ts @@ -4,6 +4,9 @@ import { resolveFg, resolveBg, extractImageRows, + redactImageRows, + type ImageCell, + type ImageRow, } from "./block-to-image.ts"; import type { ITheme, IBufferCell } from "@xterm/xterm"; import type { CommandBlock } from "./command-blocks.ts"; @@ -289,3 +292,102 @@ describe("extractImageRows", () => { expect(rows.map((r) => r.blockColor)).toEqual(["#111", "#222", "#333"]); }); }); + +/* ───────────────────────── redactImageRows ───────────────────────── */ + +function imageCells(text: string, fg = "#eeeeee"): ImageCell[] { + return Array.from(text, (ch) => ({ + ch, + width: 1, + fg, + bg: "#111111", + bold: false, + italic: false, + underline: false, + })); +} + +function imageRow(blockId: number, text: string): ImageRow { + return { blockId, blockColor: "#abc", cells: imageCells(text) }; +} + +function imageText(row: ImageRow): string { + return row.cells.map((cell) => cell.ch).join(""); +} + +describe("redactImageRows", () => { + it("replaces only the first-row prompt of every block and keeps commands", () => { + const rows = [ + imageRow(1, "alice@prod:~/app$ ssh 10.0.0.1"), + imageRow(1, "alice@prod is output, not a prompt"), + imageRow(2, "PS C:\\Users\\Alice>Get-ChildItem"), + ]; + const redacted = redactImageRows(rows, { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [], + }); + expect(redacted.map(imageText)).toEqual([ + "anonymous@rssh ssh 10.0.0.1", + "alice@prod is output, not a prompt", + "anonymous@rssh Get-ChildItem", + ]); + }); + + it("applies independent regex rules to commands and output", () => { + const rows = [ + imageRow(1, "$ curl 10.0.0.1/token"), + imageRow(1, "peer=10.2.3.4 token=secret"), + ]; + const redacted = redactImageRows(rows, { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [ + { id: "ip", pattern: String.raw`\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`, replacement: "" }, + { id: "secret", pattern: "secret", replacement: "$&-literal" }, + ], + }); + expect(redacted.map(imageText)).toEqual([ + "anonymous@rssh curl /token", + "peer= token=$&-literal", + ]); + }); + + it("leaves the prompt to normal rules when prompt redaction is disabled", () => { + const [row] = redactImageRows([imageRow(1, "alice@prod:~$ id")], { + promptEnabled: false, + promptReplacement: "anonymous@rssh", + rules: [{ id: "host", pattern: "prod", replacement: "hidden" }], + }); + expect(imageText(row)).toBe("alice@hidden:~$ id"); + }); + + it("does not mistake ordinary first-row output for a prompt", () => { + const [row] = redactImageRows([imageRow(1, "build > output.txt")], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [], + }); + expect(imageText(row)).toBe("build > output.txt"); + }); + + it("keeps replacement styling and CJK display width", () => { + const row = imageRow(1, "$ id"); + row.cells[0].fg = "#ff0000"; + const [redacted] = redactImageRows([row], { + promptEnabled: true, + promptReplacement: "匿名", + rules: [], + }); + expect(redacted.cells.slice(0, 2).map((cell) => cell.width)).toEqual([2, 2]); + expect(redacted.cells[0].fg).toBe("#ff0000"); + }); + + it("fails closed when a synced rule is not valid JavaScript regex", () => { + expect(() => redactImageRows([imageRow(1, "$ id")], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [{ id: "bad", pattern: "(?Px)", replacement: "" }], + })).toThrow(); + }); +}); diff --git a/src/lib/terminal/block-to-image.ts b/src/lib/terminal/block-to-image.ts index 6ff8805c..90d3af8e 100644 --- a/src/lib/terminal/block-to-image.ts +++ b/src/lib/terminal/block-to-image.ts @@ -21,6 +21,14 @@ import type { Terminal, ITheme, IBufferCell } from "@xterm/xterm"; import type { CommandBlock } from "./command-blocks"; import { resolveBlockLines, type FoldLookup } from "./block-content"; +import { + commandBlockPromptEnd, + compileCommandBlockRedaction, + promptReplacementForTail, + redactionRuleMatches, + type CommandBlockRedactionSettings, + type CompiledCommandBlockRedactionRule, +} from "./command-block-redaction"; export interface RenderOptions { /** 彩色竖线宽度 px。默认 4。 */ @@ -58,6 +66,7 @@ export async function renderBlocksToBlob( blocks: ReadonlyArray, opts: RenderOptions = {}, foldStore?: FoldLookup, + redaction?: CommandBlockRedactionSettings, ): Promise { if (blocks.length === 0) return null; // 非 DOM 环境(vitest node / SSR)→ null。否则下方 createElement 会 throw, @@ -67,11 +76,140 @@ export async function renderBlocksToBlob( if (document.fonts?.ready) { await document.fonts.ready; } - const rows = extractImageRows(term, blocks, foldStore); + const extracted = extractImageRows(term, blocks, foldStore); + const rows = redaction ? redactImageRows(extracted, redaction) : extracted; if (rows.length === 0) return null; return renderRowsToBlob(rows, term, opts); } +/* ───────────────────────── 脱敏(纯函数,可测) ───────────────────────── */ + +function rowText(cells: ReadonlyArray): string { + return cells.map((cell) => cell.ch).join(""); +} + +function graphemeWidth(ch: string): 1 | 2 { + const cp = ch.codePointAt(0) ?? 0; + // Same broad East-Asian/emoji ranges used by terminal wcwidth tables. The + // Prompt replacement defaults to ASCII; this keeps user-supplied CJK or + // emoji replacements aligned without dragging a second width library in. + const wide = cp >= 0x1100 && ( + cp <= 0x115f || cp === 0x2329 || cp === 0x232a || + (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe10 && cp <= 0xfe19) || + (cp >= 0xfe30 && cp <= 0xfe6f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1f300 && cp <= 0x1faff) || + (cp >= 0x20000 && cp <= 0x3fffd) + ); + return wide ? 2 : 1; +} + +function cellsFromText(text: string, style: ImageCell): ImageCell[] { + return Array.from(text, (ch) => ({ ...style, ch, width: graphemeWidth(ch) })); +} + +function styleAt(cells: ReadonlyArray, offset: number): ImageCell | null { + let cursor = 0; + for (const cell of cells) { + const end = cursor + cell.ch.length; + if (offset < end) return cell; + cursor = end; + } + return cells.at(-1) ?? null; +} + +/** Slice by UTF-16 text offsets while retaining cell styling. xterm normally + * stores one grapheme per cell, but partial slicing is handled as well. */ +function sliceCells( + cells: ReadonlyArray, + start: number, + end: number, +): ImageCell[] { + if (start >= end) return []; + const out: ImageCell[] = []; + let cursor = 0; + for (const cell of cells) { + const cellEnd = cursor + cell.ch.length; + if (cellEnd <= start) { + cursor = cellEnd; + continue; + } + if (cursor >= end) break; + const from = Math.max(0, start - cursor); + const to = Math.min(cell.ch.length, end - cursor); + const ch = cell.ch.slice(from, to); + if (ch) { + out.push(from === 0 && to === cell.ch.length + ? { ...cell } + : { ...cell, ch, width: graphemeWidth(ch) }); + } + cursor = cellEnd; + } + return out; +} + +function applyRule( + cells: ReadonlyArray, + rule: CompiledCommandBlockRedactionRule, +): ImageCell[] { + const text = rowText(cells); + const matches = redactionRuleMatches(text, rule); + if (matches.length === 0) return cells.map((cell) => ({ ...cell })); + + const out: ImageCell[] = []; + let cursor = 0; + for (const match of matches) { + out.push(...sliceCells(cells, cursor, match.start)); + const style = styleAt(cells, match.start); + if (style) out.push(...cellsFromText(rule.replacement, style)); + cursor = match.end; + } + out.push(...sliceCells(cells, cursor, text.length)); + return out; +} + +function applyRules( + cells: ReadonlyArray, + rules: ReadonlyArray, +): ImageCell[] { + return rules.reduce((current, rule) => applyRule(current, rule), [...cells]); +} + +export function redactImageRows( + rows: ReadonlyArray, + settings: CommandBlockRedactionSettings, +): ImageRow[] { + const redaction = compileCommandBlockRedaction(settings); + const seenBlocks = new Set(); + + return rows.map((row) => { + const firstRow = !seenBlocks.has(row.blockId); + seenBlocks.add(row.blockId); + const text = rowText(row.cells); + const promptEnd = commandBlockPromptEnd(text, firstRow, redaction); + + if (promptEnd === null) { + return { ...row, cells: applyRules(row.cells, redaction.rules) }; + } + + const style = styleAt(row.cells, 0); + const tail = applyRules( + sliceCells(row.cells, promptEnd, text.length), + redaction.rules, + ); + const tailText = rowText(tail); + const replacement = promptReplacementForTail(redaction.promptReplacement, tailText); + return { + ...row, + cells: [...(style ? cellsFromText(replacement, style) : []), ...tail], + }; + }); +} + /* ───────────────────────── 数据抽取(纯函数,可测) ───────────────────────── */ /** 选中块 → 视觉行序列。视觉行不合并软换行(图片是"截图",要保留视觉布局)。 diff --git a/src/lib/terminal/command-block-redaction.test.ts b/src/lib/terminal/command-block-redaction.test.ts new file mode 100644 index 00000000..2da8e838 --- /dev/null +++ b/src/lib/terminal/command-block-redaction.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { redactCommandBlockTexts } from "./command-block-redaction.ts"; + +describe("redactCommandBlockTexts", () => { + it("recognizes and replaces the first logical-line prompt in every block", () => { + const redacted = redactCommandBlockTexts([ + "alice@prod:~/app$ ssh 10.0.0.1\nalice@prod is output", + "PS C:\\Users\\Alice>Get-ChildItem\nready", + ], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [], + }); + + expect(redacted).toEqual([ + "anonymous@rssh ssh 10.0.0.1\nalice@prod is output", + "anonymous@rssh Get-ChildItem\nready", + ]); + }); + + it("applies rules to commands and output while keeping replacements literal", () => { + const [redacted] = redactCommandBlockTexts([ + "$ curl 10.0.0.1/token\npeer=10.2.3.4 token=secret", + ], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [ + { pattern: String.raw`\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`, replacement: "" }, + { pattern: "(secret)", replacement: "$&-$1-literal" }, + ], + }); + + expect(redacted).toBe( + "anonymous@rssh curl /token\npeer= token=$&-$1-literal", + ); + }); + + it("runs normal rules over the prompt when prompt redaction is disabled", () => { + const [redacted] = redactCommandBlockTexts(["alice@prod:~$ id"], { + promptEnabled: false, + promptReplacement: "anonymous@rssh", + rules: [{ pattern: "prod", replacement: "hidden" }], + }); + + expect(redacted).toBe("alice@hidden:~$ id"); + }); + + it("fails closed before returning text when any rule is invalid", () => { + expect(() => redactCommandBlockTexts(["$ id", "$ pwd"], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [{ pattern: "(?Px)", replacement: "" }], + })).toThrow(); + }); +}); diff --git a/src/lib/terminal/command-block-redaction.ts b/src/lib/terminal/command-block-redaction.ts new file mode 100644 index 00000000..7f369a0f --- /dev/null +++ b/src/lib/terminal/command-block-redaction.ts @@ -0,0 +1,142 @@ +import { detectPrompt } from "./prompt"; + +export interface CommandBlockRedactionRule { + pattern: string; + replacement: string; +} + +export interface CommandBlockRedactionSettings { + promptEnabled: boolean; + promptReplacement: string; + rules: ReadonlyArray; +} + +export interface CompiledCommandBlockRedactionRule { + regex: RegExp; + replacement: string; +} + +export interface CompiledCommandBlockRedaction { + promptEnabled: boolean; + promptReplacement: string; + rules: ReadonlyArray; +} + +export interface RedactionRuleMatch { + start: number; + end: number; +} + +/** Rust regex accepts leading inline flags. Translate the common global form + * to JavaScript flags. Unsupported Rust-only syntax fails closed at copy time + * instead of silently returning unredacted content. */ +function compileRule( + rule: CommandBlockRedactionRule, +): CompiledCommandBlockRedactionRule { + let source = rule.pattern; + let flags = "g"; + const inline = source.match(/^\(\?([ims]+)\)/); + if (inline) { + source = source.slice(inline[0].length); + flags += inline[1]; + } + return { regex: new RegExp(source, flags), replacement: rule.replacement }; +} + +export function compileCommandBlockRedaction( + settings: CommandBlockRedactionSettings, +): CompiledCommandBlockRedaction { + return { + promptEnabled: settings.promptEnabled, + promptReplacement: settings.promptReplacement, + // Compile every rule before touching content. One bad synced rule rejects + // the complete copy operation; partial redaction is a data leak. + rules: settings.rules.map(compileRule), + }; +} + +export function redactionRuleMatches( + text: string, + rule: CompiledCommandBlockRedactionRule, +): RedactionRuleMatch[] { + const matches: RedactionRuleMatch[] = []; + rule.regex.lastIndex = 0; + for (;;) { + const match = rule.regex.exec(text); + if (!match) return matches; + if (match[0].length === 0) throw new Error("redact_zero_width_pattern"); + matches.push({ start: match.index, end: match.index + match[0].length }); + } +} + +export function commandBlockPromptEnd( + text: string, + firstLine: boolean, + redaction: CompiledCommandBlockRedaction, +): number | null { + if (!firstLine || !redaction.promptEnabled) return null; + return detectPrompt(text)?.end ?? null; +} + +export function promptReplacementForTail(replacement: string, tail: string): string { + if (replacement && tail && !/\s$/.test(replacement) && !/^\s/.test(tail)) { + return `${replacement} `; + } + return replacement; +} + +function applyRuleToText( + text: string, + rule: CompiledCommandBlockRedactionRule, +): string { + const matches = redactionRuleMatches(text, rule); + if (matches.length === 0) return text; + + const out: string[] = []; + let cursor = 0; + for (const match of matches) { + out.push(text.slice(cursor, match.start), rule.replacement); + cursor = match.end; + } + out.push(text.slice(cursor)); + return out.join(""); +} + +function applyRulesToText( + text: string, + rules: ReadonlyArray, +): string { + return rules.reduce((current, rule) => applyRuleToText(current, rule), text); +} + +function redactLine( + text: string, + firstLine: boolean, + redaction: CompiledCommandBlockRedaction, +): string { + const promptEnd = commandBlockPromptEnd(text, firstLine, redaction); + if (promptEnd === null) return applyRulesToText(text, redaction.rules); + + const tail = applyRulesToText(text.slice(promptEnd), redaction.rules); + return promptReplacementForTail(redaction.promptReplacement, tail) + tail; +} + +function redactCommandBlockText( + text: string, + redaction: CompiledCommandBlockRedaction, +): string { + return text + .split("\n") + .map((line, index) => redactLine(line, index === 0, redaction)) + .join("\n"); +} + +/** Redact each block independently so prompt recognition never crosses the + * Enter-defined block boundary. Returned array preserves input ordering. */ +export function redactCommandBlockTexts( + blocks: ReadonlyArray, + settings: CommandBlockRedactionSettings, +): string[] { + const redaction = compileCommandBlockRedaction(settings); + return blocks.map((text) => redactCommandBlockText(text, redaction)); +} diff --git a/src/lib/terminal/prompt.test.ts b/src/lib/terminal/prompt.test.ts new file mode 100644 index 00000000..9616660a --- /dev/null +++ b/src/lib/terminal/prompt.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { detectPrompt } from "./prompt.ts"; + +function promptOf(line: string): string | null { + const match = detectPrompt(line); + return match ? line.slice(0, match.end) : null; +} + +describe("detectPrompt", () => { + it.each([ + ["alice@host:~/src$ git status", "alice@host:~/src$"], + ["(venv) root@server:/opt/app# id", "(venv) root@server:/opt/app#"], + ["alice@host ~/src> pwd", "alice@host ~/src>"], + ["[root@server /var/log]# tail app.log", "[root@server /var/log]#"], + ["MacBook-Pro:project alice$ make", "MacBook-Pro:project alice$"], + ["PS C:\\Users\\Alice> Get-ChildItem", "PS C:\\Users\\Alice>"], + ["C:\\Users\\Alice>dir", "C:\\Users\\Alice>"], + ["\\\\server\\share> dir", "\\\\server\\share>"], + ["alice  ~/src  cargo test", "alice  ~/src "], + ["❯ git status", "❯"], + ["~/src ❯ git status", "~/src ❯"], + ["bash-5.2$ printf ok", "bash-5.2$"], + ["$ echo ok", "$"], + ])("recognizes %s", (line, expected) => { + expect(promptOf(line)).toBe(expected); + }); + + it.each([ + "build > output.txt", + "ERROR: request failed", + "normal command output", + "100% complete", + "", + ])("does not guess on ordinary output: %s", (line) => { + expect(detectPrompt(line)).toBeNull(); + }); +}); diff --git a/src/lib/terminal/prompt.ts b/src/lib/terminal/prompt.ts new file mode 100644 index 00000000..e2c3b928 --- /dev/null +++ b/src/lib/terminal/prompt.ts @@ -0,0 +1,44 @@ +/** + * Shell prompt recognition for terminal presentation features. + * + * WindTerm takes the pragmatic route: shell-family regular expressions, not + * OCR and not a second command-block protocol. We do the same. Detection is + * deliberately anchored to column zero and callers should only inspect the + * first visual row of a command block; that keeps ordinary output out of the + * heuristic's input. + */ + +export interface PromptMatch { + /** UTF-16 offset immediately after the prompt marker, before command spacing. */ + end: number; +} + +const PROMPT_PATTERNS: ReadonlyArray = [ + // PowerShell: "PS C:\\Users\\alice>" and cross-platform "PS /home/alice>". + /^PS(?:\s+[^>\r\n]{0,200})?>/i, + // cmd.exe: drive paths and UNC paths. + /^(?:[A-Za-z]:\\|\\\\)[^>\r\n]{0,200}>/, + // Unix user@host prompts, optionally preceded by venv/context and followed + // by either :path or fish-style " path". + /^(?:(?:\([^)\r\n]{1,80}\)|\[[^\]\r\n]{1,120}\])\s*)*[^\s@:\r\n]+@[^\s:\r\n]+(?:(?::|\s+)[^#$%>\r\n]{0,160})?[#$%>]/, + // Traditional bracket prompt: "[root@host path]#" or "[ctx]$". + /^\[[^\]\r\n]{1,160}\][#$%>]/, + // macOS' historical default: "host:directory user$". + /^[A-Za-z0-9._-]{1,80}:[^#$%>\r\n]{0,120}\s+[A-Za-z0-9._-]{1,80}[#$%>]/, + // Powerline: the final segment separator marks the end of the prompt. + /^[^\r\n]{0,200}[\uE0B0\uE0B1]/, + // Starship / oh-my-zsh symbolic prompts, alone or after a short status/path. + /^(?:[^\r\n]{1,160}\s)?[❯➜➤λ](?=\s|$)/, + // Versioned shell prompts such as "bash-5.2$". + /^[A-Za-z][A-Za-z0-9._-]{0,60}[#$%>](?=\s|$)/, + // Minimal POSIX prompts. + /^[#$%>](?=\s|$)/, +]; + +export function detectPrompt(text: string): PromptMatch | null { + for (const pattern of PROMPT_PATTERNS) { + const match = pattern.exec(text); + if (match) return { end: match[0].length }; + } + return null; +} From c98dbf8a79088fb30fc6c0a75acbaa118ebc8318 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Thu, 6 Aug 2026 14:25:05 +0800 Subject: [PATCH 2/2] fix(terminal): harden prompt redaction --- src/lib/stores/app.svelte.ts | 8 +- src/lib/terminal/block-to-image.test.ts | 43 +++++++-- src/lib/terminal/block-to-image.ts | 93 +++++++++++++++---- .../terminal/command-block-redaction.test.ts | 8 ++ src/lib/terminal/prompt.test.ts | 5 + src/lib/terminal/prompt.ts | 5 +- 6 files changed, 131 insertions(+), 31 deletions(-) diff --git a/src/lib/stores/app.svelte.ts b/src/lib/stores/app.svelte.ts index 48c09f35..dbcfbe4f 100644 --- a/src/lib/stores/app.svelte.ts +++ b/src/lib/stores/app.svelte.ts @@ -828,7 +828,6 @@ export async function setCommandBlockPromptRedactEnabled(value: boolean) { value: String(value), }); _commandBlockRedaction.promptEnabled = value; - _commandBlockRedactionLoaded = true; } export async function setCommandBlockPromptReplacement(value: string) { @@ -837,11 +836,14 @@ export async function setCommandBlockPromptReplacement(value: string) { value, }); _commandBlockRedaction.promptReplacement = value; - _commandBlockRedactionLoaded = true; } export async function saveCommandBlockRedactRule(rule: CommandBlockRedactRule) { - await invoke("command_block_save_redact_rule", rule); + await invoke("command_block_save_redact_rule", { + id: rule.id, + pattern: rule.pattern, + replacement: rule.replacement, + }); await loadCommandBlockRedaction(true); } diff --git a/src/lib/terminal/block-to-image.test.ts b/src/lib/terminal/block-to-image.test.ts index f41fff65..7c9216d1 100644 --- a/src/lib/terminal/block-to-image.test.ts +++ b/src/lib/terminal/block-to-image.test.ts @@ -181,7 +181,7 @@ function rgbStringToInt(rgb: string): number { return 0; } -function fakeLine(cells: SpecCell[]) { +function fakeLine(cells: SpecCell[], isWrapped = false) { // 模拟 CJK:width=2 后跟一个 width=0 const expanded: (IBufferCell | undefined)[] = []; for (const c of cells) { @@ -196,7 +196,7 @@ function fakeLine(cells: SpecCell[]) { } return { length: expanded.length, - isWrapped: false, + isWrapped, getCell: (x: number) => expanded[x], }; } @@ -291,6 +291,15 @@ describe("extractImageRows", () => { expect(rows.map((r) => r.blockId)).toEqual([1, 2, 3]); expect(rows.map((r) => r.blockColor)).toEqual(["#111", "#222", "#333"]); }); + + it("preserves soft-wrap state for prompt recognition", () => { + const term = fakeTerm([ + fakeLine([{ ch: "a", width: 1 }]), + fakeLine([{ ch: "b", width: 1 }], true), + ]); + const rows = extractImageRows(term, [fakeBlock(1, "#abc", 0, 1)]); + expect(rows.map((row) => row.isWrapped)).toEqual([false, true]); + }); }); /* ───────────────────────── redactImageRows ───────────────────────── */ @@ -307,8 +316,8 @@ function imageCells(text: string, fg = "#eeeeee"): ImageCell[] { })); } -function imageRow(blockId: number, text: string): ImageRow { - return { blockId, blockColor: "#abc", cells: imageCells(text) }; +function imageRow(blockId: number, text: string, isWrapped = false): ImageRow { + return { blockId, blockColor: "#abc", isWrapped, cells: imageCells(text) }; } function imageText(row: ImageRow): string { @@ -343,8 +352,8 @@ describe("redactImageRows", () => { promptEnabled: true, promptReplacement: "anonymous@rssh", rules: [ - { id: "ip", pattern: String.raw`\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`, replacement: "" }, - { id: "secret", pattern: "secret", replacement: "$&-literal" }, + { pattern: String.raw`\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`, replacement: "" }, + { pattern: "secret", replacement: "$&-literal" }, ], }); expect(redacted.map(imageText)).toEqual([ @@ -357,7 +366,7 @@ describe("redactImageRows", () => { const [row] = redactImageRows([imageRow(1, "alice@prod:~$ id")], { promptEnabled: false, promptReplacement: "anonymous@rssh", - rules: [{ id: "host", pattern: "prod", replacement: "hidden" }], + rules: [{ pattern: "prod", replacement: "hidden" }], }); expect(imageText(row)).toBe("alice@hidden:~$ id"); }); @@ -371,6 +380,24 @@ describe("redactImageRows", () => { expect(imageText(row)).toBe("build > output.txt"); }); + it("redacts a prompt split across soft-wrapped image rows", () => { + const redacted = redactImageRows([ + imageRow(1, "alice@prod:~/"), + imageRow(1, "src$ id", true), + imageRow(1, "uid=1000"), + ], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [], + }); + + expect(redacted.map(imageText)).toEqual([ + "anonymous@rssh", + " id", + "uid=1000", + ]); + }); + it("keeps replacement styling and CJK display width", () => { const row = imageRow(1, "$ id"); row.cells[0].fg = "#ff0000"; @@ -387,7 +414,7 @@ describe("redactImageRows", () => { expect(() => redactImageRows([imageRow(1, "$ id")], { promptEnabled: true, promptReplacement: "anonymous@rssh", - rules: [{ id: "bad", pattern: "(?Px)", replacement: "" }], + rules: [{ pattern: "(?Px)", replacement: "" }], })).toThrow(); }); }); diff --git a/src/lib/terminal/block-to-image.ts b/src/lib/terminal/block-to-image.ts index 90d3af8e..d70c56ab 100644 --- a/src/lib/terminal/block-to-image.ts +++ b/src/lib/terminal/block-to-image.ts @@ -27,6 +27,7 @@ import { promptReplacementForTail, redactionRuleMatches, type CommandBlockRedactionSettings, + type CompiledCommandBlockRedaction, type CompiledCommandBlockRedactionRule, } from "./command-block-redaction"; @@ -54,6 +55,8 @@ export interface ImageCell { export interface ImageRow { blockId: number; blockColor: string; + /** xterm soft-wrap continuation; true means this row continues the previous. */ + isWrapped: boolean; cells: ImageCell[]; } @@ -119,7 +122,7 @@ function styleAt(cells: ReadonlyArray, offset: number): ImageCell | n if (offset < end) return cell; cursor = end; } - return cells.at(-1) ?? null; + return cells[cells.length - 1] ?? null; } /** Slice by UTF-16 text offsets while retaining cell styling. xterm normally @@ -179,37 +182,86 @@ function applyRules( return rules.reduce((current, rule) => applyRule(current, rule), [...cells]); } -export function redactImageRows( +function redactWrappedPromptRows( rows: ReadonlyArray, - settings: CommandBlockRedactionSettings, + promptEnd: number, + redaction: CompiledCommandBlockRedaction, ): ImageRow[] { - const redaction = compileCommandBlockRedaction(settings); - const seenBlocks = new Set(); + const promptStyle = styleAt(rows[0].cells, 0); + let remaining = promptEnd; - return rows.map((row) => { - const firstRow = !seenBlocks.has(row.blockId); - seenBlocks.add(row.blockId); - const text = rowText(row.cells); - const promptEnd = commandBlockPromptEnd(text, firstRow, redaction); - - if (promptEnd === null) { + return rows.map((row, index) => { + if (remaining === 0) { return { ...row, cells: applyRules(row.cells, redaction.rules) }; } - const style = styleAt(row.cells, 0); + const text = rowText(row.cells); + const consumed = Math.min(remaining, text.length); + remaining -= consumed; const tail = applyRules( - sliceCells(row.cells, promptEnd, text.length), + sliceCells(row.cells, consumed, text.length), redaction.rules, ); - const tailText = rowText(tail); - const replacement = promptReplacementForTail(redaction.promptReplacement, tailText); + if (index !== 0) return { ...row, cells: tail }; + + const replacement = promptReplacementForTail( + redaction.promptReplacement, + remaining === 0 ? rowText(tail) : "", + ); return { ...row, - cells: [...(style ? cellsFromText(replacement, style) : []), ...tail], + cells: [ + ...(promptStyle ? cellsFromText(replacement, promptStyle) : []), + ...tail, + ], }; }); } +export function redactImageRows( + rows: ReadonlyArray, + settings: CommandBlockRedactionSettings, +): ImageRow[] { + const redaction = compileCommandBlockRedaction(settings); + const seenBlocks = new Set(); + const out: ImageRow[] = []; + let index = 0; + + while (index < rows.length) { + const row = rows[index]; + const firstRow = !seenBlocks.has(row.blockId); + seenBlocks.add(row.blockId); + if (!firstRow) { + out.push({ ...row, cells: applyRules(row.cells, redaction.rules) }); + index++; + continue; + } + + let logicalEnd = index + 1; + while ( + logicalEnd < rows.length && + rows[logicalEnd].blockId === row.blockId && + rows[logicalEnd].isWrapped + ) { + logicalEnd++; + } + const logicalRows = rows.slice(index, logicalEnd); + const logicalText = logicalRows.map((item) => rowText(item.cells)).join(""); + const promptEnd = commandBlockPromptEnd(logicalText, true, redaction); + if (promptEnd === null) { + out.push(...logicalRows.map((item) => ({ + ...item, + cells: applyRules(item.cells, redaction.rules), + }))); + } else { + out.push(...redactWrappedPromptRows(logicalRows, promptEnd, redaction)); + } + index = logicalEnd; + } + + return out; +} + /* ───────────────────────── 数据抽取(纯函数,可测) ───────────────────────── */ /** 选中块 → 视觉行序列。视觉行不合并软换行(图片是"截图",要保留视觉布局)。 @@ -241,7 +293,12 @@ export function extractImageRows( if (last.ch === " " && last.bg === defaultBg(theme)) cells.pop(); else break; } - out.push({ blockId: block.id, blockColor: block.color, cells }); + out.push({ + blockId: block.id, + blockColor: block.color, + isWrapped: line.isWrapped, + cells, + }); } } return out; diff --git a/src/lib/terminal/command-block-redaction.test.ts b/src/lib/terminal/command-block-redaction.test.ts index 2da8e838..5a5a22a6 100644 --- a/src/lib/terminal/command-block-redaction.test.ts +++ b/src/lib/terminal/command-block-redaction.test.ts @@ -52,4 +52,12 @@ describe("redactCommandBlockTexts", () => { rules: [{ pattern: "(?Px)", replacement: "" }], })).toThrow(); }); + + it("rejects a zero-width pattern instead of looping", () => { + expect(() => redactCommandBlockTexts(["$ id"], { + promptEnabled: true, + promptReplacement: "anonymous@rssh", + rules: [{ pattern: "x*", replacement: "" }], + })).toThrow(/redact_zero_width_pattern/); + }); }); diff --git a/src/lib/terminal/prompt.test.ts b/src/lib/terminal/prompt.test.ts index 9616660a..f1f8a9f0 100644 --- a/src/lib/terminal/prompt.test.ts +++ b/src/lib/terminal/prompt.test.ts @@ -25,6 +25,11 @@ describe("detectPrompt", () => { expect(promptOf(line)).toBe(expected); }); + it("stops at the first Starship marker when the command contains another", () => { + expect(promptOf("❯ git log --format='%s ➜ %d'")).toBe("❯"); + expect(promptOf("~/src ❯ printf '➜'")).toBe("~/src ❯"); + }); + it.each([ "build > output.txt", "ERROR: request failed", diff --git a/src/lib/terminal/prompt.ts b/src/lib/terminal/prompt.ts index e2c3b928..7afccbc8 100644 --- a/src/lib/terminal/prompt.ts +++ b/src/lib/terminal/prompt.ts @@ -25,10 +25,11 @@ const PROMPT_PATTERNS: ReadonlyArray = [ /^\[[^\]\r\n]{1,160}\][#$%>]/, // macOS' historical default: "host:directory user$". /^[A-Za-z0-9._-]{1,80}:[^#$%>\r\n]{0,120}\s+[A-Za-z0-9._-]{1,80}[#$%>]/, - // Powerline: the final segment separator marks the end of the prompt. + // Powerline: the final segment separator marks the end of the prompt. Keep + // this greedy so a multi-segment prompt is fully redacted. /^[^\r\n]{0,200}[\uE0B0\uE0B1]/, // Starship / oh-my-zsh symbolic prompts, alone or after a short status/path. - /^(?:[^\r\n]{1,160}\s)?[❯➜➤λ](?=\s|$)/, + /^(?:[^❯➜➤λ\r\n]{1,160}\s)?[❯➜➤λ](?=\s|$)/, // Versioned shell prompts such as "bash-5.2$". /^[A-Za-z][A-Za-z0-9._-]{0,60}[#$%>](?=\s|$)/, // Minimal POSIX prompts.