Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 1 addition & 25 deletions src-tauri/src/ai/redact_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,12 @@ pub fn list(db: &Db) -> AppResult<Vec<RedactRuleRecord>> {
.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 {
Expand Down
73 changes: 73 additions & 0 deletions src-tauri/src/commands/command_block.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<RedactRuleRow>> {
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<Vec<RedactRuleRow>> {
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: "<X>".into(),
},
)
.unwrap_err();
assert_eq!(err.code(), code);
assert!(!list_redact_rules(&db).unwrap().iter().any(|r| r.id == id));
}
}
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#[cfg(desktop)]
pub mod cli;
pub mod command_block;
pub mod discovery;
pub mod external;
pub mod forward;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@ mod tests {
"highlights",
"snippets",
"ai_redact_rules",
"command_block_redaction",
"ai_command_blacklist",
"ai",
] {
Expand Down
86 changes: 86 additions & 0 deletions src-tauri/src/db/command_block_redact_rule.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<RedactRuleRow>> {
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::<Result<Vec<_>, _>>()?;
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: "<X>".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"));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
117 changes: 116 additions & 1 deletion src-tauri/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
let mut stmt = conn.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")?;
Expand Down Expand Up @@ -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)?;
}
Expand All @@ -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::<Result<_, _>>()
.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 = '<CUSTOM>'
WHERE id = 'aws-key';
INSERT INTO ai_redact_rules
(id, pattern, replacement, created_at, updated_at)
VALUES ('user-rule', 'private', '<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::<Result<_, _>>()
.unwrap();
assert!(!rules.iter().any(|(id, _)| id == "ip-10"));
assert!(rules
.iter()
.any(|(id, replacement)| id == "aws-key" && replacement == "<CUSTOM>"));
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();
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading