use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::fs::{Access, DirAccess};
use super::{program_basename, DirectoryAccess, Profile};
pub const NPM_PROGRAMS: &[&str] = &["npm", "npx", "yarn", "pnpm"];
pub fn is_npm(program: &OsStr) -> bool {
NPM_PROGRAMS
.iter()
.any(|name| program_basename(program) == OsStr::new(name))
}
pub struct Npm;
impl Profile for Npm {
fn redaction(&self) -> bool {
true
}
fn directory_access(&self) -> DirectoryAccess {
let home = std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.and_then(|h| std::fs::canonicalize(&h).ok());
let cwd = std::env::current_dir().ok();
DirectoryAccess::AskAllowList(preapproved_paths(home.as_deref(), cwd.as_deref()))
}
}
pub fn preapproved_paths(home: Option<&Path>, cwd: Option<&Path>) -> Vec<String> {
let mut paths = Vec::new();
if let Some(home) = home {
paths.push(home.join(".npm/_logs"));
paths.push(home.join(".npm/_cacache"));
paths.push(home.join(".npm/_update-notifier-last-checked"));
paths.push(home.join(".gitconfig"));
}
if let Some(cwd) = cwd {
paths.push(cwd.join("node_modules"));
paths.push(cwd.join("package.json"));
paths.push(cwd.join("package-lock.json"));
}
paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
type Asker = Box<dyn Fn(&Path, Access) -> bool + Send + Sync>;
pub struct DirGate {
state: Mutex<GateState>,
ask: Asker,
debug: bool,
}
#[derive(Default)]
struct GateState {
allowed: Vec<PathBuf>,
denied: Vec<PathBuf>,
}
impl DirGate {
pub fn new(program: String, preapproved: Vec<PathBuf>, debug: bool) -> Self {
let mut gate = Self::with_asker(
preapproved,
Box::new(move |path, access| prompt_tty(&program, path, access)),
);
gate.debug = debug;
gate
}
fn with_asker(preapproved: Vec<PathBuf>, ask: Asker) -> Self {
Self {
state: Mutex::new(GateState {
allowed: preapproved,
denied: Vec::new(),
}),
ask,
debug: false,
}
}
}
impl DirAccess for DirGate {
fn allow(&self, path: &Path, access: Access) -> bool {
let mut st = self.state.lock().unwrap();
if let Some(prefix) = st.allowed.iter().find(|p| path.starts_with(p)) {
if self.debug {
eprintln!(
"airgap[debug]: pre-allowed {} {} (allowlist: {})",
verb(access),
path.display(),
prefix.display()
);
}
return true;
}
if st.denied.iter().any(|p| path.starts_with(p)) {
return false;
}
let decision = (self.ask)(path, access);
if decision {
st.allowed.push(path.to_path_buf());
} else {
st.denied.push(path.to_path_buf());
}
decision
}
}
fn prompt_tty(program: &str, path: &Path, access: Access) -> bool {
prompt_tty_inner(program, path, access).unwrap_or(false)
}
fn verb(access: Access) -> &'static str {
match access {
Access::Read => "read",
Access::Write => "write",
Access::Create => "create",
}
}
fn prompt_tty_inner(program: &str, path: &Path, access: Access) -> std::io::Result<bool> {
let tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
let mut w = &tty;
write!(
w,
"\nairgap: {program} wants to {} the file {} — allow? [y/N] ",
verb(access),
path.display()
)?;
w.flush()?;
let mut line = String::new();
BufReader::new(&tty).read_line(&mut line)?;
Ok(matches!(line.trim_start().chars().next(), Some('y' | 'Y')))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[test]
fn detects_npm_programs_by_basename() {
assert!(is_npm(OsStr::new("npm")));
assert!(is_npm(OsStr::new("npx")));
assert!(is_npm(OsStr::new("/usr/bin/yarn")));
assert!(is_npm(OsStr::new("pnpm")));
assert!(!is_npm(OsStr::new("node")));
assert!(!is_npm(OsStr::new("claude")));
assert!(!is_npm(OsStr::new("npm-check")));
}
#[test]
fn preapproves_known_paths() {
assert_eq!(
preapproved_paths(Some(Path::new("/home/u")), Some(Path::new("/work/proj"))),
vec![
"/home/u/.npm/_logs".to_string(),
"/home/u/.npm/_cacache".to_string(),
"/home/u/.npm/_update-notifier-last-checked".to_string(),
"/home/u/.gitconfig".to_string(),
"/work/proj/node_modules".to_string(),
"/work/proj/package.json".to_string(),
"/work/proj/package-lock.json".to_string(),
]
);
assert_eq!(
preapproved_paths(None, Some(Path::new("/work/proj"))),
vec![
"/work/proj/node_modules".to_string(),
"/work/proj/package.json".to_string(),
"/work/proj/package-lock.json".to_string(),
]
);
assert!(preapproved_paths(None, None).is_empty());
}
fn counting_gate(
decide: impl Fn(&Path) -> bool + Send + Sync + 'static,
) -> (DirGate, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let gate = DirGate::with_asker(
Vec::new(),
Box::new(move |path, _access| {
c.fetch_add(1, Ordering::SeqCst);
decide(path)
}),
);
(gate, calls)
}
#[test]
fn preapproved_paths_pass_without_prompting() {
let (gate, calls) = {
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let gate = DirGate::with_asker(
vec![
PathBuf::from("/work/proj/node_modules"), PathBuf::from("/home/u/.gitconfig"), ],
Box::new(move |_path, _access| {
c.fetch_add(1, Ordering::SeqCst);
false
}),
);
(gate, calls)
};
assert!(gate.allow(Path::new("/work/proj/node_modules/dep/index.js"), Access::Read));
assert!(gate.allow(Path::new("/home/u/.gitconfig"), Access::Read));
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(!gate.allow(Path::new("/home/u/.ssh/id_rsa"), Access::Read));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn an_approved_file_is_remembered() {
let (gate, calls) = counting_gate(|_| true);
assert!(gate.allow(Path::new("/home/u/.npmrc"), Access::Read));
assert!(gate.allow(Path::new("/home/u/.npmrc"), Access::Write));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn a_denied_file_is_remembered() {
let (gate, calls) = counting_gate(|_| false);
assert!(!gate.allow(Path::new("/home/u/.ssh/id_rsa"), Access::Read));
assert!(!gate.allow(Path::new("/home/u/.ssh/id_rsa"), Access::Read));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn each_file_is_decided_independently() {
let (gate, calls) = counting_gate(|_| true);
assert!(gate.allow(Path::new("/home/u/proj/a.txt"), Access::Read));
assert!(gate.allow(Path::new("/home/u/proj/b.txt"), Access::Read));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn a_preapproved_directory_covers_files_under_it() {
let (gate, calls) = {
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let gate = DirGate::with_asker(
vec![PathBuf::from("/home/u/proj")],
Box::new(move |_path, _access| {
c.fetch_add(1, Ordering::SeqCst);
false
}),
);
(gate, calls)
};
assert!(gate.allow(Path::new("/home/u/proj/a/b/c.js"), Access::Read));
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(!gate.allow(Path::new("/home/u/proj2/x"), Access::Read));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}