use assert_cmd::Command;
use tempfile::TempDir;
fn cmd_base(tmp: &TempDir) -> Command {
let mut c = Command::cargo_bin("sqlite-graphrag").unwrap();
c.env("SQLITE_GRAPHRAG_DB_PATH", tmp.path().join("test.sqlite"));
c.env("SQLITE_GRAPHRAG_CACHE_DIR", tmp.path().join("cache"));
c.env("SQLITE_GRAPHRAG_LOG_LEVEL", "error");
c.arg("--skip-memory-guard");
c
}
fn init_db(tmp: &TempDir) {
cmd_base(tmp).arg("init").assert().success();
}
#[test]
fn test_path_traversal_rejeitado_em_db_path() {
let tmp = TempDir::new().unwrap();
let traversal = format!("{}/../../../etc/passwd", tmp.path().display());
let mut c = Command::cargo_bin("sqlite-graphrag").unwrap();
c.env("SQLITE_GRAPHRAG_DB_PATH", &traversal);
c.env("SQLITE_GRAPHRAG_CACHE_DIR", tmp.path().join("cache"));
c.env("SQLITE_GRAPHRAG_LOG_LEVEL", "error");
c.arg("--skip-memory-guard");
c.args(["init"]);
c.assert().failure().code(predicates::ord::lt(128i32));
}
#[test]
fn test_path_traversal_duplo_ponto_rejeitado() {
let tmp = TempDir::new().unwrap();
let mut c = Command::cargo_bin("sqlite-graphrag").unwrap();
c.env("SQLITE_GRAPHRAG_DB_PATH", "../../../tmp/malicioso.sqlite");
c.env("SQLITE_GRAPHRAG_CACHE_DIR", tmp.path().join("cache"));
c.env("SQLITE_GRAPHRAG_LOG_LEVEL", "error");
c.arg("--skip-memory-guard");
c.args(["init"]);
c.assert().failure();
}
#[test]
fn test_path_traversal_validate_path_direto() {
use sqlite_graphrag::paths::AppPaths;
let resultado = AppPaths::resolve(Some("../../../etc/passwd"));
assert!(
resultado.is_err(),
"resolve com .. deve retornar Err, obtido: {resultado:?}"
);
let msg = resultado.unwrap_err().to_string();
assert!(
msg.contains("path traversal") || msg.contains("validation"),
"mensagem de erro deve mencionar traversal ou validation: {msg}"
);
}
#[test]
fn test_path_normal_aceito_por_validate_path() {
let tmp = TempDir::new().unwrap();
let caminho_valido = tmp.path().join("valido.sqlite");
let resultado =
sqlite_graphrag::paths::AppPaths::resolve(Some(caminho_valido.to_str().unwrap()));
assert!(
resultado.is_ok(),
"caminho sem .. deve ser aceito, obtido: {resultado:?}"
);
}
#[test]
#[cfg(unix)]
fn test_symlink_para_etc_rejeitado() {
let tmp = TempDir::new().unwrap();
let link_path = tmp.path().join("link_malicioso.sqlite");
let _ = std::os::unix::fs::symlink("/etc/hosts", &link_path);
let mut c = Command::cargo_bin("sqlite-graphrag").unwrap();
c.env("SQLITE_GRAPHRAG_DB_PATH", &link_path);
c.env("SQLITE_GRAPHRAG_CACHE_DIR", tmp.path().join("cache"));
c.env("SQLITE_GRAPHRAG_LOG_LEVEL", "error");
c.arg("--skip-memory-guard");
c.args(["init"]);
c.assert().failure();
}
#[test]
#[cfg(unix)]
fn test_chmod_600_apos_init_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let db_path = tmp.path().join("test.sqlite");
assert!(db_path.exists(), "banco deve existir após init");
let meta = std::fs::metadata(&db_path).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"arquivo SQLite deve ter permissão 0o600 (owner rw apenas), obtido: {mode:03o}"
);
}
#[test]
#[cfg(unix)]
fn test_chmod_600_nao_permite_leitura_por_grupo() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let db_path = tmp.path().join("test.sqlite");
let meta = std::fs::metadata(&db_path).unwrap();
let mode = meta.permissions().mode() & 0o777;
let group_bits = (mode >> 3) & 0o7;
let other_bits = mode & 0o7;
assert_eq!(
group_bits, 0,
"grupo não deve ter nenhuma permissão no arquivo SQLite"
);
assert_eq!(
other_bits, 0,
"outros não devem ter nenhuma permissão no arquivo SQLite"
);
}
#[test]
#[cfg(unix)]
fn test_sqlite_wal_shm_chmod_600() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
init_db(&tmp);
cmd_base(&tmp)
.args([
"remember",
"--name",
"mem-wal-test",
"--type",
"user",
"--description",
"desc",
"--namespace",
"global",
"--body",
"conteudo para forçar escrita no WAL",
])
.assert()
.success();
let db_path = tmp.path().join("test.sqlite");
for ext in ["sqlite-wal", "sqlite-shm"] {
let arquivo = db_path.with_extension(ext);
if arquivo.exists() {
let meta = std::fs::metadata(&arquivo).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"arquivo {ext} deve ter permissão 0o600, obtido: {mode:03o}"
);
}
}
}
#[test]
fn test_blake3_hash_idempotente() {
let corpo = "conteudo de teste para hash determinístico";
let hash1 = blake3::hash(corpo.as_bytes()).to_hex().to_string();
let hash2 = blake3::hash(corpo.as_bytes()).to_hex().to_string();
assert_eq!(
hash1, hash2,
"BLAKE3 deve ser determinístico para o mesmo input"
);
}
#[test]
fn test_blake3_hash_diferente_para_corpos_distintos() {
let corpo1 = "primeiro conteudo";
let corpo2 = "segundo conteudo diferente";
let hash1 = blake3::hash(corpo1.as_bytes()).to_hex().to_string();
let hash2 = blake3::hash(corpo2.as_bytes()).to_hex().to_string();
assert_ne!(
hash1, hash2,
"BLAKE3 deve produzir hashes distintos para inputs distintos"
);
}
#[test]
fn test_blake3_hash_length_correto() {
let hash = blake3::hash(b"qualquer corpo").to_hex().to_string();
assert_eq!(
hash.len(),
64,
"BLAKE3 hex digest deve ter 64 caracteres (256 bits)"
);
}
#[test]
fn test_blake3_deduplicacao_via_cli() {
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let corpo = "conteudo exatamente idêntico para testar deduplicação por hash";
cmd_base(&tmp)
.args([
"remember",
"--name",
"mem-hash-1",
"--type",
"user",
"--description",
"desc",
"--namespace",
"global",
"--body",
corpo,
])
.assert()
.success();
let output = cmd_base(&tmp)
.args([
"remember",
"--name",
"mem-hash-2",
"--type",
"user",
"--description",
"desc",
"--namespace",
"global",
"--body",
corpo,
])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8_lossy(&output);
assert!(
stdout.contains("identical body already exists") || stdout.contains("warnings"),
"saída deve conter aviso de body duplicado: {stdout}"
);
}
#[test]
fn test_cli_slot_lock_files_tamanho_pequeno() {
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let cache_dir = tmp.path().join("cache");
if cache_dir.exists() {
for i in 1..=4 {
let lock_file = cache_dir.join(format!("cli-slot-{i}.lock"));
if lock_file.exists() {
let meta = std::fs::metadata(&lock_file).unwrap();
assert!(
meta.len() < 4096,
"lock file cli-slot-{i}.lock não deve exceder 4096 bytes, tamanho: {}",
meta.len()
);
}
}
}
}
#[test]
fn test_cache_dir_sem_traversal_no_override() {
use sqlite_graphrag::paths::AppPaths;
let resultado = AppPaths::resolve(Some("/tmp/teste-seguro/banco.sqlite"));
assert!(
resultado.is_ok() || resultado.is_err(),
"caminho absoluto sem .. deve ser processado"
);
}
#[test]
fn test_erro_nao_vaza_caminho_absoluto_no_stderr() {
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let output = cmd_base(&tmp)
.args(["read", "--name", "memoria-inexistente-segura"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("/etc/"),
"stdout não deve conter caminhos de /etc/: {stdout}"
);
assert!(
!stderr.contains("/etc/"),
"stderr não deve referenciar /etc/: {stderr}"
);
}
#[test]
fn test_sql_injection_em_nome_rejeitado() {
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let nome_injetado = "'; DROP TABLE memories; --";
cmd_base(&tmp)
.args([
"remember",
"--name",
nome_injetado,
"--type",
"user",
"--description",
"desc",
"--namespace",
"global",
"--body",
"corpo inofensivo",
])
.assert()
.failure()
.code(1);
cmd_base(&tmp).arg("health").assert().success();
}
#[test]
fn test_sql_injection_em_namespace_rejeitado() {
let tmp = TempDir::new().unwrap();
init_db(&tmp);
let ns_injetado = "global'; DROP TABLE memories; --";
cmd_base(&tmp)
.args([
"remember",
"--name",
"mem-ns-inject",
"--type",
"user",
"--description",
"desc",
"--namespace",
ns_injetado,
"--body",
"corpo",
])
.assert()
.failure()
.code(1);
cmd_base(&tmp).arg("health").assert().success();
}