use crate::commands::scan::Diag;
use crate::database::index::Indexer;
use crate::server::models::compute_portable_fingerprint;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
const MAX_TRIAGE_FILE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriageFile {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub decisions: Vec<TriageDecision>,
#[serde(default)]
pub suppression_rules: Vec<TriageSuppressionRule>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriageDecision {
pub fingerprint: String,
pub state: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub rule_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriageSuppressionRule {
pub by: String,
pub value: String,
#[serde(default = "default_suppressed")]
pub state: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
fn default_suppressed() -> String {
"suppressed".to_string()
}
pub fn triage_file_path(scan_root: &Path) -> Result<PathBuf, String> {
let root = canonical_scan_root(scan_root)?;
Ok(triage_file_path_for_root(&root))
}
fn canonical_scan_root(scan_root: &Path) -> Result<PathBuf, String> {
let canonical_root = scan_root
.canonicalize()
.map_err(|e| format!("failed to canonicalize scan root: {e}"))?;
let metadata =
std::fs::metadata(&canonical_root).map_err(|e| format!("failed to stat scan root: {e}"))?;
if !metadata.is_dir() {
return Err("scan root is not a directory".to_string());
}
Ok(canonical_root)
}
fn triage_file_path_for_root(root: &Path) -> PathBuf {
root.join(".nyx").join("triage.json")
}
fn validate_existing_path_within_root(path: &Path, root: &Path) -> Result<(), String> {
let canonical = path
.canonicalize()
.map_err(|e| format!("failed to canonicalize triage file path: {e}"))?;
if !canonical.starts_with(root) {
return Err("triage file path escapes scan root".to_string());
}
let metadata =
std::fs::metadata(&canonical).map_err(|e| format!("failed to stat triage file: {e}"))?;
if !metadata.is_file() {
return Err("triage file path is not a regular file".to_string());
}
Ok(())
}
fn validated_triage_file_path(scan_root: &Path) -> Result<PathBuf, String> {
let root = canonical_scan_root(scan_root)?;
let path = triage_file_path_for_root(&root);
if let Some(parent) = path.parent()
&& parent.exists()
{
let canonical_parent = parent
.canonicalize()
.map_err(|e| format!("failed to canonicalize triage directory: {e}"))?;
if !canonical_parent.starts_with(&root) {
return Err("triage directory escapes scan root".to_string());
}
let metadata = std::fs::metadata(&canonical_parent)
.map_err(|e| format!("failed to stat triage directory: {e}"))?;
if !metadata.is_dir() {
return Err("triage directory is not a directory".to_string());
}
}
if path.exists() {
validate_existing_path_within_root(&path, &root)?;
}
Ok(path)
}
pub fn load_triage_file(scan_root: &Path) -> Option<TriageFile> {
load_triage_file_checked(scan_root).ok().flatten()
}
pub fn load_triage_file_checked(scan_root: &Path) -> Result<Option<TriageFile>, String> {
let path = validated_triage_file_path(scan_root)?;
if !path.exists() {
return Ok(None);
}
let content = read_bounded_text_file(&path, MAX_TRIAGE_FILE_BYTES)?;
let parsed =
serde_json::from_str(&content).map_err(|e| format!("failed to parse triage file: {e}"))?;
Ok(Some(parsed))
}
pub fn save_triage_file(scan_root: &Path, file: &TriageFile) -> Result<(), String> {
let path = validated_triage_file_path(scan_root)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create .nyx directory: {e}"))?;
}
let json = serde_json::to_string_pretty(file)
.map_err(|e| format!("failed to serialize triage file: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("failed to write triage file: {e}"))?;
Ok(())
}
fn read_bounded_text_file(path: &Path, max_bytes: u64) -> Result<String, String> {
let file = std::fs::File::open(path).map_err(|e| format!("failed to open file: {e}"))?;
let metadata = file
.metadata()
.map_err(|e| format!("failed to stat file: {e}"))?;
if metadata.len() > max_bytes {
return Err(format!(
"triage file exceeds {max_bytes} bytes and was rejected"
));
}
let mut reader = std::io::BufReader::new(file).take(max_bytes);
let mut content = String::new();
reader
.read_to_string(&mut content)
.map_err(|e| format!("failed to read triage file: {e}"))?;
Ok(content)
}
pub fn export_triage(
pool: &Pool<SqliteConnectionManager>,
findings: &[Diag],
scan_root: &Path,
) -> Result<TriageFile, String> {
let idx = Indexer::from_pool("_triage", pool).map_err(|e| e.to_string())?;
let triage_map = idx.get_all_triage_states().map_err(|e| e.to_string())?;
let suppression_rules = idx.get_suppression_rules().map_err(|e| e.to_string())?;
let mut decisions = Vec::new();
for d in findings {
let local_fp = crate::server::models::compute_fingerprint(d);
if let Some((state, note, _)) = triage_map.get(&local_fp) {
if state == "open" {
continue; }
let portable_fp = compute_portable_fingerprint(d, scan_root);
let rel_path = d
.path
.strip_prefix(scan_root.to_string_lossy().as_ref())
.unwrap_or(&d.path)
.trim_start_matches('/')
.to_string();
decisions.push(TriageDecision {
fingerprint: portable_fp,
state: state.clone(),
note: note.clone(),
rule_id: d.id.clone(),
path: rel_path,
});
}
}
let rules = suppression_rules
.iter()
.filter(|r| r.suppress_by != "fingerprint")
.map(|r| TriageSuppressionRule {
by: r.suppress_by.clone(),
value: r.match_value.clone(),
state: r.state.clone(),
note: r.note.clone(),
})
.collect();
Ok(TriageFile {
version: 1,
decisions,
suppression_rules: rules,
})
}
pub fn import_triage(
pool: &Pool<SqliteConnectionManager>,
findings: &[Diag],
scan_root: &Path,
file: &TriageFile,
) -> Result<usize, String> {
let idx = Indexer::from_pool("_triage", pool).map_err(|e| e.to_string())?;
let mut portable_to_local: HashMap<String, String> = HashMap::new();
for d in findings {
let portable_fp = compute_portable_fingerprint(d, scan_root);
let local_fp = crate::server::models::compute_fingerprint(d);
portable_to_local.insert(portable_fp, local_fp);
}
let mut applied = 0;
for decision in &file.decisions {
if let Some(local_fp) = portable_to_local.get(&decision.fingerprint) {
let _ = idx.set_triage_state(local_fp, &decision.state, &decision.note, "import");
applied += 1;
}
}
for rule in &file.suppression_rules {
let _ = idx.add_suppression_rule(&rule.by, &rule.value, &rule.state, &rule.note);
}
Ok(applied)
}
#[allow(dead_code)]
pub fn sync_from_file(
pool: &Pool<SqliteConnectionManager>,
findings: &[Diag],
scan_root: &Path,
) -> Option<usize> {
let file = load_triage_file(scan_root)?;
import_triage(pool, findings, scan_root, &file).ok()
}
pub fn sync_to_file(
pool: &Pool<SqliteConnectionManager>,
findings: &[Diag],
scan_root: &Path,
) -> Result<(), String> {
let file = export_triage(pool, findings, scan_root)?;
save_triage_file(scan_root, &file)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oversized_triage_files_are_rejected() {
let root = tempfile::tempdir().unwrap();
let path = triage_file_path(root.path()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, vec![b'a'; (MAX_TRIAGE_FILE_BYTES as usize) + 1]).unwrap();
let err = load_triage_file_checked(root.path()).unwrap_err();
assert!(err.contains("exceeds"));
}
#[test]
fn triage_file_path_uses_canonical_root() {
let root = tempfile::tempdir().unwrap();
let requested = root.path().join(".");
let path = triage_file_path(&requested).unwrap();
assert_eq!(
path,
root.path()
.canonicalize()
.unwrap()
.join(".nyx")
.join("triage.json")
);
}
#[cfg(unix)]
#[test]
fn load_triage_file_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let escaped = outside.path().join("triage.json");
std::fs::write(
&escaped,
serde_json::to_string(&TriageFile {
version: 1,
decisions: vec![],
suppression_rules: vec![],
})
.unwrap(),
)
.unwrap();
symlink(outside.path(), root.path().join(".nyx")).unwrap();
let err = load_triage_file_checked(root.path()).unwrap_err();
assert!(err.contains("escapes scan root"));
}
#[cfg(unix)]
#[test]
fn save_triage_file_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), root.path().join(".nyx")).unwrap();
let err = save_triage_file(
root.path(),
&TriageFile {
version: 1,
decisions: vec![],
suppression_rules: vec![],
},
)
.unwrap_err();
assert!(err.contains("escapes scan root"));
}
}