use crate::store::Store;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Deserialize)]
pub struct Manifest {
pub name: String,
pub description: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub requires: Option<String>,
#[serde(default)]
pub install: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub stdin: Option<String>,
#[serde(default, rename = "tool")]
pub tools: Vec<Tool>,
}
#[derive(Deserialize)]
pub struct Tool {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub params: Vec<String>,
pub run: Vec<String>,
#[serde(default)]
pub limits: BTreeMap<String, Limit>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Limit {
#[serde(default)]
pub max: Option<u64>,
#[serde(default)]
pub max_len: Option<usize>,
#[serde(default)]
pub digits: bool,
#[serde(default)]
pub date: bool,
}
impl Limit {
fn check(&self, tool: &str, name: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Ok(());
}
let digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if let Some(max) = self.max
&& !(digits(value) && value.parse::<u64>().is_ok_and(|n| n <= max))
{
bail!("{tool}: {name} must be a whole number of at most {max}");
}
if let Some(max) = self.max_len
&& value.chars().count() > max
{
bail!("{tool}: {name} must be at most {max} characters");
}
if self.digits && !digits(value) {
bail!("{tool}: {name} must be digits only");
}
let date = |t: &str| {
let parts: Vec<&str> = t.split('-').collect();
matches!(parts.as_slice(), [y, m, d]
if y.len() == 4 && digits(y)
&& m.len() == 2 && digits(m) && (1..=12).contains(&m.parse::<u8>().unwrap_or(0))
&& d.len() == 2 && digits(d) && (1..=31).contains(&d.parse::<u8>().unwrap_or(0)))
};
if self.date && !date(value) {
bail!("{tool}: {name} must be a date such as 2026-01-31");
}
Ok(())
}
}
fn on_path(command: &str) -> bool {
if command.contains('/') {
return std::path::Path::new(command).exists();
}
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(command).exists()))
.unwrap_or(false)
}
impl Manifest {
pub fn ensure_available(&self) -> Result<()> {
if let Some(command) = &self.requires
&& !on_path(command)
{
let hint = self
.install
.as_deref()
.map(|i| format!("\ninstall it with: {i}"))
.unwrap_or_default();
bail!(
"the {} plugin needs `{command}`, which is not on PATH{hint}",
self.name
);
}
Ok(())
}
pub fn runs_in_caller_dir(&self) -> bool {
self.cwd.as_deref() == Some("caller")
}
pub fn version(&self) -> &str {
self.version.as_deref().unwrap_or("0")
}
pub fn tool(&self, action: &str) -> Option<&Tool> {
self.tools.iter().find(|t| t.name == action)
}
pub fn approval(&self, action: &str, agent: &str, args: &HashMap<String, String>) -> String {
let mut text = format!("run plugin:{}.{action}", self.name);
let shown = self.tool(action).map(|t| t.shown(args)).unwrap_or_default();
if !shown.is_empty() {
text.push(' ');
text.push_str(&shown);
}
text.push_str(&format!(" for {agent}"));
if let Some(secret) = &self.stdin {
text.push_str(&format!(", releasing {secret}"));
}
text
}
}
const SHOWN_VALUE: usize = 64;
const SHOWN_ARGS: usize = 256;
fn cut(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
format!("{}...", text.chars().take(max).collect::<String>())
}
impl Tool {
fn bound<'a>(
&'a self,
args: &'a HashMap<String, String>,
) -> impl Iterator<Item = (&'a str, &'a str)> {
self.params.iter().map(|p| {
let name = p.trim_end_matches('?');
(name, args.get(name).map_or("", String::as_str))
})
}
pub fn shown(&self, args: &HashMap<String, String>) -> String {
let parts: Vec<String> = self
.bound(args)
.filter(|(_, value)| !value.is_empty())
.map(|(name, value)| {
let clean: String = value
.chars()
.filter(|c| !c.is_control())
.map(|c| if c.is_ascii() { c } else { '?' })
.collect();
let clean = cut(&clean, SHOWN_VALUE)
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!("{name}=\"{clean}\"")
})
.collect();
cut(&parts.join(" "), SHOWN_ARGS)
}
pub fn call(&self, args: &HashMap<String, String>) -> String {
let mut pairs: Vec<_> = self.bound(args).collect();
pairs.sort();
crate::project::hash(&serde_json::to_vec(&pairs).expect("strings serialise"))
}
fn required(&self) -> impl Iterator<Item = &str> {
self.params
.iter()
.filter(|p| !p.ends_with('?'))
.map(String::as_str)
}
fn known(&self, key: &str) -> bool {
self.params.iter().any(|p| p.trim_end_matches('?') == key)
}
pub fn command(&self, args: &HashMap<String, String>) -> Result<Command> {
self.command_with(args, &[])
}
pub fn command_with(
&self,
args: &HashMap<String, String>,
scripts: &[(&str, &str)],
) -> Result<Command> {
for name in self.required() {
if !args.contains_key(name) {
bail!("{} needs --{name}", self.name);
}
}
for key in args.keys() {
if !self.known(key) {
bail!("{} has no parameter {key}", self.name);
}
}
for (name, limit) in &self.limits {
if !self.known(name) {
bail!(
"{} has a limit on {name}, which is not a parameter",
self.name
);
}
limit.check(&self.name, name, args.get(name).map_or("", String::as_str))?;
}
let (program, rest) = self.run.split_first().context("tool has no command")?;
let mut command = Command::new(program);
crate::without_credentials(&mut command);
for part in rest {
match scripts.iter().find(|(file, _)| file == part) {
Some((_, code)) => command.arg(code),
None => command.arg(self.bind(part, args)?),
};
}
Ok(command)
}
fn bind(&self, part: &str, args: &HashMap<String, String>) -> Result<String> {
if let Some(name) = part.strip_prefix('{').and_then(|p| p.strip_suffix('}'))
&& self.known(name)
{
return Ok(args.get(name).cloned().unwrap_or_default());
}
for param in &self.params {
let bare = param.trim_end_matches('?');
if part.contains(&format!("{{{bare}}}")) {
bail!(
"{}: {bare} must be a whole argument, not embedded in {part:?}",
self.name
);
}
}
Ok(part.to_string())
}
}
pub fn scrub(text: &str, secret: &str) -> String {
let mut out = text.to_string();
for piece in [secret, secret.lines().next().unwrap_or_default()] {
if piece.len() >= 4 {
out = out.replace(piece, "[redacted by passbox]");
}
}
out
}
pub fn render(output: &std::process::Output) -> String {
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
let err = String::from_utf8_lossy(&output.stderr);
if !err.trim().is_empty() {
text.push_str("\nstderr:\n");
text.push_str(&err);
}
if !output.status.success() {
text.push_str(&format!("\nexit: {}", output.status.code().unwrap_or(1)));
}
text
}
pub fn fed_command(
store: &Store,
name: &str,
action: &str,
params: &HashMap<String, String>,
) -> Result<(Manifest, Command)> {
let refuse = || {
anyhow!(
"the {name} plugin reads a secret, so only its bundled copy runs, \
reinstall it with `passbox plugin update {name}`"
)
};
let files = crate::first_party(name).ok_or_else(refuse)?;
let dir = store.plugins_dir().join(name);
for (file, bytes) in files {
if std::fs::read(dir.join(file)).ok().as_deref() != Some(bytes.as_bytes()) {
return Err(refuse());
}
}
let (_, toml) = files
.iter()
.find(|(file, _)| *file == "plugin.toml")
.ok_or_else(refuse)?;
let manifest: Manifest = toml::from_str(toml).context("bundled manifest")?;
let tool = manifest
.tool(action)
.with_context(|| format!("{name} has no action {action}"))?;
manifest.ensure_available()?;
let mut command = tool.command_with(params, files)?;
command.current_dir(dir);
Ok((manifest, command))
}
pub fn missing(plugin: &str, secret: &str) -> anyhow::Error {
anyhow!("the {plugin} plugin needs the secret {secret}, add it with `passbox add {secret}`")
}
pub fn run_fed(mut command: Command, secret: &str) -> Result<String> {
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.context("could not run the plugin command")?;
let _ = child
.stdin
.take()
.expect("piped")
.write_all(secret.as_bytes());
let output = child.wait_with_output()?;
Ok(scrub(&render(&output), secret))
}
pub fn valid_name(name: &str) -> Result<()> {
let safe = !name.is_empty()
&& !name.contains("..")
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if !safe {
bail!("invalid plugin name {name:?}, use letters, digits, dash and underscore");
}
Ok(())
}
pub fn load(store: &Store, name: &str) -> Result<Manifest> {
valid_name(name)?;
let path = store.plugins_dir().join(name).join("plugin.toml");
let text = std::fs::read_to_string(&path)
.with_context(|| format!("no plugin named {name}, run `passbox plugin add {name}`"))?;
let manifest: Manifest = toml::from_str(&text).with_context(|| format!("{name} manifest"))?;
if manifest.name != name {
bail!("plugin dir {name} holds a manifest named {}", manifest.name);
}
Ok(manifest)
}
pub fn installed(store: &Store) -> Result<Vec<Manifest>> {
let dir = store.plugins_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if path.is_dir()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
&& let Ok(manifest) = load(store, name)
{
out.push(manifest);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
#[derive(Default)]
pub struct Windows {
pub tools: BTreeMap<String, u64>,
pub hash: String,
}
impl Windows {
pub fn shown(&self) -> String {
let span = |secs: u64| match secs {
s if s % 3600 == 0 => format!("{}h", s / 3600),
s if s % 60 == 0 => format!("{}m", s / 60),
s => format!("{s}s"),
};
self.tools
.iter()
.map(|(tool, secs)| format!("{tool} {}", span(*secs)))
.collect::<Vec<_>>()
.join(", ")
}
}
pub fn windows(store: &Store) -> Result<Windows> {
let path = crate::sync::config_path(store);
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(Windows::default());
};
parse_windows(&text, |name| load(store, name))
.with_context(|| format!("{} is not valid", path.display()))
}
fn duration(text: &str) -> Result<u64> {
let refuse = || anyhow!("{text:?} is not a duration such as \"90s\", \"30m\" or \"24h\"");
let unit = match text.chars().last() {
Some('s') => 1,
Some('m') => 60,
Some('h') => 3600,
_ => return Err(refuse()),
};
let count: u64 = text[..text.len() - 1]
.parse()
.ok()
.filter(|n| *n > 0)
.ok_or_else(refuse)?;
Ok(count.saturating_mul(unit))
}
fn parse_windows(text: &str, manifest: impl Fn(&str) -> Result<Manifest>) -> Result<Windows> {
let config: toml::Table = toml::from_str(text)?;
let Some(plugins) = config.get("plugins") else {
return Ok(Windows::default());
};
let plugins = plugins.as_table().context("plugins must be a table")?;
if let Some(other) = plugins.keys().find(|k| *k != "windows") {
bail!("unknown key plugins.{other}, the only one is plugins.windows");
}
let Some(section) = plugins.get("windows") else {
return Ok(Windows::default());
};
let section = section
.as_table()
.context("plugins.windows must be a table")?;
let part_ok = |p: &str| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
};
let mut out = Windows::default();
let mut hashed = String::new();
for (key, value) in section {
let (plugin, action) = key
.split_once('.')
.filter(|(p, t)| part_ok(p) && part_ok(t))
.with_context(|| {
format!(
"{key:?} must name one plugin and one tool, quoted, such as \"things3.logbook\""
)
})?;
let span = value
.as_str()
.with_context(|| format!("{key} must be a duration such as \"30m\""))?;
let secs = duration(span)
.with_context(|| key.clone())?
.min(crate::broker::MAX_GRANT_SECS);
let manifest = manifest(plugin)
.with_context(|| format!("{key} names a plugin that is not installed"))?;
let tool = manifest
.tool(action)
.with_context(|| format!("{key}: {plugin} has no tool {action}"))?;
hashed.push_str(&format!(
"{key}={secs} stdin={:?} run={:?}\n",
manifest.stdin, tool.run
));
out.tools.insert(key.clone(), secs);
}
out.hash = crate::project::hash(hashed.as_bytes());
Ok(out)
}
#[derive(Serialize, Deserialize)]
pub struct Approval {
pub hash: String,
mac: String,
}
pub(crate) fn approval_path(store: &Store) -> std::path::PathBuf {
store
.dir
.join(format!("windows-{}.json", crate::store::hostname()))
}
fn mac(key: &age::x25519::Identity, hash: &str) -> String {
use age::secrecy::ExposeSecret;
let mut digest = Sha256::new();
digest.update(b"passbox plugin windows\n");
digest.update(key.to_string().expose_secret().as_bytes());
digest.update(b"\n");
digest.update(hash.as_bytes());
hex::encode(digest.finalize())
}
pub fn approved(store: &Store) -> Option<Approval> {
serde_json::from_slice(&std::fs::read(approval_path(store)).ok()?).ok()
}
pub fn approve(store: &Store, key: &age::x25519::Identity, hash: &str) -> Result<()> {
let record = Approval {
hash: hash.to_string(),
mac: mac(key, hash),
};
crate::store::write_private(&approval_path(store), &serde_json::to_vec(&record)?)
}
impl Approval {
pub fn verify(&self, key: &age::x25519::Identity) -> bool {
self.mac == mac(key, &self.hash)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tool() -> Tool {
Tool {
name: "send".into(),
description: String::new(),
params: vec!["to".into(), "subject".into(), "body?".into()],
run: vec![
"osascript".into(),
"send.scpt".into(),
"{to}".into(),
"{subject}".into(),
"{body}".into(),
],
limits: BTreeMap::new(),
}
}
fn args(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn each_value_is_a_whole_argument_and_an_absent_optional_is_empty() {
let cmd = tool()
.command(&args(&[("to", "a@b.com"), ("subject", "hi; rm -rf ~")]))
.unwrap();
let rendered: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(rendered, vec!["send.scpt", "a@b.com", "hi; rm -rf ~", ""]);
}
#[test]
fn a_value_embedded_in_a_larger_argument_is_refused() {
let mut t = tool();
t.run = vec![
"osascript".into(),
"-e".into(),
"set b to \"{subject}\"".into(),
];
let err = t
.command(&args(&[("to", "a"), ("subject", "b")]))
.unwrap_err();
assert!(err.to_string().contains("whole argument"), "{err}");
}
#[test]
fn a_plugin_command_never_inherits_the_token_or_the_passphrase() {
let cmd = tool()
.command(&args(&[("to", "a"), ("subject", "b")]))
.unwrap();
let removed: Vec<_> = cmd
.get_envs()
.filter(|(_, value)| value.is_none())
.map(|(key, _)| key.to_string_lossy().into_owned())
.collect();
assert!(
removed.contains(&"PASSBOX_TOKEN".to_string()),
"{removed:?}"
);
assert!(
removed.contains(&"PASSBOX_PASSPHRASE".to_string()),
"{removed:?}"
);
}
#[test]
fn a_missing_required_parameter_is_refused() {
let err = tool().command(&args(&[("to", "a@b.com")])).unwrap_err();
assert!(err.to_string().contains("needs --subject"), "{err}");
}
#[test]
fn an_unknown_parameter_is_refused() {
let err = tool()
.command(&args(&[("to", "a"), ("subject", "b"), ("cc", "x")]))
.unwrap_err();
assert!(err.to_string().contains("no parameter cc"), "{err}");
}
#[test]
fn a_things3_job_tool_needs_its_limit_or_date() {
let text = std::fs::read_to_string("plugins/things3/plugin.toml").unwrap();
let manifest: Manifest = toml::from_str(&text).unwrap();
for (action, param) in [
("inbox_json", "limit"),
("completed", "modified_after"),
("search_json", "query"),
] {
let err = manifest
.tool(action)
.unwrap()
.command(&args(&[]))
.unwrap_err();
assert!(
err.to_string().contains(&format!("needs --{param}")),
"{action}: {err}"
);
}
let cmd = manifest
.tool("search_json")
.unwrap()
.command(&args(&[("query", "--limit=1")]))
.unwrap();
let argv: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(argv, ["search", "--json", "--", "--limit=1"]);
}
#[test]
fn every_first_party_tool_builds_the_expected_command() {
fn built(plugin: &str, action: &str, pairs: &[(&str, &str)]) -> Vec<String> {
let text = std::fs::read_to_string(format!("plugins/{plugin}/plugin.toml")).unwrap();
let manifest: Manifest = toml::from_str(&text).unwrap();
let cmd = manifest
.tool(action)
.unwrap_or_else(|| panic!("{plugin} has no {action}"))
.command(&args(pairs))
.unwrap();
let mut out = vec![cmd.get_program().to_string_lossy().into_owned()];
out.extend(cmd.get_args().map(|a| a.to_string_lossy().into_owned()));
out
}
type Case = (
&'static str,
&'static str,
&'static [(&'static str, &'static str)],
&'static [&'static str],
);
let cases: &[Case] = &[
(
"mail",
"recent",
&[("count", "5")],
&["osascript", "recent.applescript", "5"],
),
(
"mail",
"recent",
&[],
&["osascript", "recent.applescript", ""],
),
(
"mail",
"search",
&[("query", "inv")],
&["osascript", "search.applescript", "inv"],
),
("calendar", "today", &[], &["bash", "agenda.sh", "0"]),
(
"calendar",
"agenda",
&[("days", "3")],
&["bash", "agenda.sh", "3"],
),
("things3", "inbox", &[], &["things", "inbox"]),
("things3", "today", &[], &["things", "today"]),
(
"things3",
"search",
&[("query", "q")],
&["things", "search", "--", "q"],
),
(
"things3",
"today_json",
&[],
&["things", "today", "--json", "--recursive"],
),
(
"things3",
"inbox_json",
&[("limit", "20")],
&["things", "inbox", "--json", "--recursive", "--limit", "20"],
),
(
"things3",
"search_json",
&[("query", "q")],
&["things", "search", "--json", "--", "q"],
),
(
"things3",
"upcoming",
&[],
&["things", "upcoming", "--json", "--recursive"],
),
("things3", "logbook", &[], &["things", "logbook", "--json"]),
(
"things3",
"completed",
&[("modified_after", "2026-01-31")],
&[
"things",
"completed",
"--json",
"--modified-after",
"2026-01-31",
],
),
("things3", "all", &[], &["things", "all"]),
("things3", "trash", &[], &["things", "trash"]),
(
"imap",
"recent",
&[("count", "5")],
&["/usr/bin/python3", "-I", "-c", "imap.py", "recent", "5", ""],
),
(
"imap",
"search",
&[("query", "invoice"), ("mailbox", "Archive")],
&[
"/usr/bin/python3",
"-I",
"-c",
"imap.py",
"search",
"invoice",
"Archive",
],
),
(
"imap",
"read",
&[("id", "42")],
&["/usr/bin/python3", "-I", "-c", "imap.py", "read", "42", ""],
),
(
"imap",
"attachments",
&[("id", "42")],
&[
"/usr/bin/python3",
"-I",
"-c",
"imap.py",
"attachments",
"42",
"",
],
),
];
for (plugin, action, pairs, expected) in cases {
assert_eq!(&built(plugin, action, pairs), expected, "{plugin} {action}");
}
}
fn fed() -> Manifest {
toml::from_str(
"name = \"demo\"\ndescription = \"test\"\nstdin = \"mail/imap\"\n\
[[tool]]\nname = \"recent\"\nparams = [\"q\"]\n\
run = [\"sh\", \"-c\", \"run.sh\", \"run.sh\", \"{q}\"]\n",
)
.unwrap()
}
#[test]
fn the_prompt_names_the_secret_the_run_releases() {
let mut manifest = fed();
assert_eq!(
manifest.approval("recent", "claude", &args(&[("q", "x")])),
"run plugin:demo.recent q=\"x\" for claude, releasing mail/imap"
);
manifest.stdin = None;
assert_eq!(
manifest.approval("recent", "claude", &args(&[])),
"run plugin:demo.recent for claude"
);
}
fn bundled(plugin: &str) -> Manifest {
let text = std::fs::read_to_string(format!("plugins/{plugin}/plugin.toml")).unwrap();
toml::from_str(&text).unwrap()
}
#[test]
fn the_prompt_shows_the_query_and_the_mailbox_in_call_order() {
let imap = bundled("imap");
let call = args(&[("mailbox", "INBOX"), ("query", "invoice")]);
assert_eq!(
imap.approval("search", "claude", &call),
"run plugin:imap.search query=\"invoice\" mailbox=\"INBOX\" for claude, releasing mail/imap"
);
assert_eq!(
bundled("mail").approval("search", "claude", &args(&[("query", "invoice")])),
"run plugin:mail.search query=\"invoice\" for claude"
);
}
#[test]
fn a_shown_value_is_printable_ascii_with_quotes_escaped_and_a_long_one_is_cut() {
let search = bundled("imap");
let search = search.tool("search").unwrap();
let forged = "a\"\n\r\u{1b}[2K\\b\u{202e}c\t";
assert_eq!(
search.shown(&args(&[("query", forged)])),
"query=\"a\\\"[2K\\\\b?c\""
);
let long = search.shown(&args(&[("query", &"x".repeat(500))]));
assert_eq!(long, format!("query=\"{}...\"", "x".repeat(64)));
let mut many = bundled("imap");
many.tools[1].params = (0..20).map(|i| format!("p{i}")).collect();
let wide: Vec<(String, String)> =
(0..20).map(|i| (format!("p{i}"), "y".repeat(64))).collect();
let wide: HashMap<_, _> = wide.into_iter().collect();
let shown = many.tools[1].shown(&wide);
assert!(shown.ends_with("..."), "{shown}");
assert_eq!(shown.chars().count(), 256 + 3);
assert!(!shown.chars().any(|c| c.is_control() || !c.is_ascii()));
}
#[test]
fn a_value_over_each_kind_of_limit_is_refused_and_one_inside_passes() {
let long = |n: usize| "q".repeat(n);
let (q200, q201, m100, m101) = (long(200), long(201), long(100), long(101));
type Case<'a> = (&'a str, &'a str, Vec<(&'a str, &'a str)>, Option<&'a str>);
let cases: Vec<Case> = vec![
("imap", "recent", vec![("count", "50")], None),
(
"imap",
"recent",
vec![("count", "51")],
Some("count must be a whole number of at most 50"),
),
(
"imap",
"recent",
vec![("count", "-1")],
Some("count must be a whole number"),
),
("mail", "recent", vec![("count", "51")], Some("at most 50")),
("imap", "read", vec![("id", "42")], None),
(
"imap",
"read",
vec![("id", "42:*")],
Some("id must be digits only"),
),
(
"imap",
"attachments",
vec![("id", "1 OR 2")],
Some("id must be digits only"),
),
(
"imap",
"search",
vec![("query", &q200), ("mailbox", &m100)],
None,
),
(
"imap",
"search",
vec![("query", &q201)],
Some("query must be at most 200 characters"),
),
(
"imap",
"search",
vec![("query", "a"), ("mailbox", &m101)],
Some("mailbox must be at most 100"),
),
(
"mail",
"search",
vec![("query", &q201)],
Some("at most 200"),
),
(
"things3",
"search_json",
vec![("query", &q201)],
Some("at most 200"),
),
("calendar", "agenda", vec![("days", "31")], None),
(
"calendar",
"agenda",
vec![("days", "32")],
Some("days must be a whole number of at most 31"),
),
(
"things3",
"inbox_json",
vec![("limit", "101")],
Some("at most 100"),
),
(
"things3",
"completed",
vec![("modified_after", "2026-01-31")],
None,
),
(
"things3",
"completed",
vec![("modified_after", "2026-13-01")],
Some("must be a date such as 2026-01-31"),
),
(
"things3",
"completed",
vec![("modified_after", "--help")],
Some("must be a date"),
),
];
for (plugin, action, pairs, refused) in cases {
let manifest = bundled(plugin);
let result = manifest.tool(action).unwrap().command(&args(&pairs));
match refused {
None => assert!(result.is_ok(), "{plugin}.{action}: {:?}", result.err()),
Some(why) => {
let err = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains(why), "{plugin}.{action} {pairs:?}: {err}");
}
}
}
let mut typo = tool();
typo.limits.insert("bcc".into(), Limit::default());
let err = typo
.command(&args(&[("to", "a"), ("subject", "b")]))
.unwrap_err();
assert!(err.to_string().contains("limit on bcc"), "{err}");
let unknown = toml::from_str::<Manifest>(
"name = \"d\"\ndescription = \"\"\n[[tool]]\nname = \"t\"\nparams = [\"n\"]\n\
limits = { n = { maximum = 5 } }\nrun = [\"true\"]\n",
);
assert!(unknown.is_err(), "a misspelt limit is refused");
}
#[test]
fn an_empty_and_an_omitted_optional_are_the_same_call_and_any_other_value_is_not() {
let imap = bundled("imap");
let search = imap.tool("search").unwrap();
let call = |pairs: &[(&str, &str)]| search.call(&args(pairs));
let invoice = call(&[("query", "invoice")]);
assert_eq!(invoice, call(&[("query", "invoice"), ("mailbox", "")]));
assert_ne!(invoice, call(&[("query", "receipt")]));
assert_ne!(
invoice,
call(&[("query", "invoice"), ("mailbox", "Archive")])
);
assert_ne!(
call(&[("query", "a"), ("mailbox", "b")]),
call(&[("query", "a\",\"b"), ("mailbox", "")])
);
let read = imap.tool("read").unwrap();
assert_ne!(
read.call(&args(&[("id", "42")])),
read.call(&args(&[("id", "43")]))
);
}
#[test]
fn a_script_name_in_the_template_becomes_its_bytes_and_a_param_value_does_not() {
let manifest = fed();
let cmd = manifest
.tool("recent")
.unwrap()
.command_with(&args(&[("q", "run.sh")]), &[("run.sh", "echo {q}")])
.unwrap();
let rendered: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(rendered, ["-c", "echo {q}", "echo {q}", "run.sh"]);
}
#[test]
fn the_child_gets_the_secret_on_stdin_and_not_in_its_env() {
let manifest = fed();
let script = "read -r pw; echo \"got $pw\" >&2; env";
let cmd = manifest
.tool("recent")
.unwrap()
.command_with(&args(&[("q", "x")]), &[("run.sh", script)])
.unwrap();
let out = run_fed(cmd, "hunter22-pw\nhost: h").unwrap();
assert!(out.contains("got [redacted by passbox]"), "{out}");
let env = out.split("stderr:").next().unwrap();
assert!(!env.contains("hunter22"), "{env}");
assert!(!env.contains("redacted"), "{env}");
assert!(!out.contains("hunter22"), "{out}");
}
#[test]
fn a_password_is_scrubbed_and_its_fields_are_left() {
let secret = "hunter22\nuser: me@example.com";
assert_eq!(
scrub("me@example.com said hunter22", secret),
"me@example.com said [redacted by passbox]"
);
assert_eq!(
scrub(&format!("entry: {secret}"), secret),
"entry: [redacted by passbox]"
);
}
#[test]
fn a_name_that_climbs_out_of_the_plugins_dir_is_rejected() {
for bad in ["../victim", "..", "a/b", "a/../b", "", "x/../../y"] {
assert!(valid_name(bad).is_err(), "{bad} should be rejected");
}
for good in ["mail", "things3", "my-plugin", "my_plugin", "a.b"] {
assert!(valid_name(good).is_ok(), "{good} should be allowed");
}
}
fn installed_demo(name: &str) -> Result<Manifest> {
if name != "demo" {
bail!("no plugin named {name}");
}
Ok(toml::from_str(
"name = \"demo\"\ndescription = \"test\"\n\
[[tool]]\nname = \"read\"\nreadonly = true\nrun = [\"true\"]\n\
[[tool]]\nname = \"act\"\nrun = [\"true\"]\n",
)
.unwrap())
}
fn parsed(section: &str) -> Result<Windows> {
parse_windows(&format!("[plugins.windows]\n{section}\n"), installed_demo)
}
#[test]
fn a_window_names_exactly_one_installed_tool() {
let ok = parsed("\"demo.read\" = \"90s\"").unwrap();
assert_eq!(ok.tools["demo.read"], 90);
for (bad, why) in [
("demo = \"1h\"", "one plugin and one tool"),
("\"*\" = \"1h\"", "one plugin and one tool"),
("\"demo.*\" = \"1h\"", "one plugin and one tool"),
("\"*.read\" = \"1h\"", "one plugin and one tool"),
("\"demo.read.x\" = \"1h\"", "one plugin and one tool"),
("\"demo.\" = \"1h\"", "one plugin and one tool"),
("allow_writes = true", "one plugin and one tool"),
("\"demo.nope\" = \"1h\"", "demo has no tool nope"),
("\"other.read\" = \"1h\"", "not installed"),
("\"demo.read\" = \"1d\"", "not a duration"),
("\"demo.read\" = \"0s\"", "not a duration"),
("\"demo.read\" = 60", "must be a duration"),
] {
let err = format!("{:#}", parsed(bad).err().expect(bad));
assert!(err.contains(why), "{bad}: {err}");
}
let err = parse_windows("[plugins]\nallow_writes = true\n", installed_demo)
.err()
.unwrap();
assert!(err.to_string().contains("unknown key plugins.allow_writes"));
}
#[test]
fn a_window_is_capped_at_a_day_and_shown_as_set() {
let w = parsed("\"demo.read\" = \"48h\"").unwrap();
assert_eq!(w.tools["demo.read"], crate::broker::MAX_GRANT_SECS);
assert_eq!(w.shown(), "demo.read 24h");
assert_eq!(
parsed("\"demo.read\" = \"30m\"").unwrap().shown(),
"demo.read 30m"
);
assert_eq!(
parsed("\"demo.read\" = \"90s\"").unwrap().shown(),
"demo.read 90s"
);
}
#[test]
fn no_section_opens_no_window_and_an_edit_changes_the_hash() {
assert!(parse_windows("", installed_demo).unwrap().tools.is_empty());
assert!(
parse_windows("[sync]\nengine = \"icloud\"\n", installed_demo)
.unwrap()
.tools
.is_empty()
);
let a = parsed("\"demo.read\" = \"1h\"").unwrap().hash;
assert_eq!(a, parsed("\"demo.read\" = \"60m\"").unwrap().hash);
assert_ne!(a, parsed("\"demo.read\" = \"2h\"").unwrap().hash);
}
#[test]
fn an_approval_record_verifies_only_under_the_key_that_made_it() {
let tmp = tempfile::tempdir().unwrap();
let store = Store {
dir: tmp.path().to_path_buf(),
};
let key = age::x25519::Identity::generate();
approve(&store, &key, "h1").unwrap();
let record = approved(&store).unwrap();
assert_eq!(record.hash, "h1");
assert!(record.verify(&key));
assert!(!record.verify(&age::x25519::Identity::generate()));
let forged = Approval {
hash: "h2".into(),
mac: record.mac.clone(),
};
assert!(!forged.verify(&key));
}
#[test]
fn a_window_covers_any_installed_tool_and_an_old_readonly_key_still_loads() {
let ok = parsed("\"demo.act\" = \"1h\"").unwrap();
assert_eq!(ok.tools["demo.act"], 3600);
assert!(installed_demo("demo").unwrap().tool("read").is_some());
}
#[test]
fn a_literal_brace_that_is_not_a_parameter_passes_through() {
let mut t = tool();
t.run = vec!["osascript".into(), "-e".into(), "{1, 2}".into()];
let cmd = t.command(&args(&[("to", "a"), ("subject", "b")])).unwrap();
let rendered: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(rendered, vec!["-e", "{1, 2}"]);
}
}