Compare commits
No commits in common. "cf1d5adb83615938a390b2cd26a8f129bbbe8f3d" and "5e596f0cd3049861f862945c9a1245dfe0b34a3f" have entirely different histories.
cf1d5adb83
...
5e596f0cd3
163
src-tauri/Cargo.lock
generated
163
src-tauri/Cargo.lock
generated
@ -355,26 +355,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bzip2"
|
|
||||||
version = "0.4.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
|
||||||
dependencies = [
|
|
||||||
"bzip2-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bzip2-sys"
|
|
||||||
version = "0.1.13+1.0.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@ -449,8 +429,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
|
||||||
"libc",
|
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -730,25 +708,6 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-deque"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-epoch",
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-epoch"
|
|
||||||
version = "0.9.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@ -1229,12 +1188,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "either"
|
|
||||||
version = "1.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
version = "0.13.8"
|
version = "0.13.8"
|
||||||
@ -2514,7 +2467,7 @@ dependencies = [
|
|||||||
"hmac",
|
"hmac",
|
||||||
"iterator-sorted",
|
"iterator-sorted",
|
||||||
"k256",
|
"k256",
|
||||||
"pbkdf2 0.12.2",
|
"pbkdf2",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"scrypt",
|
"scrypt",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2635,16 +2588,6 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jobserver"
|
|
||||||
version = "0.1.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@ -2875,16 +2818,13 @@ version = "0.31.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
|
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"itoa",
|
"itoa",
|
||||||
"linked-hash-map",
|
"linked-hash-map",
|
||||||
"log",
|
"log",
|
||||||
"md5",
|
"md5",
|
||||||
"nom",
|
|
||||||
"pom",
|
"pom",
|
||||||
"rayon",
|
|
||||||
"time",
|
"time",
|
||||||
"weezl",
|
"weezl",
|
||||||
]
|
]
|
||||||
@ -2998,12 +2938,6 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "minimal-lexical"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minisign-verify"
|
name = "minisign-verify"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@ -3188,16 +3122,6 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nom"
|
|
||||||
version = "7.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
"minimal-lexical",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -3518,17 +3442,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "password-hash"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@ -3547,18 +3460,6 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pbkdf2"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
"hmac",
|
|
||||||
"password-hash",
|
|
||||||
"sha2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pbkdf2"
|
name = "pbkdf2"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
@ -4260,26 +4161,6 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rayon"
|
|
||||||
version = "1.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
|
||||||
dependencies = [
|
|
||||||
"either",
|
|
||||||
"rayon-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rayon-core"
|
|
||||||
version = "1.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-deque",
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@ -4769,7 +4650,7 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pbkdf2 0.12.2",
|
"pbkdf2",
|
||||||
"salsa20",
|
"salsa20",
|
||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
@ -6371,10 +6252,8 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"infer 0.15.0",
|
"infer 0.15.0",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"lopdf",
|
|
||||||
"mockito",
|
"mockito",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
"quick-xml 0.36.2",
|
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@ -6399,7 +6278,6 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"warp",
|
"warp",
|
||||||
"zip 0.6.6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -7926,18 +7804,10 @@ version = "0.6.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bzip2",
|
|
||||||
"constant_time_eq 0.1.5",
|
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
"flate2",
|
"flate2",
|
||||||
"hmac",
|
|
||||||
"pbkdf2 0.11.0",
|
|
||||||
"sha1",
|
|
||||||
"time",
|
|
||||||
"zstd",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -7978,35 +7848,6 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstd"
|
|
||||||
version = "0.11.2+zstd.1.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
|
||||||
dependencies = [
|
|
||||||
"zstd-safe",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstd-safe"
|
|
||||||
version = "5.0.2+zstd.1.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"zstd-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstd-sys"
|
|
||||||
version = "2.0.16+zstd.1.5.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@ -45,9 +45,6 @@ warp = "0.3"
|
|||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
infer = "0.15"
|
infer = "0.15"
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
lopdf = "0.31"
|
|
||||||
zip = "0.6"
|
|
||||||
quick-xml = "0.36"
|
|
||||||
rmcp = { version = "1.7.0", features = [
|
rmcp = { version = "1.7.0", features = [
|
||||||
"client",
|
"client",
|
||||||
"transport-child-process",
|
"transport-child-process",
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
use std::io::Write;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct SudoOutput {
|
|
||||||
pub stdout: String,
|
|
||||||
pub stderr: String,
|
|
||||||
pub success: bool,
|
|
||||||
pub exit_code: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a command via sudo, passing the password via stdin (never via cmdline args).
|
|
||||||
/// `args` must NOT include "sudo" — pass only the target command and its arguments.
|
|
||||||
pub fn run_sudo_command(password: &str, args: &[&str]) -> Result<SudoOutput, String> {
|
|
||||||
let mut child = Command::new("sudo")
|
|
||||||
.arg("-S") // read password from stdin
|
|
||||||
.arg("-p")
|
|
||||||
.arg("") // suppress prompt text
|
|
||||||
.arg("--") // end of sudo options — prevents injection
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| format!("Failed to spawn sudo: {e}"))?;
|
|
||||||
|
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
writeln!(stdin, "{}", password)
|
|
||||||
.map_err(|e| format!("Failed to write password to stdin: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = child
|
|
||||||
.wait_with_output()
|
|
||||||
.map_err(|e| format!("Failed to wait for sudo: {e}"))?;
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
||||||
let stderr = strip_sudo_password_prompt(&String::from_utf8_lossy(&output.stderr));
|
|
||||||
|
|
||||||
Ok(SudoOutput {
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
success: output.status.success(),
|
|
||||||
exit_code: output.status.code(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip "[sudo] password for ..." prompt lines from stderr before logging.
|
|
||||||
fn strip_sudo_password_prompt(text: &str) -> String {
|
|
||||||
text.lines()
|
|
||||||
.filter(|line| {
|
|
||||||
let lower = line.to_lowercase();
|
|
||||||
!lower.contains("[sudo] password") && !lower.starts_with("password:")
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like run_sudo_command but writes a sanitized audit entry first.
|
|
||||||
/// The password is NEVER included in audit details.
|
|
||||||
pub fn run_sudo_command_audited(
|
|
||||||
password: &str,
|
|
||||||
args: &[&str],
|
|
||||||
db: &rusqlite::Connection,
|
|
||||||
) -> Result<SudoOutput, String> {
|
|
||||||
let sanitized_args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
|
||||||
let details = serde_json::json!({
|
|
||||||
"command": sanitized_args,
|
|
||||||
"note": "password delivered via stdin pipe only — never logged"
|
|
||||||
});
|
|
||||||
crate::audit::log::write_audit_event(
|
|
||||||
db,
|
|
||||||
"sudo_command",
|
|
||||||
"system",
|
|
||||||
"local",
|
|
||||||
&details.to_string(),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Audit log failed: {e}"))?;
|
|
||||||
|
|
||||||
run_sudo_command(password, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_sudo_password_prompt_removes_prompt_lines() {
|
|
||||||
let stderr = "[sudo] password for alice:\nsome other output\nPassword: bad line";
|
|
||||||
let cleaned = strip_sudo_password_prompt(stderr);
|
|
||||||
assert!(!cleaned.contains("[sudo] password"));
|
|
||||||
assert!(!cleaned.contains("Password:"));
|
|
||||||
assert!(cleaned.contains("some other output"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_sudo_password_prompt_keeps_clean_output() {
|
|
||||||
let stderr = "Error: permission denied\nsome warning";
|
|
||||||
let cleaned = strip_sudo_password_prompt(stderr);
|
|
||||||
assert_eq!(cleaned, "Error: permission denied\nsome warning");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_run_sudo_command_audited_does_not_log_password() {
|
|
||||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
||||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
let _result = run_sudo_command_audited("my-secret-password", &["true"], &conn);
|
|
||||||
// result may fail in test environment, but audit log must exist
|
|
||||||
let details: String = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT details FROM audit_log WHERE action = 'sudo_command' LIMIT 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!details.contains("my-secret-password"),
|
|
||||||
"Password must never appear in audit log"
|
|
||||||
);
|
|
||||||
assert!(details.contains("true"), "Command args should be logged");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -204,19 +204,15 @@ pub async fn chat_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load conversation history across ALL conversations for this issue
|
// Load conversation history (use and_then to keep stmt lifetime within closure)
|
||||||
let history: Vec<Message> = {
|
let history: Vec<Message> = {
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
let raw: Vec<(String, String)> = db
|
let raw: Vec<(String, String)> = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT am.role, am.content \
|
"SELECT role, content FROM ai_messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
||||||
FROM ai_messages am \
|
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = ?1 \
|
|
||||||
ORDER BY am.created_at ASC",
|
|
||||||
)
|
)
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| {
|
||||||
stmt.query_map([&issue_id], |row| {
|
stmt.query_map([&conversation_id], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
})
|
})
|
||||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||||
@ -1029,194 +1025,4 @@ mod tests {
|
|||||||
let list = extract_list(text, "KEY_FINDINGS:");
|
let list = extract_list(text, "KEY_FINDINGS:");
|
||||||
assert_eq!(list, vec!["Actual item"]);
|
assert_eq!(list, vec!["Actual item"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_history_query_same_conversation() {
|
|
||||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
||||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params!["issue-1", "Test", &now, &now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-1", "conv-1", "user", "Hello", 5, "2025-01-01 10:00:00"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-2", "conv-1", "assistant", "Hi there", 8, "2025-01-01 10:00:01"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let issue_id = "issue-1";
|
|
||||||
let raw: Vec<(String, String)> = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT am.role, am.content \
|
|
||||||
FROM ai_messages am \
|
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = ?1 \
|
|
||||||
ORDER BY am.created_at ASC",
|
|
||||||
)
|
|
||||||
.and_then(|mut stmt| {
|
|
||||||
stmt.query_map([&issue_id], |row| {
|
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
||||||
})
|
|
||||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(raw.len(), 2);
|
|
||||||
assert_eq!(raw[0], ("user".to_string(), "Hello".to_string()));
|
|
||||||
assert_eq!(raw[1], ("assistant".to_string(), "Hi there".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_history_query_across_conversations() {
|
|
||||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
||||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params!["issue-1", "Test", &now, &now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-1", "conv-1", "user", "From conv 1", 5, "2025-01-01 10:00:00"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-2", "conv-2", "user", "From conv 2", 5, "2025-01-01 11:00:00"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let issue_id = "issue-1";
|
|
||||||
let raw: Vec<(String, String)> = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT am.role, am.content \
|
|
||||||
FROM ai_messages am \
|
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = ?1 \
|
|
||||||
ORDER BY am.created_at ASC",
|
|
||||||
)
|
|
||||||
.and_then(|mut stmt| {
|
|
||||||
stmt.query_map([&issue_id], |row| {
|
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
||||||
})
|
|
||||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(raw.len(), 2);
|
|
||||||
assert_eq!(raw[0].1, "From conv 1");
|
|
||||||
assert_eq!(raw[1].1, "From conv 2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_history_query_empty_for_new_issue() {
|
|
||||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
||||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params!["issue-new", "Empty", &now, &now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let issue_id = "issue-new";
|
|
||||||
let raw: Vec<(String, String)> = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT am.role, am.content \
|
|
||||||
FROM ai_messages am \
|
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = ?1 \
|
|
||||||
ORDER BY am.created_at ASC",
|
|
||||||
)
|
|
||||||
.and_then(|mut stmt| {
|
|
||||||
stmt.query_map([&issue_id], |row| {
|
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
||||||
})
|
|
||||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(raw.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_history_query_ordered_by_created_at() {
|
|
||||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
||||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params!["issue-1", "Test", &now, &now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// Insert messages out of order: conv-2 message is earlier than conv-1 message
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-1", "conv-1", "user", "Second", 5, "2025-01-01 12:00:00"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-2", "conv-2", "user", "First", 5, "2025-01-01 09:00:00"],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let issue_id = "issue-1";
|
|
||||||
let raw: Vec<(String, String)> = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT am.role, am.content \
|
|
||||||
FROM ai_messages am \
|
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = ?1 \
|
|
||||||
ORDER BY am.created_at ASC",
|
|
||||||
)
|
|
||||||
.and_then(|mut stmt| {
|
|
||||||
stmt.query_map([&issue_id], |row| {
|
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
||||||
})
|
|
||||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(raw.len(), 2);
|
|
||||||
assert_eq!(raw[0].1, "First");
|
|
||||||
assert_eq!(raw[1].1, "Second");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,141 +9,6 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
const MAX_LOG_FILE_BYTES: u64 = 50 * 1024 * 1024;
|
const MAX_LOG_FILE_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
|
|
||||||
const SAFE_TEXT_EXTENSIONS: &[&str] = &[
|
|
||||||
"log",
|
|
||||||
"txt",
|
|
||||||
"out",
|
|
||||||
"err",
|
|
||||||
"syslog",
|
|
||||||
"journal",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"json",
|
|
||||||
"toml",
|
|
||||||
"xml",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
"config",
|
|
||||||
"env",
|
|
||||||
"properties",
|
|
||||||
"md",
|
|
||||||
"markdown",
|
|
||||||
"rst",
|
|
||||||
"csv",
|
|
||||||
"tsv",
|
|
||||||
"ndjson",
|
|
||||||
"jsonl",
|
|
||||||
"sql",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"py",
|
|
||||||
"js",
|
|
||||||
"ts",
|
|
||||||
"rb",
|
|
||||||
"go",
|
|
||||||
"rs",
|
|
||||||
"java",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"diff",
|
|
||||||
"patch",
|
|
||||||
"rtf",
|
|
||||||
];
|
|
||||||
|
|
||||||
const SAFE_BINARY_EXTENSIONS: &[&str] = &["pdf", "docx", "doc", "xlsx", "xls"];
|
|
||||||
|
|
||||||
pub fn is_safe_file(path: &Path) -> bool {
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|e| e.to_lowercase());
|
|
||||||
match ext.as_deref() {
|
|
||||||
Some(e) => SAFE_TEXT_EXTENSIONS.contains(&e) || SAFE_BINARY_EXTENSIONS.contains(&e),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extract_text_content(path: &Path) -> Result<String, String> {
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|e| e.to_lowercase())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
match ext.as_str() {
|
|
||||||
"pdf" => extract_pdf_text(path),
|
|
||||||
"docx" | "doc" => extract_docx_text(path),
|
|
||||||
"xlsx" | "xls" => Err(format!(
|
|
||||||
"Spreadsheet format .{ext} is not yet supported for text extraction. \
|
|
||||||
Export the sheet as CSV and upload that instead."
|
|
||||||
)),
|
|
||||||
_ => std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_pdf_text(path: &Path) -> Result<String, String> {
|
|
||||||
let doc = lopdf::Document::load(path).map_err(|e| format!("Failed to parse PDF: {e}"))?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut pages: Vec<u32> = doc.get_pages().keys().copied().collect();
|
|
||||||
pages.sort_unstable();
|
|
||||||
for page_num in pages {
|
|
||||||
if let Ok(content) = doc.extract_text(&[page_num]) {
|
|
||||||
text.push_str(&content);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
return Err("PDF contains no extractable text (may be a scanned image)".to_string());
|
|
||||||
}
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_docx_text(path: &Path) -> Result<String, String> {
|
|
||||||
use std::io::Read as _;
|
|
||||||
let file = std::fs::File::open(path).map_err(|e| format!("Failed to open file: {e}"))?;
|
|
||||||
let mut archive =
|
|
||||||
zip::ZipArchive::new(file).map_err(|e| format!("Failed to open as ZIP/DOCX: {e}"))?;
|
|
||||||
let mut xml_content = String::new();
|
|
||||||
{
|
|
||||||
// Safety: only one hardcoded entry is ever accessed; no arbitrary path extraction is
|
|
||||||
// performed, so zip-slip path traversal attacks cannot apply here.
|
|
||||||
let mut doc_xml = archive
|
|
||||||
.by_name("word/document.xml")
|
|
||||||
.map_err(|_| "Not a valid DOCX: missing word/document.xml".to_string())?;
|
|
||||||
doc_xml
|
|
||||||
.read_to_string(&mut xml_content)
|
|
||||||
.map_err(|e| format!("Failed to read document.xml: {e}"))?;
|
|
||||||
}
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut reader = quick_xml::Reader::from_str(&xml_content);
|
|
||||||
reader.config_mut().trim_text(true);
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
loop {
|
|
||||||
match reader.read_event_into(&mut buf) {
|
|
||||||
Ok(quick_xml::events::Event::Text(e)) => {
|
|
||||||
if let Ok(s) = e.unescape() {
|
|
||||||
let trimmed = s.trim().to_string();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
text.push_str(&trimmed);
|
|
||||||
text.push(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(quick_xml::events::Event::Eof) => break,
|
|
||||||
Err(e) => return Err(format!("XML parse error: {e}")),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
buf.clear();
|
|
||||||
}
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
return Err("DOCX contains no extractable text".to_string());
|
|
||||||
}
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_log_file_path(file_path: &str) -> Result<PathBuf, String> {
|
fn validate_log_file_path(file_path: &str) -> Result<PathBuf, String> {
|
||||||
let path = Path::new(file_path);
|
let path = Path::new(file_path);
|
||||||
let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?;
|
let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?;
|
||||||
@ -170,59 +35,24 @@ pub async fn upload_log_file(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<LogFile, String> {
|
) -> Result<LogFile, String> {
|
||||||
let canonical_path = validate_log_file_path(&file_path)?;
|
let canonical_path = validate_log_file_path(&file_path)?;
|
||||||
|
let content = std::fs::read(&canonical_path).map_err(|_| "Failed to read selected log file")?;
|
||||||
if !is_safe_file(&canonical_path) {
|
let content_hash = format!("{:x}", Sha256::digest(&content));
|
||||||
let ext = canonical_path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("(none)");
|
|
||||||
return Err(format!(
|
|
||||||
"File type '.{ext}' is not supported. Supported formats include .log, .txt, .json, .pdf, .docx, .md, and many more."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_name = canonical_path
|
let file_name = canonical_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let file_size = content.len() as i64;
|
||||||
let file_ext = canonical_path
|
let mime_type = if file_name.ends_with(".json") {
|
||||||
.extension()
|
"application/json"
|
||||||
.and_then(|e| e.to_str())
|
} else if file_name.ends_with(".xml") {
|
||||||
.map(|e| e.to_lowercase())
|
"application/xml"
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let extracted_text = extract_text_content(&canonical_path)
|
|
||||||
.map_err(|e| format!("Failed to read file content: {e}"))?;
|
|
||||||
let content_bytes = extracted_text.as_bytes();
|
|
||||||
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
|
|
||||||
let file_size = content_bytes.len() as i64;
|
|
||||||
|
|
||||||
let mime_type = match file_ext.as_str() {
|
|
||||||
"json" => "application/json",
|
|
||||||
"xml" => "application/xml",
|
|
||||||
"yaml" | "yml" => "application/yaml",
|
|
||||||
"pdf" => "application/pdf",
|
|
||||||
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
"doc" => "application/msword",
|
|
||||||
"md" | "markdown" => "text/markdown",
|
|
||||||
"csv" | "tsv" => "text/csv",
|
|
||||||
"html" | "htm" => "text/html",
|
|
||||||
_ => "text/plain",
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_binary = SAFE_BINARY_EXTENSIONS.contains(&file_ext.as_str());
|
|
||||||
let stored_path = if is_binary {
|
|
||||||
let extracted_path = canonical_path.with_extension("extracted.txt");
|
|
||||||
std::fs::write(&extracted_path, &extracted_text)
|
|
||||||
.map_err(|e| format!("Failed to write extracted text: {e}"))?;
|
|
||||||
extracted_path.to_string_lossy().to_string()
|
|
||||||
} else {
|
} else {
|
||||||
canonical_path.to_string_lossy().to_string()
|
"text/plain"
|
||||||
};
|
};
|
||||||
|
|
||||||
let log_file = LogFile::new(issue_id.clone(), file_name, stored_path, file_size);
|
let canonical_file_path = canonical_path.to_string_lossy().to_string();
|
||||||
|
let log_file = LogFile::new(issue_id.clone(), file_name, canonical_file_path, file_size);
|
||||||
let log_file = LogFile {
|
let log_file = LogFile {
|
||||||
content_hash: content_hash.clone(),
|
content_hash: content_hash.clone(),
|
||||||
mime_type: mime_type.to_string(),
|
mime_type: mime_type.to_string(),
|
||||||
@ -274,36 +104,17 @@ pub async fn upload_log_file_by_content(
|
|||||||
content: String,
|
content: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<LogFile, String> {
|
) -> Result<LogFile, String> {
|
||||||
let fake_path = Path::new(&file_name);
|
|
||||||
if !is_safe_file(fake_path) {
|
|
||||||
let ext = fake_path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("(none)");
|
|
||||||
return Err(format!("File type '.{ext}' is not supported."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_bytes = content.as_bytes();
|
let content_bytes = content.as_bytes();
|
||||||
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
|
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
|
||||||
let file_size = content_bytes.len() as i64;
|
let file_size = content_bytes.len() as i64;
|
||||||
|
|
||||||
let file_ext = fake_path
|
// Determine mime type based on file extension
|
||||||
.extension()
|
let mime_type = if file_name.ends_with(".json") {
|
||||||
.and_then(|e| e.to_str())
|
"application/json"
|
||||||
.map(|e| e.to_lowercase())
|
} else if file_name.ends_with(".xml") {
|
||||||
.unwrap_or_default();
|
"application/xml"
|
||||||
|
} else {
|
||||||
let mime_type = match file_ext.as_str() {
|
"text/plain"
|
||||||
"json" => "application/json",
|
|
||||||
"xml" => "application/xml",
|
|
||||||
"yaml" | "yml" => "application/yaml",
|
|
||||||
"pdf" => "application/pdf",
|
|
||||||
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
"doc" => "application/msword",
|
|
||||||
"md" | "markdown" => "text/markdown",
|
|
||||||
"csv" | "tsv" => "text/csv",
|
|
||||||
"html" | "htm" => "text/html",
|
|
||||||
_ => "text/plain",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the file_name as the file_path for DB storage
|
// Use the file_name as the file_path for DB storage
|
||||||
@ -517,68 +328,4 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
let _ = std::fs::remove_file(file_path);
|
let _ = std::fs::remove_file(file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_allows_txt() {
|
|
||||||
assert!(is_safe_file(Path::new("file.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_allows_md() {
|
|
||||||
assert!(is_safe_file(Path::new("readme.md")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_allows_pdf() {
|
|
||||||
assert!(is_safe_file(Path::new("report.pdf")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_allows_docx() {
|
|
||||||
assert!(is_safe_file(Path::new("doc.docx")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_rejects_exe() {
|
|
||||||
assert!(!is_safe_file(Path::new("malware.exe")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_rejects_dll() {
|
|
||||||
assert!(!is_safe_file(Path::new("library.dll")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_rejects_zip_directly() {
|
|
||||||
assert!(!is_safe_file(Path::new("archive.zip")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_case_insensitive() {
|
|
||||||
assert!(is_safe_file(Path::new("file.TXT")));
|
|
||||||
assert!(is_safe_file(Path::new("file.Log")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_safe_file_no_extension_rejected() {
|
|
||||||
assert!(!is_safe_file(Path::new("Makefile")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_text_plain_file() {
|
|
||||||
let dir = std::env::temp_dir();
|
|
||||||
let path = dir.join(format!("tftsr-test-extract-{}.txt", uuid::Uuid::now_v7()));
|
|
||||||
std::fs::write(&path, "hello world").unwrap();
|
|
||||||
let result = extract_text_content(&path);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(result.unwrap().trim(), "hello world");
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_text_unsupported_binary_returns_error() {
|
|
||||||
let result = extract_text_content(Path::new("data.xlsx"));
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().contains("not yet supported"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -347,7 +347,7 @@ pub async fn list_issues(
|
|||||||
let offset = filter.offset.unwrap_or(0);
|
let offset = filter.offset.unwrap_or(0);
|
||||||
|
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
|
"SELECT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
|
||||||
(SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \
|
(SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \
|
||||||
(SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \
|
(SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \
|
||||||
FROM issues i WHERE 1=1",
|
FROM issues i WHERE 1=1",
|
||||||
@ -384,19 +384,9 @@ pub async fn list_issues(
|
|||||||
}
|
}
|
||||||
if let Some(ref search) = filter.search {
|
if let Some(ref search) = filter.search {
|
||||||
let pattern = format!("%{search}%");
|
let pattern = format!("%{search}%");
|
||||||
let idx = params.len() + 1;
|
|
||||||
sql.push_str(&format!(
|
sql.push_str(&format!(
|
||||||
" AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
|
" AND (i.title LIKE ?{0} OR i.description LIKE ?{0} OR i.category LIKE ?{0})",
|
||||||
OR EXISTS (SELECT 1 FROM ai_messages am \
|
params.len() + 1
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
|
|
||||||
OR EXISTS (SELECT 1 FROM resolution_steps rs \
|
|
||||||
WHERE rs.issue_id = i.id \
|
|
||||||
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
|
|
||||||
OR EXISTS (SELECT 1 FROM log_files lf \
|
|
||||||
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
|
|
||||||
OR EXISTS (SELECT 1 FROM timeline_events te \
|
|
||||||
WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))",
|
|
||||||
));
|
));
|
||||||
params.push(Box::new(pattern));
|
params.push(Box::new(pattern));
|
||||||
}
|
}
|
||||||
@ -645,163 +635,3 @@ pub async fn get_timeline_events(
|
|||||||
.collect();
|
.collect();
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
fn setup_test_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) {
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \
|
|
||||||
VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')",
|
|
||||||
rusqlite::params![id, title, description, &now, &now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_search_query(conn: &Connection, search: &str) -> Vec<String> {
|
|
||||||
let pattern = format!("%{search}%");
|
|
||||||
let idx = 1;
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
|
|
||||||
(SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \
|
|
||||||
(SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \
|
|
||||||
FROM issues i WHERE 1=1 \
|
|
||||||
AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
|
|
||||||
OR EXISTS (SELECT 1 FROM ai_messages am \
|
|
||||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
|
||||||
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
|
|
||||||
OR EXISTS (SELECT 1 FROM resolution_steps rs \
|
|
||||||
WHERE rs.issue_id = i.id \
|
|
||||||
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
|
|
||||||
OR EXISTS (SELECT 1 FROM log_files lf \
|
|
||||||
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
|
|
||||||
OR EXISTS (SELECT 1 FROM timeline_events te \
|
|
||||||
WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \
|
|
||||||
ORDER BY i.updated_at DESC"
|
|
||||||
);
|
|
||||||
let mut stmt = conn.prepare(&sql).unwrap();
|
|
||||||
stmt.query_map([&pattern], |row| row.get::<_, String>(0))
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_finds_by_title() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details");
|
|
||||||
insert_issue(&conn, "issue-2", "Network timeout", "No details");
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "Kubernetes");
|
|
||||||
assert_eq!(results, vec!["issue-1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_finds_by_description() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(
|
|
||||||
&conn,
|
|
||||||
"issue-1",
|
|
||||||
"Generic title",
|
|
||||||
"The pod was killed due to memory pressure",
|
|
||||||
);
|
|
||||||
insert_issue(&conn, "issue-2", "Other", "All fine");
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "memory pressure");
|
|
||||||
assert_eq!(results, vec!["issue-1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_finds_by_ai_message_content() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
|
|
||||||
).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now],
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "deadlock in PostgreSQL");
|
|
||||||
assert_eq!(results, vec!["issue-1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_finds_by_resolution_step_answer() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
||||||
rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now],
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "pool exhausted");
|
|
||||||
assert_eq!(results, vec!["issue-1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_finds_by_log_file_name() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
||||||
rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0],
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "nginx-error");
|
|
||||||
assert_eq!(results, vec!["issue-1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_finds_by_timeline_description() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"],
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "DNS resolution");
|
|
||||||
assert_eq!(results, vec!["issue-1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_no_duplicates_for_multiple_matches() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
insert_issue(
|
|
||||||
&conn,
|
|
||||||
"issue-1",
|
|
||||||
"Kubernetes crash",
|
|
||||||
"Kubernetes pod killed",
|
|
||||||
);
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
|
|
||||||
).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now],
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
let results = run_search_query(&conn, "Kubernetes");
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
assert_eq!(results[0], "issue-1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
pub mod agentic;
|
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
|||||||
@ -284,81 +284,3 @@ pub async fn get_app_version() -> Result<String, String> {
|
|||||||
.or_else(|_| env::var("CARGO_PKG_VERSION"))
|
.or_else(|_| env::var("CARGO_PKG_VERSION"))
|
||||||
.map_err(|e| format!("Failed to get version: {e}"))
|
.map_err(|e| format!("Failed to get version: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sudo credential commands ---
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct SudoConfigStatus {
|
|
||||||
pub configured: bool,
|
|
||||||
pub username: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_sudo_password(
|
|
||||||
password: String,
|
|
||||||
username: Option<String>,
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let encrypted = crate::integrations::auth::encrypt_token(&password)?;
|
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
||||||
let id = uuid::Uuid::now_v7().to_string();
|
|
||||||
let uname = username.unwrap_or_default();
|
|
||||||
db.execute(
|
|
||||||
"INSERT OR REPLACE INTO sudo_config (id, username, encrypted_password, created_at, updated_at) \
|
|
||||||
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
|
|
||||||
rusqlite::params![id, uname, encrypted],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to store sudo config: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_sudo_config_status(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<SudoConfigStatus, String> {
|
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
||||||
let result: Option<(String, String)> = db
|
|
||||||
.prepare("SELECT username, updated_at FROM sudo_config LIMIT 1")
|
|
||||||
.and_then(|mut stmt| {
|
|
||||||
stmt.query_row([], |row| {
|
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
match result {
|
|
||||||
Some((username, updated_at)) => Ok(SudoConfigStatus {
|
|
||||||
configured: true,
|
|
||||||
username,
|
|
||||||
updated_at,
|
|
||||||
}),
|
|
||||||
None => Ok(SudoConfigStatus {
|
|
||||||
configured: false,
|
|
||||||
username: String::new(),
|
|
||||||
updated_at: String::new(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn test_sudo_password(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
|
||||||
let encrypted: Option<String> = {
|
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
||||||
db.prepare("SELECT encrypted_password FROM sudo_config LIMIT 1")
|
|
||||||
.and_then(|mut stmt| stmt.query_row([], |row| row.get::<_, String>(0)))
|
|
||||||
.ok()
|
|
||||||
};
|
|
||||||
let encrypted = encrypted.ok_or("No sudo password configured")?;
|
|
||||||
let password = crate::integrations::auth::decrypt_token(&encrypted)?;
|
|
||||||
let result = crate::commands::agentic::run_sudo_command(&password, &["true"])
|
|
||||||
.map_err(|e| format!("Sudo test failed: {e}"))?;
|
|
||||||
Ok(result.success)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn clear_sudo_password(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
||||||
db.execute("DELETE FROM sudo_config", [])
|
|
||||||
.map_err(|e| format!("Failed to clear sudo config: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@ -249,16 +249,6 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
|||||||
FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
|
FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
|
||||||
);",
|
);",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"019_create_sudo_config",
|
|
||||||
"CREATE TABLE IF NOT EXISTS sudo_config (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL DEFAULT '',
|
|
||||||
encrypted_password TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);",
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, sql) in migrations {
|
for (name, sql) in migrations {
|
||||||
@ -1044,61 +1034,4 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(applied, 1, "018 should only be recorded once");
|
assert_eq!(applied, 1, "018 should only be recorded once");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Migration 019: sudo_config ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_019_sudo_config_table_exists() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_019_sudo_config_columns() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let mut stmt = conn.prepare("PRAGMA table_info(sudo_config)").unwrap();
|
|
||||||
let columns: Vec<String> = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.unwrap()
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(columns.contains(&"id".to_string()));
|
|
||||||
assert!(columns.contains(&"username".to_string()));
|
|
||||||
assert!(columns.contains(&"encrypted_password".to_string()));
|
|
||||||
assert!(columns.contains(&"created_at".to_string()));
|
|
||||||
assert!(columns.contains(&"updated_at".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_019_idempotent() {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
run_migrations(&conn).unwrap();
|
|
||||||
run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 1, "sudo_config table should exist after double-run");
|
|
||||||
|
|
||||||
let applied: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM _migrations WHERE name = '019_create_sudo_config'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(applied, 1, "019 should only be recorded once");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,10 +133,6 @@ pub fn run() {
|
|||||||
commands::system::update_settings,
|
commands::system::update_settings,
|
||||||
commands::system::get_audit_log,
|
commands::system::get_audit_log,
|
||||||
commands::system::get_app_version,
|
commands::system::get_app_version,
|
||||||
commands::system::set_sudo_password,
|
|
||||||
commands::system::get_sudo_config_status,
|
|
||||||
commands::system::test_sudo_password,
|
|
||||||
commands::system::clear_sudo_password,
|
|
||||||
// MCP Servers
|
// MCP Servers
|
||||||
mcp::commands::list_mcp_servers,
|
mcp::commands::list_mcp_servers,
|
||||||
mcp::commands::create_mcp_server,
|
mcp::commands::create_mcp_server,
|
||||||
|
|||||||
@ -591,26 +591,6 @@ export function initiateMcpOauthCmd(id: string): Promise<void> {
|
|||||||
return invoke<void>("initiate_mcp_oauth", { id });
|
return invoke<void>("initiate_mcp_oauth", { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sudo credential commands ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface SudoConfigStatus {
|
|
||||||
configured: boolean;
|
|
||||||
username: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setSudoPasswordCmd = (password: string, username?: string) =>
|
|
||||||
invoke<void>("set_sudo_password", { password, username: username ?? null });
|
|
||||||
|
|
||||||
export const getSudoConfigStatusCmd = () =>
|
|
||||||
invoke<SudoConfigStatus>("get_sudo_config_status");
|
|
||||||
|
|
||||||
export const testSudoPasswordCmd = () =>
|
|
||||||
invoke<boolean>("test_sudo_password");
|
|
||||||
|
|
||||||
export const clearSudoPasswordCmd = () =>
|
|
||||||
invoke<void>("clear_sudo_password");
|
|
||||||
|
|
||||||
// ─── System / Version ─────────────────────────────────────────────────────────
|
// ─── System / Version ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const getAppVersionCmd = () =>
|
export const getAppVersionCmd = () =>
|
||||||
|
|||||||
@ -252,21 +252,8 @@ export default function LogUpload() {
|
|||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept=".log,.txt,.out,.err,.syslog,.journal,.yaml,.yml,.json,.toml,.xml,.ini,.cfg,.conf,.config,.env,.properties,.md,.markdown,.rst,.csv,.tsv,.ndjson,.jsonl,.sql,.sh,.bash,.zsh,.py,.js,.ts,.rb,.go,.rs,.java,.html,.htm,.css,.diff,.patch,.pdf,.docx,.doc,.rtf,.xlsx,.xls"
|
accept=".log,.txt,.json,.csv,.xml,.yaml,.yml"
|
||||||
/>
|
/>
|
||||||
<details className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<summary className="cursor-pointer hover:text-gray-700 dark:hover:text-gray-200">
|
|
||||||
Supported formats
|
|
||||||
</summary>
|
|
||||||
<div className="mt-1 pl-3 space-y-1">
|
|
||||||
<div><span className="font-medium">Logs & text:</span> .log, .txt, .out, .err, .syslog, .journal</div>
|
|
||||||
<div><span className="font-medium">Config & markup:</span> .yaml, .yml, .json, .toml, .xml, .ini, .cfg, .conf, .env, .properties</div>
|
|
||||||
<div><span className="font-medium">Documents:</span> .pdf, .docx, .doc, .md, .rst, .rtf</div>
|
|
||||||
<div><span className="font-medium">Data:</span> .csv, .tsv, .xlsx, .xls, .ndjson, .jsonl, .sql</div>
|
|
||||||
<div><span className="font-medium">Code & scripts:</span> .sh, .bash, .zsh, .py, .js, .ts, .rb, .go, .rs, .java, .html, .css, .diff, .patch</div>
|
|
||||||
<p className="mt-1 italic">Binary formats (PDF, DOCX, XLSX) will have their text extracted automatically.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File list */}
|
{/* File list */}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Shield, RefreshCw, Lock } from "lucide-react";
|
import { Shield, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
@ -7,15 +7,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Badge,
|
Badge,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import {
|
import { getAuditLogCmd, type AuditEntry } from "@/lib/tauriCommands";
|
||||||
getAuditLogCmd,
|
|
||||||
getSudoConfigStatusCmd,
|
|
||||||
setSudoPasswordCmd,
|
|
||||||
testSudoPasswordCmd,
|
|
||||||
clearSudoPasswordCmd,
|
|
||||||
type AuditEntry,
|
|
||||||
type SudoConfigStatus,
|
|
||||||
} from "@/lib/tauriCommands";
|
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
|
||||||
const piiPatterns = [
|
const piiPatterns = [
|
||||||
@ -36,15 +28,8 @@ export default function Security() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sudoPassword, setSudoPassword] = useState("");
|
|
||||||
const [sudoUsername, setSudoUsername] = useState("");
|
|
||||||
const [sudoStatus, setSudoStatus] = useState<SudoConfigStatus | null>(null);
|
|
||||||
const [sudoMessage, setSudoMessage] = useState("");
|
|
||||||
const [sudoTesting, setSudoTesting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAuditLog();
|
loadAuditLog();
|
||||||
loadSudoStatus();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadAuditLog = async () => {
|
const loadAuditLog = async () => {
|
||||||
@ -59,51 +44,6 @@ export default function Security() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSudoStatus = async () => {
|
|
||||||
try {
|
|
||||||
const status = await getSudoConfigStatusCmd();
|
|
||||||
setSudoStatus(status);
|
|
||||||
} catch {
|
|
||||||
// ignore — table may not exist yet
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveSudo = async () => {
|
|
||||||
setSudoMessage("");
|
|
||||||
try {
|
|
||||||
await setSudoPasswordCmd(sudoPassword, sudoUsername || undefined);
|
|
||||||
setSudoPassword("");
|
|
||||||
setSudoMessage("Saved successfully");
|
|
||||||
await loadSudoStatus();
|
|
||||||
} catch (err) {
|
|
||||||
setSudoMessage(`Error: ${String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestSudo = async () => {
|
|
||||||
setSudoTesting(true);
|
|
||||||
setSudoMessage("");
|
|
||||||
try {
|
|
||||||
const ok = await testSudoPasswordCmd();
|
|
||||||
setSudoMessage(ok ? "Password verified" : "Authentication failed");
|
|
||||||
} catch (err) {
|
|
||||||
setSudoMessage(`Authentication failed: ${String(err)}`);
|
|
||||||
} finally {
|
|
||||||
setSudoTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearSudo = async () => {
|
|
||||||
setSudoMessage("");
|
|
||||||
try {
|
|
||||||
await clearSudoPasswordCmd();
|
|
||||||
setSudoMessage("Credentials cleared");
|
|
||||||
await loadSudoStatus();
|
|
||||||
} catch (err) {
|
|
||||||
setSudoMessage(`Error: ${String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRow = (entryId: string) => {
|
const toggleRow = (entryId: string) => {
|
||||||
setExpandedRows((prev) => {
|
setExpandedRows((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@ -163,82 +103,6 @@ export default function Security() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sudo Credentials */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Lock className="w-5 h-5" />
|
|
||||||
Sudo Credentials
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{sudoStatus?.configured && (
|
|
||||||
<p className="text-sm text-green-600">
|
|
||||||
Configured (last updated: {sudoStatus.updated_at})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{sudoStatus && !sudoStatus.configured && (
|
|
||||||
<p className="text-sm text-muted-foreground">Not configured</p>
|
|
||||||
)}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium" htmlFor="sudo-username">
|
|
||||||
Username (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="sudo-username"
|
|
||||||
type="text"
|
|
||||||
value={sudoUsername}
|
|
||||||
onChange={(e) => setSudoUsername(e.target.value)}
|
|
||||||
placeholder="Leave empty for current user"
|
|
||||||
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium" htmlFor="sudo-password">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="sudo-password"
|
|
||||||
type="password"
|
|
||||||
value={sudoPassword}
|
|
||||||
onChange={(e) => setSudoPassword(e.target.value)}
|
|
||||||
placeholder="Enter sudo password"
|
|
||||||
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSaveSudo}
|
|
||||||
disabled={!sudoPassword}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleTestSudo}
|
|
||||||
disabled={sudoTesting || !sudoStatus?.configured}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-md border border-input hover:bg-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{sudoTesting ? "Testing..." : "Test"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleClearSudo}
|
|
||||||
disabled={!sudoStatus?.configured}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-md border border-destructive text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{sudoMessage && (
|
|
||||||
<p className={`text-sm ${sudoMessage.startsWith("Error") || sudoMessage.includes("failed") ? "text-destructive" : "text-green-600"}`}>
|
|
||||||
{sudoMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Audit Log */}
|
{/* Audit Log */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user