#[cfg(feature = "host")]
use crate::project::{self, Grant};
#[cfg(feature = "host")]
use crate::store;
use crate::store::Store;
#[cfg(feature = "host")]
use age::x25519;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
#[cfg(feature = "host")]
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
#[cfg(feature = "host")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(feature = "host")]
const KEY_TTL_SECS: u64 = 300;
#[cfg(feature = "host")]
const MAX_GRANT_SECS: u64 = 86_400;
#[cfg(feature = "host")]
static LAST_SEEN: AtomicU64 = AtomicU64::new(0);
#[cfg(feature = "host")]
fn reject_namespaces(asked: &[String], known: &[String]) -> Result<()> {
for pattern in asked {
if known.iter().any(|n| n == pattern) {
continue;
}
let under = store::select(known, Some(pattern));
if under.is_empty() {
bail!("no secret named {pattern}");
}
bail!(
"{pattern} is a namespace holding {} secrets, ask for the ones this needs:\n {}",
under.len(),
under.join("\n ")
);
}
Ok(())
}
#[cfg(feature = "host")]
fn token_lookup(
tokens: &HashMap<String, TokenGrant>,
token: &str,
name: &str,
now: u64,
) -> Option<String> {
let grant = tokens.get(token)?;
(now < grant.expires)
.then(|| grant.values.get(name).cloned())
.flatten()
}
#[cfg(feature = "host")]
struct TokenGrant {
values: HashMap<String, String>,
expires: u64,
}
#[derive(Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Op {
#[default]
Get,
List,
Grant,
Plugin,
}
#[derive(Serialize, Deserialize)]
pub struct Request {
#[serde(default)]
pub name: String,
pub agent: String,
#[serde(default)]
pub op: Op,
#[serde(default)]
pub token: String,
#[serde(default)]
pub ttl: u64,
#[serde(default)]
pub names: Vec<String>,
#[serde(default)]
pub action: String,
#[serde(default)]
pub params: Vec<(String, String)>,
#[serde(default)]
pub run: bool,
}
#[derive(Serialize, Deserialize)]
pub struct Response {
pub ok: bool,
pub value: Option<String>,
pub error: Option<String>,
}
#[derive(Serialize)]
struct AuditRecord<'a> {
at: u64,
agent: &'a str,
secret: &'a str,
decision: &'a str,
caller: &'a str,
}
#[cfg(feature = "host")]
struct Broker {
store: Store,
key: Option<x25519::Identity>,
tokens: HashMap<String, TokenGrant>,
key_at: u64,
#[cfg(feature = "host")]
grants: Vec<Grant>,
}
#[cfg(feature = "host")]
impl Broker {
fn new(store: Store) -> Self {
Broker {
store,
key: None,
key_at: 0,
tokens: HashMap::new(),
#[cfg(feature = "host")]
grants: Vec::new(),
}
}
fn cached_key(&mut self) -> Option<&x25519::Identity> {
if store::now().saturating_sub(self.key_at) >= KEY_TTL_SECS {
self.key = None;
}
self.key.as_ref()
}
fn prompt(&mut self, agent: &str, secret: &str) -> Result<()> {
self.ask(&format!("release the password for {secret} to {agent}"))
}
fn refused(
&self,
agent: &str,
secrets: &[&str],
error: anyhow::Error,
caller: &str,
) -> anyhow::Error {
let decision = if error.root_cause().to_string().starts_with("denied") {
"denied"
} else {
"prompt failed"
};
for secret in secrets {
let _ = self.audit_by(agent, secret, decision, caller);
}
error
}
fn ask(&mut self, reason: &str) -> Result<()> {
let key = self.store.unlock_with_se(reason)?;
#[cfg(feature = "host")]
{
self.grants = project::load(&self.store, &key);
}
self.key = Some(key);
self.key_at = store::now();
Ok(())
}
#[cfg(feature = "host")]
fn granted(&self, secret: &str, cwd: Option<&std::path::Path>) -> bool {
let Some(manifest) = cwd.and_then(project::find) else {
return false;
};
let now = store::now();
self.grants.iter().any(|g| g.covers(&manifest, secret, now))
}
#[cfg(feature = "host")]
fn grant_manifest(
&mut self,
request: &Request,
cwd: Option<&std::path::Path>,
caller: &str,
) -> Result<bool> {
let Some(manifest) = cwd.and_then(project::find) else {
return Ok(false);
};
if !manifest.secrets.iter().any(|s| s == &request.name) {
return Ok(false);
}
let declared: Vec<&str> = manifest.secrets.iter().map(String::as_str).collect();
self.ask(&format!(
"give {} access to {} secrets declared by {} for {} minutes",
request.agent,
manifest.secrets.len(),
manifest.dir.display(),
manifest.window_secs / 60
))
.map_err(|e| self.refused(&request.agent, &declared, e, caller))?;
self.grants.push(Grant {
dir: manifest.dir.to_string_lossy().to_string(),
hash: manifest.hash,
secrets: manifest.secrets,
until: store::now() + manifest.window_secs,
});
project::save(&self.store, &self.grants)?;
Ok(true)
}
fn handle(
&mut self,
request: &Request,
caller: &str,
cwd: Option<&std::path::Path>,
) -> Result<String> {
match request.op {
Op::Get => self.handle_get(request, caller, cwd),
Op::List => self.handle_list(request, caller),
Op::Grant => self.handle_grant(request, caller),
Op::Plugin => self.handle_plugin(request, caller),
}
}
#[cfg(feature = "host")]
fn handle_plugin(&mut self, request: &Request, caller: &str) -> Result<String> {
let manifest = crate::plugin::load(&self.store, &request.name)?;
let tool = manifest
.tool(&request.action)
.ok_or_else(|| anyhow!("{} has no action {}", request.name, request.action))?;
let slot_name = format!("plugin:{}.{}", request.name, request.action);
self.ask(&format!("run {slot_name} for {}", request.agent))
.map_err(|e| self.refused(&request.agent, &[&slot_name], e, caller))?;
self.audit_by(&request.agent, &slot_name, "approved", caller)?;
if !request.run {
return Ok("ok".to_string());
}
if manifest.runs_in_caller_dir() {
bail!("{} runs on the caller, not on the host", request.name);
}
manifest.ensure_available()?;
let bound: HashMap<String, String> = request.params.iter().cloned().collect();
let mut command = tool.command(&bound)?;
command.current_dir(self.store.plugins_dir().join(&request.name));
run_plugin_command(command)
}
fn handle_list(&mut self, request: &Request, caller: &str) -> Result<String> {
self.ask(&format!("list your secret names for {}", request.agent))
.map_err(|e| self.refused(&request.agent, &["<list>"], e, caller))?;
let key = self.key.clone().expect("just unlocked");
self.audit(&key, &request.agent, "<list>", "approved", caller)?;
let names = self.store.names(&key)?;
Ok(names.join("\n"))
}
fn handle_grant(&mut self, request: &Request, caller: &str) -> Result<String> {
let asked = if request.ttl == 0 { 300 } else { request.ttl };
let ttl = asked.min(MAX_GRANT_SECS);
if ttl < asked {
eprintln!("capped {asked}s at {MAX_GRANT_SECS}s");
}
if let Some(key) = self.cached_key().cloned() {
reject_namespaces(&request.names, &self.store.names(&key)?)?;
}
let reason = format!(
"grant {} to {} for {ttl}s",
request.names.join(", "),
request.agent
);
let asked_for: Vec<&str> = request.names.iter().map(String::as_str).collect();
self.ask(&reason)
.map_err(|e| self.refused(&request.agent, &asked_for, e, caller))?;
let key = self.key.clone().expect("just unlocked");
let all = self.store.names(&key)?;
reject_namespaces(&request.names, &all)?;
let mut wanted = request.names.clone();
wanted.sort();
wanted.dedup();
let mut values = HashMap::new();
for name in &wanted {
let Some((_, secret)) = self.store.find(name, &key)? else {
continue;
};
values.insert(name.clone(), secret.value.clone());
}
if values.is_empty() {
bail!("{} matched no secrets", request.names.join(", "));
}
let token = store::new_id() + &store::new_id();
let granted: Vec<String> = values.keys().cloned().collect();
self.tokens.insert(
token.clone(),
TokenGrant {
values,
expires: store::now() + ttl,
},
);
for name in &granted {
self.audit(&key, &request.agent, name, "granted", caller)?;
}
Ok(format!("{token}\n{}", granted.join("\n")))
}
fn value_for_token(&mut self, token: &str, name: &str) -> Option<String> {
let now = store::now();
self.tokens.retain(|_, g| now < g.expires);
token_lookup(&self.tokens, token, name, now)
}
fn handle_get(
&mut self,
request: &Request,
caller: &str,
cwd: Option<&std::path::Path>,
) -> Result<String> {
if !request.token.is_empty()
&& let Some(value) = self.value_for_token(&request.token, &request.name)
{
self.audit_by(&request.agent, &request.name, "by token", caller)?;
return Ok(value);
}
let covered = (self.cached_key().is_some() && self.granted(&request.name, cwd))
|| (self.cached_key().is_none() && self.grant_manifest(request, cwd, caller)?);
let outcome = if covered {
"project grant"
} else {
self.prompt(&request.agent, &request.name)
.map_err(|e| self.refused(&request.agent, &[&request.name], e, caller))?;
"released"
};
let key = self.key.clone().expect("just unlocked");
let Some((_, secret)) = self.store.find(&request.name, &key)? else {
self.audit(&key, &request.agent, &request.name, "unknown", caller)?;
bail!("no secret named {}", request.name);
};
self.audit(&key, &request.agent, &request.name, outcome, caller)?;
Ok(secret.value.clone())
}
fn audit_by(&self, agent: &str, secret: &str, decision: &str, caller: &str) -> Result<()> {
let to = self.store.recipient()?;
self.audit_to(&to, agent, secret, decision, caller)
}
fn audit(
&self,
key: &x25519::Identity,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
self.audit_to(&key.to_public(), agent, secret, decision, caller)
}
fn audit_to(
&self,
to: &x25519::Recipient,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
append_audit_to(&self.store, to, agent, secret, decision, caller)
}
}
pub const TAILNET_PORT: u16 = 8787;
#[cfg(feature = "host")]
fn prompt_agent(chain: &str) -> String {
const SHELLS: [&str; 6] = ["sh", "bash", "zsh", "fish", "dash", "ksh"];
for comm in chain.split(" < ") {
let name = comm
.trim()
.rsplit('/')
.next()
.unwrap_or("")
.trim_start_matches('-');
if name.is_empty() || name == "unknown" || name == "passbox" || SHELLS.contains(&name) {
continue;
}
return name.to_string();
}
"passbox".to_string()
}
#[cfg(feature = "host")]
fn reply<S: std::io::Read + Write>(
broker: &std::sync::Mutex<Broker>,
stream: &mut S,
caller: &str,
cwd: Option<&std::path::Path>,
local: bool,
) -> Result<()> {
let mut line = String::new();
let mut reader = BufReader::new(&mut *stream);
if let Err(e) = reader.read_line(&mut line) {
eprintln!("could not read the request: {e}");
return Ok(());
}
eprintln!("read {} bytes of request", line.len());
let response = match serde_json::from_str::<Request>(&line) {
Ok(mut request) => {
if request.agent.is_empty() {
request.agent = if local {
prompt_agent(caller)
} else {
"passbox".to_string()
};
}
match broker.lock().expect("broker").handle(&request, caller, cwd) {
Ok(value) => Response {
ok: true,
value: Some(value),
error: None,
},
Err(e) => Response {
ok: false,
value: None,
error: Some(format!("{e:#}")),
},
}
}
Err(e) => Response {
ok: false,
value: None,
error: Some(format!("bad request: {e}")),
},
};
writeln!(stream, "{}", serde_json::to_string(&response)?)?;
Ok(())
}
#[cfg(feature = "host")]
fn serve_tailnet(
listener: std::net::TcpListener,
broker: std::sync::Arc<std::sync::Mutex<Broker>>,
) {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
LAST_SEEN.store(store::now(), Ordering::Relaxed);
let caller = stream
.peer_addr()
.ok()
.and_then(|a| crate::tailnet::whois(a.ip()))
.unwrap_or_else(|| "an unidentified tailnet peer".to_string());
eprintln!("tailnet request from {caller}");
if let Err(e) = reply(&broker, &mut stream, &caller, None, false) {
eprintln!("tailnet request failed: {e:#}");
}
LAST_SEEN.store(store::now(), Ordering::Relaxed);
}
}
#[cfg(all(feature = "host", target_os = "macos"))]
fn harden() {
unsafe {
libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0);
let none = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
libc::setrlimit(libc::RLIMIT_CORE, &none);
}
}
#[cfg(feature = "host")]
pub fn serve(store: Store) -> Result<()> {
#[cfg(target_os = "macos")]
harden();
let socket = store.socket_path();
if socket.exists() {
std::fs::remove_file(&socket)?;
}
store::private_dir(&store.dir)?;
let listener = UnixListener::bind(&socket)?;
std::fs::set_permissions(&socket, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
LAST_SEEN.store(store::now(), Ordering::Relaxed);
watchdog(socket.clone());
let broker = std::sync::Arc::new(std::sync::Mutex::new(Broker::new(store)));
if crate::tailnet::address().is_some() {
match std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, TAILNET_PORT)) {
Ok(tcp) => {
eprintln!(
"listening on 127.0.0.1:{TAILNET_PORT}; expose it with\n \
tailscale serve --bg --tcp {TAILNET_PORT} tcp://127.0.0.1:{TAILNET_PORT}"
);
let shared = std::sync::Arc::clone(&broker);
std::thread::spawn(move || serve_tailnet(tcp, shared));
}
Err(e) => eprintln!("not listening for tailnet peers: {e}"),
}
}
eprintln!("broker ready, waiting for requests. Ctrl+C to stop");
for stream in listener.incoming() {
let mut stream = stream?;
LAST_SEEN.store(store::now(), Ordering::Relaxed);
let pid = peer_pid(&stream);
let caller = pid.map_or_else(|| "unknown".to_string(), ancestry);
let cwd = pid.and_then(caller_cwd);
let _ = reply(&broker, &mut stream, &caller, cwd.as_deref(), true);
LAST_SEEN.store(store::now(), Ordering::Relaxed);
}
Ok(())
}
#[cfg(feature = "host")]
fn watchdog(socket: std::path::PathBuf) {
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_secs(30));
let idle = store::now().saturating_sub(LAST_SEEN.load(Ordering::Relaxed));
if idle >= KEY_TTL_SECS || !socket.exists() {
let _ = std::fs::remove_file(&socket);
std::process::exit(0);
}
}
});
}
#[cfg(feature = "host")]
fn peer_pid(stream: &UnixStream) -> Option<libc::pid_t> {
use std::os::unix::io::AsRawFd;
const SOL_LOCAL: libc::c_int = 0;
const LOCAL_PEERPID: libc::c_int = 0x002;
let mut pid: libc::pid_t = 0;
let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
let got = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
SOL_LOCAL,
LOCAL_PEERPID,
(&raw mut pid).cast(),
&raw mut len,
)
};
(got == 0 && pid > 0).then_some(pid)
}
#[cfg(feature = "host")]
fn caller_cwd(pid: libc::pid_t) -> Option<std::path::PathBuf> {
let out = std::process::Command::new("lsof")
.args(["-a", "-d", "cwd", "-Fn", "-p", &pid.to_string()])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.strip_prefix('n'))
.map(std::path::PathBuf::from)
}
pub(crate) fn append_audit(
store: &Store,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
append_audit_to(store, &store.recipient()?, agent, secret, decision, caller)
}
fn append_audit_to(
store: &Store,
to: &age::x25519::Recipient,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
let record = AuditRecord {
at: crate::store::now(),
agent,
secret,
decision,
caller,
};
let line = crate::crypto::encrypt_line(&serde_json::to_vec(&record)?, to)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(store.audit_path())?;
writeln!(file, "{line}")?;
Ok(())
}
pub(crate) fn ancestry(pid: libc::pid_t) -> String {
let mut chain = Vec::new();
let mut current = pid;
for _ in 0..6 {
let out = std::process::Command::new("ps")
.args(["-o", "ppid=,comm=", "-p", ¤t.to_string()])
.output();
let Ok(out) = out else { break };
let text = String::from_utf8_lossy(&out.stdout);
let Some((ppid, comm)) = text.trim().split_once(char::is_whitespace) else {
break;
};
chain.push(comm.trim().to_string());
match ppid.trim().parse::<libc::pid_t>() {
Ok(next) if next > 1 => current = next,
_ => break,
}
}
if chain.is_empty() {
return "unknown".to_string();
}
chain.join(" < ")
}
pub fn request(store: &Store, name: &str, agent: &str) -> Result<String> {
request_token(store, name, agent, crate::held_token().unwrap_or_default())
}
pub fn request_token(store: &Store, name: &str, agent: &str, token: &str) -> Result<String> {
ask_broker(
store,
Request {
name: name.to_string(),
agent: agent.to_string(),
op: Op::Get,
token: token.to_string(),
ttl: 0,
names: Vec::new(),
action: String::new(),
params: Vec::new(),
run: false,
},
)
}
pub fn list(store: &Store, agent: &str) -> Result<Vec<String>> {
let names = ask_broker(
store,
Request {
name: String::new(),
agent: agent.to_string(),
op: Op::List,
token: String::new(),
ttl: 0,
names: Vec::new(),
action: String::new(),
params: Vec::new(),
run: false,
},
)?;
Ok(names.lines().map(str::to_string).collect())
}
pub fn grant(store: &Store, names: &[String], agent: &str, ttl: u64) -> Result<String> {
ask_broker(
store,
Request {
name: String::new(),
agent: agent.to_string(),
op: Op::Grant,
token: String::new(),
ttl,
names: names.to_vec(),
action: String::new(),
params: Vec::new(),
run: false,
},
)
}
#[cfg(feature = "host")]
pub fn plugin_approve(store: &Store, name: &str, action: &str, agent: &str) -> Result<()> {
ask_broker(
store,
Request {
name: name.to_string(),
agent: agent.to_string(),
op: Op::Plugin,
token: String::new(),
ttl: 0,
names: Vec::new(),
action: action.to_string(),
params: Vec::new(),
run: false,
},
)
.map(|_| ())
}
pub fn plugin_run_remote(
store: &Store,
name: &str,
action: &str,
params: &[(String, String)],
agent: &str,
) -> Result<String> {
ask_broker(
store,
Request {
name: name.to_string(),
agent: agent.to_string(),
op: Op::Plugin,
token: String::new(),
ttl: 0,
names: Vec::new(),
action: action.to_string(),
params: params.to_vec(),
run: true,
},
)
}
fn ask_broker(store: &Store, request: Request) -> Result<String> {
if let Some(host) = store.host() {
let target = if host.contains(':') {
host.clone()
} else {
format!("{host}:{TAILNET_PORT}")
};
let mut stream = std::net::TcpStream::connect(&target)
.with_context(|| format!("no passbox host at {target}"))?;
writeln!(stream, "{}", serde_json::to_string(&request)?)?;
let mut line = String::new();
BufReader::new(stream.try_clone()?).read_line(&mut line)?;
return unwrap_response(&line);
}
let socket = store.socket_path();
let mut stream = match UnixStream::connect(&socket) {
Ok(s) => s,
Err(_) => {
#[cfg(feature = "host")]
{
spawn(store)?;
UnixStream::connect(&socket).context("the broker did not come up")?
}
#[cfg(not(feature = "host"))]
bail!(
"no broker at {}, and a client cannot start one",
socket.display()
)
}
};
writeln!(stream, "{}", serde_json::to_string(&request)?)?;
let mut line = String::new();
BufReader::new(&stream).read_line(&mut line)?;
unwrap_response(&line)
}
fn unwrap_response(line: &str) -> Result<String> {
let response: Response = serde_json::from_str(line).context("broker sent nonsense")?;
if !response.ok {
bail!(
"{}",
response.error.unwrap_or_else(|| "refused".to_string())
);
}
response
.value
.ok_or_else(|| anyhow!("broker sent no value"))
}
#[cfg(feature = "host")]
fn spawn(store: &Store) -> Result<()> {
std::process::Command::new(std::env::current_exe()?)
.arg("broker")
.env("PASSBOX_DIR", &store.dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("could not start the broker")?;
for _ in 0..50 {
if store.socket_path().exists() {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(40));
}
bail!("the broker never opened its socket")
}
#[cfg(feature = "host")]
fn run_plugin_command(mut command: std::process::Command) -> Result<String> {
let out = command
.output()
.context("could not run the plugin command")?;
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
let err = String::from_utf8_lossy(&out.stderr);
if !err.trim().is_empty() {
text.push_str("\nstderr:\n");
text.push_str(&err);
}
if !out.status.success() {
text.push_str(&format!("\nexit: {}", out.status.code().unwrap_or(1)));
}
Ok(text)
}
#[cfg(all(test, feature = "host"))]
mod tests {
use super::*;
#[test]
fn the_prompt_name_comes_from_the_first_real_ancestor() {
assert_eq!(prompt_agent("passbox < zsh < claude < login"), "claude");
assert_eq!(prompt_agent("passbox < -zsh < codex"), "codex");
assert_eq!(prompt_agent("passbox < /bin/bash < node"), "node");
assert_eq!(prompt_agent("passbox < zsh"), "passbox");
assert_eq!(prompt_agent("unknown"), "passbox");
}
#[test]
fn run_plugin_command_returns_output_then_stderr_and_exit() {
let mut ok = std::process::Command::new("printf");
ok.args(["%s", "hello"]);
assert_eq!(run_plugin_command(ok).unwrap(), "hello");
let mut bad = std::process::Command::new("sh");
bad.args(["-c", "printf out; printf oops >&2; exit 3"]);
let text = run_plugin_command(bad).unwrap();
assert!(text.contains("out"), "{text}");
assert!(text.contains("stderr:") && text.contains("oops"), "{text}");
assert!(text.contains("exit: 3"), "{text}");
}
fn grant_of(pairs: &[(&str, &str)], expires: u64) -> TokenGrant {
TokenGrant {
values: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
expires,
}
}
#[test]
fn a_namespace_is_refused_and_its_names_listed() {
let known: Vec<String> = ["acme/pk", "acme/addr", "personal/github"]
.iter()
.map(|s| s.to_string())
.collect();
let err = reject_namespaces(&["acme".to_string()], &known)
.unwrap_err()
.to_string();
assert!(err.contains("namespace holding 2"), "{err}");
assert!(
err.contains("acme/pk") && err.contains("acme/addr"),
"{err}"
);
}
#[test]
fn exact_names_are_accepted() {
let known: Vec<String> = ["acme/pk", "personal/github"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(reject_namespaces(&["acme/pk".to_string()], &known).is_ok());
assert!(
reject_namespaces(
&["acme/pk".to_string(), "personal/github".to_string()],
&known
)
.is_ok()
);
}
#[test]
fn a_name_that_is_not_there_is_refused() {
let known = vec!["token".to_string(), "other".to_string()];
let err = reject_namespaces(&["nope".to_string()], &known)
.unwrap_err()
.to_string();
assert!(err.contains("no secret named nope"), "{err}");
assert!(reject_namespaces(&["token".to_string()], &known).is_ok());
}
#[test]
fn a_token_opens_only_what_it_was_granted() {
let mut tokens = HashMap::new();
tokens.insert("tok".to_string(), grant_of(&[("acme/pk", "value")], 2_000));
assert_eq!(
token_lookup(&tokens, "tok", "acme/pk", 1_000).as_deref(),
Some("value")
);
assert!(token_lookup(&tokens, "tok", "personal/github", 1_000).is_none());
}
#[test]
fn an_unknown_token_opens_nothing() {
let mut tokens = HashMap::new();
tokens.insert("tok".to_string(), grant_of(&[("acme/pk", "v")], 2_000));
assert!(token_lookup(&tokens, "guessed", "acme/pk", 1_000).is_none());
}
#[test]
fn a_lapsed_token_opens_nothing() {
let mut tokens = HashMap::new();
tokens.insert("tok".to_string(), grant_of(&[("acme/pk", "v")], 2_000));
assert!(token_lookup(&tokens, "tok", "acme/pk", 2_000).is_none());
assert!(token_lookup(&tokens, "tok", "acme/pk", 9_999).is_none());
}
#[test]
fn a_grant_is_capped_at_a_day() {
let asked: u64 = 7 * 86_400;
assert_eq!(asked.min(MAX_GRANT_SECS), MAX_GRANT_SECS);
assert_eq!(600u64.min(MAX_GRANT_SECS), 600);
}
}