use std::path::Path;
use rusqlite::OptionalExtension;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use super::ipc::ErrPayload;
use super::ops::OpResult;
use super::state::DaemonState;
pub async fn op_source_resolve(state: &std::sync::Arc<DaemonState>, args: Value) -> OpResult {
let path = args
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| ErrPayload::new("bad_args", "missing `path`"))?
.to_string();
let client_mtime_ns = args.get("mtime_ns").and_then(Value::as_i64);
let client_inode = args.get("inode").and_then(Value::as_i64);
let p = Path::new(&path);
if !p.is_absolute() {
return Err(ErrPayload::new(
"bad_args",
format!("path must be absolute: {}", path),
));
}
let meta = match std::fs::metadata(p) {
Ok(m) => m,
Err(e) => {
return Err(ErrPayload::new("stat_failed", format!("{}: {}", path, e)));
}
};
let on_disk_mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
use std::os::unix::fs::MetadataExt;
let on_disk_inode = meta.ino() as i64;
let on_disk_size = meta.len() as i64;
let _client_says_match =
client_mtime_ns == Some(on_disk_mtime) && client_inode == Some(on_disk_inode);
let row: Option<(i64, i64, Vec<u8>)> = state.with_catalog(|conn| {
conn.query_row(
"SELECT mtime, inode, hash FROM compiled_files WHERE path = ?",
rusqlite::params![path],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, Vec<u8>>(2)?,
))
},
)
.optional()
})?;
if let Some((cached_mtime, cached_inode, _cached_hash)) = row {
if cached_mtime == on_disk_mtime && cached_inode == on_disk_inode {
tracing::info!(path = %path, "source_resolve hit");
state.with_catalog(|conn| {
conn.execute(
"UPDATE compiled_files SET use_count = use_count + 1, last_used_at = ? WHERE path = ?",
rusqlite::params![now_ns_i64(), path],
).ok();
Ok::<_, rusqlite::Error>(())
})?;
return Ok(json!({
"hit": true,
"stale": false,
"path": path,
"mtime_ns": on_disk_mtime,
"inode": on_disk_inode,
}));
}
tracing::info!(path = %path, "source_resolve stale, refreshing");
} else {
tracing::info!(path = %path, "source_resolve miss, ingesting");
}
let content = match read_file_nofollow(p) {
Ok(b) => b,
Err(e) => {
return Err(ErrPayload::new("read_failed", format!("{}: {}", path, e)));
}
};
let hash = Sha256::digest(&content).to_vec();
let sensitive = is_sensitive(&path, &content);
let bytes_in = content.len() as i64;
let bytes_out = content.len() as i64;
let parent_paths_json = "[]";
state.with_catalog(|conn| {
conn.execute(
r#"INSERT INTO compiled_files
(path, kind, mtime, inode, hash, last_used_at, use_count, bytes_in, bytes_out, sensitive, parent_paths)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
mtime = excluded.mtime,
inode = excluded.inode,
hash = excluded.hash,
last_used_at = excluded.last_used_at,
use_count = compiled_files.use_count + 1,
bytes_in = excluded.bytes_in,
bytes_out = excluded.bytes_out,
sensitive = excluded.sensitive"#,
rusqlite::params![
path,
"source",
on_disk_mtime,
on_disk_inode,
hash,
now_ns_i64(),
bytes_in,
bytes_out,
sensitive as i64,
parent_paths_json,
],
)?;
Ok::<_, rusqlite::Error>(())
})?;
Ok(json!({
"hit": false,
"stale": false,
"path": path,
"mtime_ns": on_disk_mtime,
"inode": on_disk_inode,
"bytes_in": bytes_in,
"bytes_out": bytes_out,
"sensitive": sensitive,
"size_on_disk": on_disk_size,
}))
}
fn is_sensitive(path: &str, content: &[u8]) -> bool {
let lower = path.to_ascii_lowercase();
if lower.contains("token")
|| lower.contains("secret")
|| lower.contains("credential")
|| lower.contains("password")
|| lower.ends_with(".env")
|| lower.contains(".env.")
{
return true;
}
let scan_len = content.len().min(64 * 1024);
let head = &content[..scan_len];
let s = String::from_utf8_lossy(head);
let upper = s.to_ascii_uppercase();
upper.contains("AWS_SECRET")
|| upper.contains("API_KEY=")
|| upper.contains("PASSWORD=")
|| upper.contains("PRIVATE_KEY")
|| upper.contains("SECRET_ACCESS_KEY")
}
fn read_file_nofollow(p: &Path) -> std::io::Result<Vec<u8>> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(p)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
Ok(buf)
}
fn now_ns_i64() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sensitive_path_tokens() {
assert!(is_sensitive("/Users/wizard/.zpwr/local/.tokens.sh", b""));
assert!(is_sensitive("/etc/secrets.env", b""));
assert!(is_sensitive("/home/me/credentials.sh", b""));
assert!(is_sensitive("/home/me/.env.local", b""));
}
#[test]
fn sensitive_content_aws() {
assert!(is_sensitive(
"/some/innocent.sh",
b"export AWS_SECRET_ACCESS_KEY=abc123"
));
assert!(is_sensitive("/x.sh", b"API_KEY=zzz"));
assert!(is_sensitive("/x.sh", b"PRIVATE_KEY=----BEGIN..."));
}
#[test]
fn not_sensitive_innocent_content() {
assert!(!is_sensitive(
"/Users/wizard/.zshrc",
b"alias ll='ls -la'\nbindkey ..."
));
}
#[tokio::test]
async fn source_resolve_sets_sensitive_flag_in_catalog() {
use std::io::Write;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let paths = super::super::paths::CachePaths::with_root(tmp.path().join("zshrs"));
paths.ensure_dirs().unwrap();
let state = super::super::state::DaemonState::new(paths.clone()).unwrap();
let outside = tmp.path().join("tokens.sh");
let mut f = std::fs::File::create(&outside).unwrap();
writeln!(f, "export AWS_SECRET_ACCESS_KEY=abc123").unwrap();
drop(f);
let resp = op_source_resolve(
&state,
serde_json::json!({"path": outside.display().to_string()}),
)
.await
.expect("op_source_resolve");
assert_eq!(resp["sensitive"].as_bool(), Some(true), "response flag");
let flag: i64 = state
.with_catalog(|conn| -> rusqlite::Result<i64> {
conn.query_row(
"SELECT sensitive FROM compiled_files WHERE path = ?",
rusqlite::params![outside.display().to_string()],
|r| r.get(0),
)
})
.expect("catalog query");
assert_eq!(flag, 1, "catalog row should have sensitive=1");
let plain = tmp.path().join("plain.sh");
let mut f2 = std::fs::File::create(&plain).unwrap();
writeln!(f2, "alias ll='ls -la'").unwrap();
drop(f2);
op_source_resolve(
&state,
serde_json::json!({"path": plain.display().to_string()}),
)
.await
.expect("op_source_resolve plain");
let flag: i64 = state
.with_catalog(|conn| -> rusqlite::Result<i64> {
conn.query_row(
"SELECT sensitive FROM compiled_files WHERE path = ?",
rusqlite::params![plain.display().to_string()],
|r| r.get(0),
)
})
.expect("catalog query");
assert_eq!(flag, 0, "innocent content must not be flagged");
}
}