use std::path::PathBuf;
use super::write_json_stdout;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Browser {
#[default]
Chrome,
Chromium,
Brave,
Edge,
}
impl std::str::FromStr for Browser {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"chrome" | "google-chrome" => Ok(Browser::Chrome),
"chromium" => Ok(Browser::Chromium),
"brave" => Ok(Browser::Brave),
"edge" | "microsoft-edge" | "msedge" => Ok(Browser::Edge),
other => Err(format!(
"unknown browser '{other}' (expected chrome, chromium, brave, or edge)"
)),
}
}
}
impl Browser {
fn as_str(self) -> &'static str {
match self {
Browser::Chrome => "chrome",
Browser::Chromium => "chromium",
Browser::Brave => "brave",
Browser::Edge => "edge",
}
}
fn windows_registry_root(self) -> &'static str {
match self {
Browser::Chrome => "Software\\Google\\Chrome\\NativeMessagingHosts",
Browser::Chromium => "Software\\Chromium\\NativeMessagingHosts",
Browser::Brave => "Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts",
Browser::Edge => "Software\\Microsoft\\Edge\\NativeMessagingHosts",
}
}
}
pub fn is_valid_extension_id(id: &str) -> bool {
id.len() == 32 && id.bytes().all(|b| (b'a'..=b'p').contains(&b))
}
pub const HOST_NAME: &str = "sh.tirith.browser";
pub const PLACEHOLDER_EXTENSION_ID: &str = "EXTENSION_ID_PLACEHOLDER_REPLACE_ME";
pub fn install_extension(
extension_id: Option<String>,
browser: Browser,
apply: bool,
json: bool,
) -> i32 {
let platform = manifest_platform();
let Some(exe) = current_tirith_exe() else {
let msg = "cannot determine the absolute path of the tirith executable; aborting so we \
never write a relative native-messaging manifest path";
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"written": false,
"error": msg,
});
let _ = write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
);
} else {
eprintln!("tirith browser install-extension: {msg}");
}
return 1;
};
let (extension_id, is_placeholder) = match extension_id
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
Some(id) => (id, false),
None => (PLACEHOLDER_EXTENSION_ID.to_string(), true),
};
if !is_placeholder && !is_valid_extension_id(&extension_id) {
let msg = format!(
"invalid --extension-id '{extension_id}': a Chrome extension id is exactly 32 \
letters in the range a–p"
);
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"written": false,
"error": msg,
});
let _ = write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
);
} else {
eprintln!("tirith browser install-extension: {msg}");
}
return 1;
}
let manifest = render_manifest(&exe, &extension_id);
let manifest_path = manifest_path(browser);
let Some(path) = manifest_path else {
if cfg!(target_os = "windows") {
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"manifest_path": serde_json::Value::Null,
"written": false,
"extension_id": extension_id,
"extension_id_is_placeholder": is_placeholder,
"manifest": manifest,
"note": windows_guidance(&exe, browser),
});
if !write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
) {
return 1;
}
} else {
eprintln!(
"tirith browser install-extension: {}",
windows_guidance(&exe, browser)
);
eprintln!("tirith browser install-extension: manifest body to register:");
println!("{manifest}");
}
return 0;
}
let msg = if platform == "unsupported" {
"native messaging host installation is not supported on this platform"
} else {
"cannot resolve the manifest path: no home directory ($HOME) could be resolved"
};
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"manifest_path": serde_json::Value::Null,
"written": false,
"error": msg,
});
let _ = write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
);
} else {
eprintln!("tirith browser install-extension: {msg}");
}
return 1;
};
if !apply {
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"manifest_path": path.display().to_string(),
"written": false,
"extension_id": extension_id,
"extension_id_is_placeholder": is_placeholder,
"manifest": manifest,
});
if !write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
) {
return 1;
}
} else {
eprintln!(
"tirith browser install-extension: dry-run; would write the {} manifest to {}",
browser.as_str(),
path.display()
);
eprintln!("tirith browser install-extension: rerun with --apply to install.");
if is_placeholder {
eprintln!(
"tirith browser install-extension: NOTE — using a PLACEHOLDER extension id. \
Pass --extension-id <id> with the companion extension's real Chrome id \
(32 letters a–p) or the host will refuse the connection."
);
}
println!("{manifest}");
}
return 0;
}
let needs_write = !matches!(
tirith_core::util::read_text_no_follow_capped(&path, 1024 * 1024),
Ok(existing) if existing == manifest.as_bytes()
);
if needs_write {
let policy = tirith_core::policy::Policy::discover_local_only(None);
if let Err(e) = write_manifest_permitted(&path, manifest.as_bytes(), &policy) {
let msg = format!("failed to write {}: {e}", path.display());
return apply_failure(json, platform, browser, &path, &msg);
}
}
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"manifest_path": path.display().to_string(),
"written": needs_write,
"extension_id": extension_id,
"extension_id_is_placeholder": is_placeholder,
});
if !write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
) {
return 1;
}
} else {
if needs_write {
eprintln!(
"tirith browser install-extension: wrote the {} manifest to {}",
browser.as_str(),
path.display()
);
} else {
eprintln!(
"tirith browser install-extension: {} already up to date",
path.display()
);
}
if is_placeholder {
eprintln!(
"tirith browser install-extension: NOTE — wrote a PLACEHOLDER extension id. \
Re-run with --extension-id <id> once the companion extension is published."
);
}
}
0
}
fn write_manifest_permitted(
path: &std::path::Path,
contents: &[u8],
policy: &tirith_core::policy::Policy,
) -> std::io::Result<()> {
let root = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
super::write_config_file_permitted_with_parent_creation(
root, path, contents, true, policy, false, true,
)
}
fn apply_failure(
json: bool,
platform: &str,
browser: Browser,
path: &std::path::Path,
msg: &str,
) -> i32 {
if json {
let env = serde_json::json!({
"platform": platform,
"browser": browser.as_str(),
"host_name": HOST_NAME,
"manifest_path": path.display().to_string(),
"written": false,
"error": msg,
});
let _ = write_json_stdout(
&env,
"tirith browser install-extension: failed to write JSON output",
);
} else {
eprintln!("tirith browser install-extension: {msg}");
}
1
}
pub fn render_manifest(exe: &str, extension_id: &str) -> String {
let manifest = serde_json::json!({
"name": HOST_NAME,
"description": "tirith browser native-messaging host (paste provenance, M12)",
"path": exe,
"type": "stdio",
"allowed_origins": [format!("chrome-extension://{extension_id}/")],
});
serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| "{}".to_string())
}
fn manifest_platform() -> &'static str {
#[cfg(target_os = "macos")]
{
"macos"
}
#[cfg(target_os = "linux")]
{
"linux"
}
#[cfg(target_os = "windows")]
{
"windows-registry"
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
"unsupported"
}
}
fn manifest_path(browser: Browser) -> Option<PathBuf> {
let home = home::home_dir()?;
let file = format!("{HOST_NAME}.json");
#[cfg(target_os = "macos")]
{
let vendor = match browser {
Browser::Chrome => "Google/Chrome",
Browser::Chromium => "Chromium",
Browser::Brave => "BraveSoftware/Brave-Browser",
Browser::Edge => "Microsoft Edge",
};
Some(
home.join("Library/Application Support")
.join(vendor)
.join("NativeMessagingHosts")
.join(file),
)
}
#[cfg(target_os = "linux")]
{
let vendor = match browser {
Browser::Chrome => "google-chrome",
Browser::Chromium => "chromium",
Browser::Brave => "BraveSoftware/Brave-Browser",
Browser::Edge => "microsoft-edge",
};
Some(
home.join(".config")
.join(vendor)
.join("NativeMessagingHosts")
.join(file),
)
}
#[cfg(target_os = "windows")]
{
let _ = (home, file, browser);
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = (home, file, browser);
None
}
}
fn windows_guidance(exe: &str, browser: Browser) -> String {
let root = browser.windows_registry_root();
format!(
"on Windows, register the native messaging host via the registry rather than a file drop. \
Save the manifest body below to a file (e.g. %LOCALAPPDATA%\\tirith\\{HOST_NAME}.json), \
then create the key \
HKCU\\{root}\\{HOST_NAME} with its default value \
set to that file's path. The host executable is: {exe}"
)
}
fn current_tirith_exe() -> Option<String> {
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.map(|p| p.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_contains_required_fields() {
let m = render_manifest("/usr/local/bin/tirith", "abcdefghijklmnopabcdefghijklmnop");
let parsed: serde_json::Value = serde_json::from_str(&m).expect("manifest is valid JSON");
assert_eq!(parsed["name"], HOST_NAME);
assert_eq!(parsed["path"], "/usr/local/bin/tirith");
assert_eq!(parsed["type"], "stdio");
let origins = parsed["allowed_origins"].as_array().expect("origins array");
assert_eq!(origins.len(), 1);
assert_eq!(
origins[0],
"chrome-extension://abcdefghijklmnopabcdefghijklmnop/"
);
assert!(
parsed["description"].as_str().unwrap().contains("tirith"),
"description should mention tirith"
);
}
#[test]
fn current_tirith_exe_is_some_and_absolute() {
let s = current_tirith_exe()
.expect("current_exe()/canonicalize() should resolve in a normal test run");
assert!(
std::path::Path::new(&s).is_absolute(),
"manifest `path` must be absolute; got {s}"
);
}
#[test]
fn manifest_preserves_exe_path_with_spaces() {
let m = render_manifest(
"/Applications/My Tools/tirith",
"abcdefghijklmnopabcdefghijklmnop",
);
let parsed: serde_json::Value = serde_json::from_str(&m).unwrap();
assert_eq!(parsed["path"], "/Applications/My Tools/tirith");
}
#[test]
fn placeholder_id_appears_in_origin() {
let m = render_manifest("/bin/tirith", PLACEHOLDER_EXTENSION_ID);
assert!(
m.contains(&format!("chrome-extension://{PLACEHOLDER_EXTENSION_ID}/")),
"placeholder id must appear in the origin; got: {m}"
);
}
#[test]
fn windows_guidance_mentions_registry_and_exe() {
let g = windows_guidance("C:\\tools\\tirith.exe", Browser::Chrome);
assert!(g.contains("HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts"));
assert!(g.contains(HOST_NAME));
assert!(g.contains("C:\\tools\\tirith.exe"));
}
#[test]
fn windows_guidance_honors_browser_registry_root() {
let edge = windows_guidance("C:\\tools\\tirith.exe", Browser::Edge);
assert!(
edge.contains("HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts"),
"Edge guidance must name the Edge registry root; got: {edge}"
);
assert!(
!edge.contains("Google\\Chrome"),
"Edge guidance must NOT point at the Chrome root; got: {edge}"
);
let brave = windows_guidance("C:\\tools\\tirith.exe", Browser::Brave);
assert!(
brave.contains("HKCU\\Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts"),
"Brave guidance must name the Brave registry root; got: {brave}"
);
let chromium = windows_guidance("C:\\tools\\tirith.exe", Browser::Chromium);
assert!(
chromium.contains("HKCU\\Software\\Chromium\\NativeMessagingHosts"),
"Chromium guidance must name the Chromium registry root; got: {chromium}"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn manifest_path_targets_native_messaging_hosts_dir() {
let Some(p) = manifest_path(Browser::Chrome) else {
eprintln!("skipping: no home dir resolved in this environment");
return;
};
let s = p.display().to_string();
assert!(
s.ends_with(&format!("NativeMessagingHosts/{HOST_NAME}.json")),
"got {s}"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn manifest_path_is_per_browser() {
let cases = [
(
Browser::Chrome,
if cfg!(target_os = "macos") {
"Google/Chrome"
} else {
"google-chrome"
},
),
(
Browser::Chromium,
if cfg!(target_os = "macos") {
"Chromium"
} else {
"chromium"
},
),
(Browser::Brave, "Brave-Browser"),
(
Browser::Edge,
if cfg!(target_os = "macos") {
"Microsoft Edge"
} else {
"microsoft-edge"
},
),
];
let mut seen: Vec<String> = Vec::new();
for (browser, segment) in cases {
let Some(p) = manifest_path(browser) else {
eprintln!("skipping: no home dir resolved in this environment");
return;
};
let s = p.display().to_string();
assert!(
s.ends_with(&format!("NativeMessagingHosts/{HOST_NAME}.json")),
"{} path must end in the host filename; got {s}",
browser.as_str()
);
assert!(
s.contains(segment),
"{} path must contain the vendor segment '{segment}'; got {s}",
browser.as_str()
);
seen.push(s);
}
let before = seen.len();
seen.sort();
seen.dedup();
assert_eq!(before, seen.len(), "per-browser paths must be distinct");
}
#[test]
fn extension_id_validator_accepts_only_32_letters_a_to_p() {
assert!(is_valid_extension_id("abcdefghijklmnopabcdefghijklmnop"));
assert!(is_valid_extension_id(&"a".repeat(32)));
assert!(is_valid_extension_id(&"p".repeat(32)));
assert!(!is_valid_extension_id("abcdefghijklmnop"));
assert!(!is_valid_extension_id(&"a".repeat(33)));
assert!(!is_valid_extension_id(&"q".repeat(32)));
assert!(!is_valid_extension_id(&"z".repeat(32)));
assert!(!is_valid_extension_id("0123456789abcdef0123456789abcdef"));
assert!(!is_valid_extension_id("ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP"));
assert!(!is_valid_extension_id(PLACEHOLDER_EXTENSION_ID));
}
#[test]
fn manifest_platform_is_os_only_no_browser_suffix() {
let p = manifest_platform();
assert!(
!p.contains("chrome"),
"platform tag must not hardcode a browser; got '{p}'"
);
#[cfg(target_os = "macos")]
assert_eq!(p, "macos");
#[cfg(target_os = "linux")]
assert_eq!(p, "linux");
#[cfg(target_os = "windows")]
assert_eq!(p, "windows-registry");
}
#[test]
fn browser_parses_known_values() {
use std::str::FromStr;
assert_eq!(Browser::from_str("chrome").unwrap(), Browser::Chrome);
assert_eq!(Browser::from_str("Chromium").unwrap(), Browser::Chromium);
assert_eq!(Browser::from_str("BRAVE").unwrap(), Browser::Brave);
assert_eq!(Browser::from_str("edge").unwrap(), Browser::Edge);
assert_eq!(Browser::from_str("msedge").unwrap(), Browser::Edge);
assert!(Browser::from_str("safari").is_err());
assert_eq!(Browser::default(), Browser::Chrome);
}
#[test]
fn manifest_deny_creates_neither_parent_nor_file() {
let root = tempfile::tempdir().unwrap();
let parent = root
.path()
.join("browser-config")
.join("NativeMessagingHosts");
let path = parent.join("dev.tirith.host.json");
let mut policy = tirith_core::policy::Policy::default();
policy.task_gate.mode = tirith_core::web3_policy::TaskGateMode::Enforce;
policy
.task_gate
.effects_denied_for_untrusted_sources
.insert(tirith_core::effects::CommandEffectKind::FilesystemWrite);
let error = write_manifest_permitted(&path, b"{}\n", &policy)
.expect_err("the task gate must refuse browser persistence");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(!parent.exists());
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn manifest_write_refuses_a_final_symlink() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let parent = root.path().join("NativeMessagingHosts");
std::fs::create_dir(&parent).unwrap();
let outside_path = outside.path().join("manifest.json");
std::fs::write(&outside_path, b"outside\n").unwrap();
let path = parent.join("dev.tirith.host.json");
std::os::unix::fs::symlink(&outside_path, &path).unwrap();
let error = write_manifest_permitted(
&path,
b"replacement\n",
&tirith_core::policy::Policy::default(),
)
.expect_err("native manifest publication must not follow a final symlink");
assert!(matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::InvalidInput
));
assert_eq!(std::fs::read(&outside_path).unwrap(), b"outside\n");
}
}