diff --git a/src/capabilities/default.json b/src/capabilities/default.json index 778bfb5..e895c6b 100644 --- a/src/capabilities/default.json +++ b/src/capabilities/default.json @@ -5,7 +5,6 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default", "dialog:default" ] } diff --git a/src/src/commands/about.rs b/src/src/commands/about.rs index 7ec9f56..b847a85 100644 --- a/src/src/commands/about.rs +++ b/src/src/commands/about.rs @@ -23,8 +23,10 @@ fn os_pretty_name() -> String { fs::read_to_string("/etc/os-release") .ok() .and_then(|s| { - s.lines() - .find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string())) + s.lines().find_map(|l| { + l.strip_prefix("PRETTY_NAME=") + .map(|v| v.trim_matches('"').to_string()) + }) }) .unwrap_or_else(|| "BOS".to_string()) } @@ -49,11 +51,15 @@ fn cpu() -> String { let model = fs::read_to_string("/proc/cpuinfo") .ok() .and_then(|s| { - s.lines() - .find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())) + s.lines().find_map(|l| { + l.strip_prefix("model name") + .map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()) + }) }) .unwrap_or_else(|| "unknown".to_string()); - let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); if cores > 0 { format!("{model} ({cores} threads)") } else { @@ -62,14 +68,12 @@ fn cpu() -> String { } fn memory() -> String { - let kb = fs::read_to_string("/proc/meminfo") - .ok() - .and_then(|s| { - s.lines() - .find(|l| l.starts_with("MemTotal:")) - .and_then(|l| l.split_whitespace().nth(1)) - .and_then(|v| v.parse::().ok()) - }); + let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| { + s.lines() + .find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().ok()) + }); match kb { Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0), None => "unknown".to_string(), @@ -96,7 +100,11 @@ async fn gpu() -> String { } async fn disk_usage() -> String { - let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else { + let Ok(output) = Command::new("df") + .args(["-h", "--output=used,size,pcent", "/"]) + .output() + .await + else { return "unknown".to_string(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -136,11 +144,35 @@ pub async fn get_system_info() -> SystemInfo { } } +/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static +/// hostnames are also capped at `HOST_NAME_MAX` (64). +fn valid_hostname(name: &str) -> bool { + let name = name.trim(); + if name.is_empty() || name.len() > 64 || name.starts_with('-') { + return false; + } + if name.contains('\n') || name.contains('\r') || name.contains('\0') { + return false; + } + name.split('.').all(valid_dns_label) +} + +fn valid_dns_label(label: &str) -> bool { + let b = label.as_bytes(); + if b.is_empty() || b.len() > 63 { + return false; + } + if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() { + return false; + } + b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-') +} + #[tauri::command] pub async fn set_hostname(name: String) -> Result<(), String> { let name = name.trim(); - if name.is_empty() { - return Err("Hostname can't be empty".into()); + if !valid_hostname(name) { + return Err("invalid hostname".into()); } let output = Command::new("pkexec") .args(["hostnamectl", "set-hostname", name]) @@ -153,3 +185,24 @@ pub async fn set_hostname(name: String) -> Result<(), String> { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostname_rfc1123() { + assert!(valid_hostname("bos")); + assert!(valid_hostname("bos.local")); + assert!(valid_hostname("a1-b")); + assert!(valid_hostname("1host")); + assert!(!valid_hostname("")); + assert!(!valid_hostname("-bos")); + assert!(!valid_hostname("bos-")); + assert!(!valid_hostname("-foo.bar")); + assert!(!valid_hostname("foo_bar")); + assert!(!valid_hostname("bos\n-set-hostname evil")); + assert!(!valid_hostname("--help")); + assert!(!valid_hostname(&"a".repeat(65))); + } +} diff --git a/src/src/commands/aur.rs b/src/src/commands/aur.rs index 226af63..6c72b64 100644 --- a/src/src/commands/aur.rs +++ b/src/src/commands/aur.rs @@ -10,6 +10,8 @@ use serde::Serialize; +use super::util; + #[derive(Serialize, Clone)] pub struct AurResult { name: String, @@ -19,7 +21,11 @@ pub struct AurResult { #[tauri::command] pub async fn search_aur(query: String) -> Vec { - let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else { + let Ok(output) = tokio::process::Command::new("yay") + .args(["-Ss", "--aur", &query]) + .output() + .await + else { return Vec::new(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec { while let Some(header) = lines.next() { // "aur/name version (+votes score) [Orphaned]" — name/version are // always the first two whitespace-separated fields after "aur/". - let Some(rest) = header.strip_prefix("aur/") else { continue }; + let Some(rest) = header.strip_prefix("aur/") else { + continue; + }; let mut parts = rest.split_whitespace(); let Some(name) = parts.next() else { continue }; let version = parts.next().unwrap_or("").to_string(); let description = lines.next().unwrap_or("").trim().to_string(); - results.push(AurResult { name: name.to_string(), version, description }); + results.push(AurResult { + name: name.to_string(), + version, + description, + }); if results.len() >= 50 { break; } @@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec { } #[tauri::command] -pub fn install_aur_package(pkg: String) { - let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn(); +pub fn install_aur_package(pkg: String) -> Result<(), String> { + if !util::valid_pkg_name(&pkg) { + return Err(format!("refusing to install '{pkg}'")); + } + std::process::Command::new("kitty") + .args(["-e", "yay", "-S", &pkg]) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/src/src/commands/breadpad.rs b/src/src/commands/breadpad.rs index 5f0fb5d..19db123 100644 --- a/src/src/commands/breadpad.rs +++ b/src/src/commands/breadpad.rs @@ -35,22 +35,35 @@ pub struct BreadpadConfig { pub fn get_breadpad_config() -> BreadpadConfig { let doc = config::load_doc(&config_path()); BreadpadConfig { - default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()), + default_type: config::get_str(&doc, &["settings", "default_type"]) + .unwrap_or_else(|| "note".into()), workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true), snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]), - archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30), + archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]) + .unwrap_or(30), model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(), tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(), ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true), - ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(), + ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]) + .unwrap_or_default(), ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(), - ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6), - reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()), - reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60), + ollama_confidence_threshold: config::get_f64( + &doc, + &["model", "ollama", "confidence_threshold"], + ) + .unwrap_or(0.6), + reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]) + .unwrap_or_else(|| "7:00".into()), + reminders_missed_grace_minutes: config::get_i64( + &doc, + &["reminders", "missed_grace_minutes"], + ) + .unwrap_or(60), calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false), calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(), calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(), - calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(), + // Write-only to the webview, same as restic — never round-trip the secret. + calendar_password: String::new(), } } @@ -60,19 +73,76 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> { let mut doc = config::load_doc(&path); config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type); config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag); - config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options); - config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days); + config::set_str_list( + &mut doc, + &["settings", "snooze_options"], + &cfg.snooze_options, + ); + config::set_i64( + &mut doc, + &["settings", "archive_after_days"], + cfg.archive_after_days, + ); config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path); config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path); - config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled); - config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint); + config::set_bool( + &mut doc, + &["model", "ollama", "enabled"], + cfg.ollama_enabled, + ); + config::set_str_or_remove( + &mut doc, + &["model", "ollama", "endpoint"], + &cfg.ollama_endpoint, + ); config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model); - config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold); - config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning); - config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes); + config::set_f64( + &mut doc, + &["model", "ollama", "confidence_threshold"], + cfg.ollama_confidence_threshold, + ); + config::set_str_or_remove( + &mut doc, + &["reminders", "default_morning"], + &cfg.reminders_default_morning, + ); + config::set_i64( + &mut doc, + &["reminders", "missed_grace_minutes"], + cfg.reminders_missed_grace_minutes, + ); config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled); config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url); config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username); - config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password); + apply_calendar_password(&mut doc, &cfg.calendar_password); config::save_doc(&path, &doc).map_err(|e| e.to_string()) } + +/// Empty incoming password keeps the existing secret (PasswordField is write-only). +fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) { + if incoming.is_empty() { + return; + } + config::set_str(doc, &["calendar", "password"], incoming); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_password_keeps_existing_secret() { + let mut doc: toml_edit::DocumentMut = + "[calendar]\npassword = \"secret\"\n".parse().unwrap(); + apply_calendar_password(&mut doc, ""); + assert_eq!( + config::get_str(&doc, &["calendar", "password"]).as_deref(), + Some("secret") + ); + apply_calendar_password(&mut doc, "newpass"); + assert_eq!( + config::get_str(&doc, &["calendar", "password"]).as_deref(), + Some("newpass") + ); + } +} diff --git a/src/src/commands/datetime.rs b/src/src/commands/datetime.rs index 2e8660b..5413cc2 100644 --- a/src/src/commands/datetime.rs +++ b/src/src/commands/datetime.rs @@ -26,7 +26,12 @@ async fn list_timezones() -> Vec { .output() .await .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(str::to_string) + .collect() + }) .unwrap_or_default() } @@ -60,10 +65,32 @@ pub async fn get_datetime_info() -> DateTimeInfo { } } +/// Reject flags, path traversal, and newlines before we ever exec. Charset +/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`). +fn timezone_looks_safe(tz: &str) -> bool { + let tz = tz.trim(); + if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') { + return false; + } + if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") { + return false; + } + tz.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-')) +} + #[tauri::command] pub async fn set_timezone(tz: String) -> Result<(), String> { + let tz = tz.trim(); + if !timezone_looks_safe(tz) { + return Err("invalid timezone".into()); + } + let listed = list_timezones().await; + if !listed.is_empty() && !listed.iter().any(|t| t == tz) { + return Err("unknown timezone".into()); + } let output = Command::new("pkexec") - .args(["timedatectl", "set-timezone", &tz]) + .args(["timedatectl", "set-timezone", tz]) .output() .await .map_err(|e| e.to_string())?; @@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> { #[tauri::command] pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> { let val = if enabled { "true" } else { "false" }; - Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?; + Command::new("pkexec") + .args(["timedatectl", "set-ntp", val]) + .status() + .await + .map_err(|e| e.to_string())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timezone_rejects_flags_and_traversal() { + assert!(timezone_looks_safe("UTC")); + assert!(timezone_looks_safe("America/New_York")); + assert!(timezone_looks_safe("Etc/GMT+6")); + assert!(!timezone_looks_safe("")); + assert!(!timezone_looks_safe("-UTC")); + assert!(!timezone_looks_safe("--help")); + assert!(!timezone_looks_safe("America/../UTC")); + assert!(!timezone_looks_safe("UTC\n--adjust")); + assert!(!timezone_looks_safe("UTC;reboot")); + } +} diff --git a/src/src/commands/firewall.rs b/src/src/commands/firewall.rs index 0841d7b..bfc6471 100644 --- a/src/src/commands/firewall.rs +++ b/src/src/commands/firewall.rs @@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if stderr.is_empty() { match output.status.code() { - Some(127) => "no polkit authentication agent is available in this session".to_string(), + Some(127) => { + "no polkit authentication agent is available in this session".to_string() + } Some(code) => format!("pkexec exited with status {code}"), None => "pkexec was terminated by a signal".to_string(), } @@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result { }); } let text = String::from_utf8_lossy(&output.stdout); - let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active"); + let active = text + .lines() + .next() + .is_some_and(|l| l.trim() == "Status: active"); let rules = text .lines() .filter_map(|l| { @@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result { } let (num, rest) = l.split_once(']')?; let number = num.trim_start_matches('[').trim().to_string(); - Some(FirewallRule { number, text: rest.trim().to_string() }) + Some(FirewallRule { + number, + text: rest.trim().to_string(), + }) }) .collect(); Ok(FirewallStatus { active, rules }) @@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result { #[tauri::command] pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { let verb = if enabled { "enable" } else { "disable" }; - let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?; + let output = Command::new("pkexec") + .args(["ufw", "--force", verb]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -68,9 +80,54 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { } } +/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No +/// service names, IPs, or flags — those become extra `ufw allow` operands. +fn valid_firewall_rule(rule: &str) -> bool { + let rule = rule.trim(); + if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') { + return false; + } + if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') { + return false; + } + let (port, proto) = if let Some((p, rest)) = rule.split_once('/') { + (p, Some(rest)) + } else if let Some((p, rest)) = rule.split_once(' ') { + (p, Some(rest.trim())) + } else { + (rule, None) + }; + let Ok(n) = port.parse::() else { + return false; + }; + if n == 0 { + return false; + } + match proto { + None => true, + Some(p) => p == "tcp" || p == "udp", + } +} + +fn valid_rule_number(number: &str) -> bool { + let t = number.trim(); + !t.is_empty() + && t.len() <= 8 + && t.bytes().all(|b| b.is_ascii_digit()) + && t.parse::().is_ok_and(|n| n > 0) +} + #[tauri::command] pub async fn add_firewall_rule(rule: String) -> Result<(), String> { - let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?; + let rule = rule.trim(); + if !valid_firewall_rule(rule) { + return Err("invalid firewall rule".into()); + } + let output = Command::new("pkexec") + .args(["ufw", "allow", rule]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> { #[tauri::command] pub async fn remove_firewall_rule(number: String) -> Result<(), String> { - let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?; + if !valid_rule_number(&number) { + return Err("invalid rule number".into()); + } + let output = Command::new("pkexec") + .args(["ufw", "--force", "delete", number.trim()]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firewall_rule_is_port_and_optional_proto() { + assert!(valid_firewall_rule("22")); + assert!(valid_firewall_rule("8080/tcp")); + assert!(valid_firewall_rule("53/udp")); + assert!(valid_firewall_rule("80 tcp")); + assert!(!valid_firewall_rule("OpenSSH")); + assert!(!valid_firewall_rule("-f")); + assert!(!valid_firewall_rule("22;id")); + assert!(!valid_firewall_rule("22/tcp\nallow 23")); + assert!(!valid_firewall_rule("0")); + assert!(!valid_firewall_rule("65536")); + assert!(!valid_firewall_rule("22/all")); + } + + #[test] + fn firewall_delete_is_positive_int() { + assert!(valid_rule_number("1")); + assert!(valid_rule_number("12")); + assert!(!valid_rule_number("0")); + assert!(!valid_rule_number("-1")); + assert!(!valid_rule_number("1;2")); + assert!(!valid_rule_number("1\n2")); + assert!(!valid_rule_number("")); + } +} diff --git a/src/src/commands/power.rs b/src/src/commands/power.rs index 3d82def..d1d6820 100644 --- a/src/src/commands/power.rs +++ b/src/src/commands/power.rs @@ -7,13 +7,22 @@ use serde::Serialize; use tokio::process::Command; +use super::util; + async fn upower_device(kind: &str) -> Option { let out = Command::new("upower").arg("-e").output().await.ok()?; - String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string) + String::from_utf8_lossy(&out.stdout) + .lines() + .find(|l| l.to_lowercase().contains(kind)) + .map(str::to_string) } async fn upower_field(device: &str, field: &str) -> Option { - let out = Command::new("upower").args(["-i", device]).output().await.ok()?; + let out = Command::new("upower") + .args(["-i", device]) + .output() + .await + .ok()?; let text = String::from_utf8_lossy(&out.stdout); text.lines() .find(|l| l.trim_start().starts_with(field)) @@ -39,12 +48,18 @@ async fn battery_summary() -> Vec<(String, String)> { if let Some(t) = t { rows.push(("Time remaining".to_string(), t)); } - let full: Option = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); - let design: Option = - upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); + let full: Option = upower_field(&bat, "energy-full") + .await + .and_then(|v| v.split_whitespace().next()?.parse().ok()); + let design: Option = upower_field(&bat, "energy-full-design") + .await + .and_then(|v| v.split_whitespace().next()?.parse().ok()); if let (Some(full), Some(design)) = (full, design) { if design > 0.0 { - rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0))); + rows.push(( + "Battery health".to_string(), + format!("{:.0}% of design capacity", full / design * 100.0), + )); } } rows @@ -64,12 +79,19 @@ async fn power_source() -> String { async fn tlp_profile() -> Option { let out = Command::new("tlp-stat").arg("-s").output().await.ok()?; let text = String::from_utf8_lossy(&out.stdout); - text.lines().find(|l| l.trim_start().starts_with("TLP profile")).and_then(|l| l.split('=').nth(1)).map(|v| v.trim().to_string()) + text.lines() + .find(|l| l.trim_start().starts_with("TLP profile")) + .and_then(|l| l.split('=').nth(1)) + .map(|v| v.trim().to_string()) } async fn brightness_device() -> Option { let out = Command::new("brightnessctl").output().await.ok()?; - String::from_utf8_lossy(&out.stdout).lines().find(|l| l.starts_with("Device")).and_then(|l| l.split('\'').nth(1)).map(str::to_string) + String::from_utf8_lossy(&out.stdout) + .lines() + .find(|l| l.starts_with("Device")) + .and_then(|l| l.split('\'').nth(1)) + .map(str::to_string) } async fn brightness_pct() -> Option { @@ -98,7 +120,10 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> } fn read_threshold(path: &std::path::Path) -> i64 { - std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(100) + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(100) } #[derive(Serialize)] @@ -130,16 +155,57 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> { return Err("No controllable backlight found".into()); }; let pct = format!("{percent}%"); - Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?; + Command::new("brightnessctl") + .args(["--device", &device, "set", &pct]) + .status() + .await + .map_err(|e| e.to_string())?; Ok(()) } +fn charge_threshold_write(which: &str, percent: i64) -> Result<(String, i64), String> { + if which != "start" && which != "end" { + return Err("threshold must be start or end".into()); + } + Ok((which.to_string(), percent.clamp(0, 100))) +} + #[tauri::command] pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> { + let (which, percent) = charge_threshold_write(&which, percent)?; let Some((start, end)) = charge_threshold_paths() else { return Err("No charge threshold support on this hardware".into()); }; let path = if which == "start" { start } else { end }; - Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?; - Ok(()) + // GNU tee writes stdin to its path operands — the percent must be piped, + // not passed as a second path argument. + let input = format!("{percent}\n"); + if util::run_with_stdin(&["pkexec", "tee", &path.display().to_string()], &input).await { + Ok(()) + } else { + Err("Failed to set charge threshold".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn charge_threshold_clamps_and_restricts_which() { + assert_eq!( + charge_threshold_write("start", 80).unwrap(), + ("start".into(), 80) + ); + assert_eq!( + charge_threshold_write("end", 150).unwrap(), + ("end".into(), 100) + ); + assert_eq!( + charge_threshold_write("start", -5).unwrap(), + ("start".into(), 0) + ); + assert!(charge_threshold_write("both", 50).is_err()); + assert!(charge_threshold_write("-start", 50).is_err()); + } } diff --git a/src/src/commands/service.rs b/src/src/commands/service.rs index c99fee4..17a1540 100644 --- a/src/src/commands/service.rs +++ b/src/src/commands/service.rs @@ -21,6 +21,19 @@ pub enum ServiceAction { Restart, } +/// Units the frontend already hardcodes in ServiceControl call sites. +const ALLOWED_UNITS: &[&str] = &[ + "breadd.service", + "breadclipd.service", + "breadcrumbs.service", + "breadmill.service", + "breadbox-sync.service", +]; + +fn allowed_unit(unit: &str) -> bool { + ALLOWED_UNITS.contains(&unit) +} + async fn systemctl_active(unit: &str) -> bool { Command::new("systemctl") .args(["--user", "is-active", "--quiet", unit]) @@ -40,15 +53,21 @@ async fn systemctl_enabled(unit: &str) -> bool { } #[tauri::command] -pub async fn get_service_status(unit: String) -> ServiceStatus { - ServiceStatus { +pub async fn get_service_status(unit: String) -> Result { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } + Ok(ServiceStatus { active: systemctl_active(&unit).await, enabled: systemctl_enabled(&unit).await, - } + }) } #[tauri::command] pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } let verb = match action { ServiceAction::Start => "start", ServiceAction::Stop => "stop", @@ -70,8 +89,31 @@ pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), S /// panel, no reason to pull an open-ended `journalctl -f` tail into the /// webview. #[tauri::command] -pub fn open_logs(unit: String) { - let _ = std::process::Command::new("kitty") +pub fn open_logs(unit: String) -> Result<(), String> { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } + std::process::Command::new("kitty") .args(["-e", "journalctl", "--user", "-u", &unit, "-f"]) - .spawn(); + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units_match_frontend_hardcoded_list() { + assert!(allowed_unit("breadd.service")); + assert!(allowed_unit("breadclipd.service")); + assert!(allowed_unit("breadcrumbs.service")); + assert!(allowed_unit("breadmill.service")); + assert!(allowed_unit("breadbox-sync.service")); + assert!(!allowed_unit("sshd.service")); + assert!(!allowed_unit("breadd.service;reboot")); + assert!(!allowed_unit("../sshd.service")); + assert!(!allowed_unit("-u sshd")); + } } diff --git a/src/src/commands/users.rs b/src/src/commands/users.rs index d26ff59..8b0db82 100644 --- a/src/src/commands/users.rs +++ b/src/src/commands/users.rs @@ -3,9 +3,10 @@ //! `pkexec`. use serde::Serialize; -use tokio::io::AsyncWriteExt; use tokio::process::Command; +use super::util; + #[derive(Serialize, Clone)] pub struct Account { username: String, @@ -26,10 +27,16 @@ fn list_accounts() -> Vec { let shell = f[6]; // Real human accounts: normal UID range, a real login shell // (excludes system/service accounts like greeter, avahi, etc). - if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") { + if !(1000..60000).contains(&uid) + || shell.ends_with("nologin") + || shell.ends_with("/false") + { return None; } - Some(Account { username: f[0].to_string(), full_name: f[4].split(',').next().unwrap_or("").to_string() }) + Some(Account { + username: f[0].to_string(), + full_name: f[4].split(',').next().unwrap_or("").to_string(), + }) }) .collect() } @@ -42,29 +49,69 @@ pub struct UsersInfo { #[tauri::command] pub fn get_users_info() -> UsersInfo { - UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() } + UsersInfo { + accounts: list_accounts(), + current_user: std::env::var("USER").unwrap_or_default(), + } } -/// Runs a root command that needs a line of input on stdin (chpasswd's own -/// "user:password" format). `pkexec` inherits the spawning process's stdin -/// only when explicitly piped, so this pipes it through. -async fn run_with_stdin(args: &[&str], input: String) -> bool { - let Ok(mut child) = Command::new(args[0]).args(&args[1..]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() - else { +/// shadow-utils `USER_NAME_MAX` is 32; keep chpasswd/useradd operands inside it. +const USERNAME_MAX: usize = 32; + +/// `[a-z_][a-z0-9_-]*`, length-capped, no leading `-`. Also rejects `:`, +/// newlines, and other chpasswd field/line separators. +fn valid_username(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.is_empty() || bytes.len() > USERNAME_MAX { return false; - }; - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(input.as_bytes()).await.is_err() { - return false; - } } - child.wait().await.map(|s| s.success()).unwrap_or(false) + let first = bytes[0]; + if first != b'_' && !first.is_ascii_lowercase() { + return false; + } + bytes[1..] + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(*b, b'_' | b'-')) +} + +/// chpasswd reads `user:password` lines — a `:`, `\n`, or `\r` in either +/// field injects extra passwd entries or shifts columns. +fn valid_chpasswd_password(password: &str) -> bool { + !password.is_empty() + && password.len() <= 512 + && !password.contains('\n') + && !password.contains('\r') + && !password.contains(':') + && !password.contains('\0') +} + +fn chpasswd_input(username: &str, password: &str) -> Result { + if !valid_username(username) { + return Err("invalid username".into()); + } + if !valid_chpasswd_password(password) { + return Err("invalid password".into()); + } + Ok(format!("{username}:{password}\n")) +} + +fn may_delete_user(username: &str, current: &str) -> Result<(), String> { + if !valid_username(username) { + return Err("invalid username".into()); + } + if username == "root" { + return Err("refusing to remove root".into()); + } + if !current.is_empty() && username == current { + return Err("refusing to remove the current user".into()); + } + Ok(()) } #[tauri::command] pub async fn change_password(username: String, password: String) -> Result<(), String> { - let input = format!("{username}:{password}\n"); - if run_with_stdin(&["pkexec", "chpasswd"], input).await { + let input = chpasswd_input(&username, &password)?; + if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await { Ok(()) } else { Err("Failed to change password".into()) @@ -73,7 +120,13 @@ pub async fn change_password(username: String, password: String) -> Result<(), S #[tauri::command] pub async fn remove_user(username: String) -> Result<(), String> { - let output = Command::new("pkexec").args(["userdel", "-r", &username]).output().await.map_err(|e| e.to_string())?; + let current = std::env::var("USER").unwrap_or_default(); + may_delete_user(&username, ¤t)?; + let output = Command::new("pkexec") + .args(["userdel", "-r", &username]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -83,22 +136,68 @@ pub async fn remove_user(username: String) -> Result<(), String> { #[tauri::command] pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> { - let username = username.trim().to_string(); - let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()]; + let username = username.trim(); + let input = chpasswd_input(username, &password)?; + let mut useradd_args = vec![ + "pkexec".to_string(), + "useradd".to_string(), + "-m".to_string(), + "-s".to_string(), + "/bin/bash".to_string(), + ]; if !full_name.trim().is_empty() { useradd_args.push("-c".to_string()); useradd_args.push(full_name.trim().to_string()); } - useradd_args.push(username.clone()); + useradd_args.push(username.to_string()); let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect(); - let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?; + let output = Command::new(args_ref[0]) + .args(&args_ref[1..]) + .output() + .await + .map_err(|e| e.to_string())?; if !output.status.success() { return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); } - let input = format!("{username}:{password}\n"); - if run_with_stdin(&["pkexec", "chpasswd"], input).await { + if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await { Ok(()) } else { Err("User created, but setting the password failed.".into()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chpasswd_rejects_newline_injection() { + assert!(chpasswd_input("alice", "pw\nroot:evil").is_err()); + assert!(chpasswd_input("alice\nroot", "pw").is_err()); + assert!(chpasswd_input("alice\rroot", "pw").is_err()); + assert!(chpasswd_input("alice", "pw\rroot:x").is_err()); + assert!(chpasswd_input("al:ice", "pw").is_err()); + assert!(chpasswd_input("alice", "p:w").is_err()); + assert_eq!(chpasswd_input("alice", "secret").unwrap(), "alice:secret\n"); + } + + #[test] + fn username_grammar() { + assert!(valid_username("alice")); + assert!(valid_username("_svc")); + assert!(valid_username("a1-b_c")); + assert!(!valid_username("")); + assert!(!valid_username("-alice")); + assert!(!valid_username("Alice")); + assert!(!valid_username("root user")); + assert!(!valid_username(&"a".repeat(USERNAME_MAX + 1))); + } + + #[test] + fn remove_user_refuses_root_and_self() { + assert!(may_delete_user("root", "alice").is_err()); + assert!(may_delete_user("alice", "alice").is_err()); + assert!(may_delete_user("root\n", "alice").is_err()); + assert!(may_delete_user("bob", "alice").is_ok()); + } +} diff --git a/src/src/commands/util.rs b/src/src/commands/util.rs index 1acb283..d7a40d6 100644 --- a/src/src/commands/util.rs +++ b/src/src/commands/util.rs @@ -90,6 +90,30 @@ pub fn bos_settings_dir() -> PathBuf { config::config_dir().join("bos-settings") } +/// Pipe `input` to a command's stdin (`pkexec` does not inherit a piped +/// stdin unless we set it). Used by chpasswd and `pkexec tee`. +pub async fn run_with_stdin(args: &[&str], input: &str) -> bool { + if args.is_empty() { + return false; + } + let Ok(mut child) = tokio::process::Command::new(args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return false; + }; + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + if stdin.write_all(input.as_bytes()).await.is_err() { + return false; + } + } + child.wait().await.map(|s| s.success()).unwrap_or(false) +} + /// Atomic write with mode 0600 set on the new inode before/after replace, /// matching breadcrumbs' `networks.toml` care. pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> {