Harden privileged command operands against injection
All checks were successful
dev release / build (push) Successful in 3m2s

Validate chpasswd user:password fields, refuse deleting root or the
current user, and pipe charge-threshold writes through tee stdin.
Allowlist firewall rules, systemd units, AUR names, hostname, and
timezone. Keep CalDAV passwords write-only like restic.
This commit is contained in:
Breadway 2026-08-23 14:40:03 +08:00
parent 77b5a223d8
commit f7b114f778
10 changed files with 605 additions and 89 deletions

View file

@ -5,7 +5,6 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default",
"dialog:default" "dialog:default"
] ]
} }

View file

@ -23,8 +23,10 @@ fn os_pretty_name() -> String {
fs::read_to_string("/etc/os-release") fs::read_to_string("/etc/os-release")
.ok() .ok()
.and_then(|s| { .and_then(|s| {
s.lines() s.lines().find_map(|l| {
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string())) l.strip_prefix("PRETTY_NAME=")
.map(|v| v.trim_matches('"').to_string())
})
}) })
.unwrap_or_else(|| "BOS".to_string()) .unwrap_or_else(|| "BOS".to_string())
} }
@ -49,11 +51,15 @@ fn cpu() -> String {
let model = fs::read_to_string("/proc/cpuinfo") let model = fs::read_to_string("/proc/cpuinfo")
.ok() .ok()
.and_then(|s| { .and_then(|s| {
s.lines() s.lines().find_map(|l| {
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())) l.strip_prefix("model name")
.map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())
})
}) })
.unwrap_or_else(|| "unknown".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 { if cores > 0 {
format!("{model} ({cores} threads)") format!("{model} ({cores} threads)")
} else { } else {
@ -62,14 +68,12 @@ fn cpu() -> String {
} }
fn memory() -> String { fn memory() -> String {
let kb = fs::read_to_string("/proc/meminfo") let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| {
.ok() s.lines()
.and_then(|s| { .find(|l| l.starts_with("MemTotal:"))
s.lines() .and_then(|l| l.split_whitespace().nth(1))
.find(|l| l.starts_with("MemTotal:")) .and_then(|v| v.parse::<u64>().ok())
.and_then(|l| l.split_whitespace().nth(1)) });
.and_then(|v| v.parse::<u64>().ok())
});
match kb { match kb {
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0), Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
None => "unknown".to_string(), None => "unknown".to_string(),
@ -96,7 +100,11 @@ async fn gpu() -> String {
} }
async fn disk_usage() -> 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(); return "unknown".to_string();
}; };
let text = String::from_utf8_lossy(&output.stdout); 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] #[tauri::command]
pub async fn set_hostname(name: String) -> Result<(), String> { pub async fn set_hostname(name: String) -> Result<(), String> {
let name = name.trim(); let name = name.trim();
if name.is_empty() { if !valid_hostname(name) {
return Err("Hostname can't be empty".into()); return Err("invalid hostname".into());
} }
let output = Command::new("pkexec") let output = Command::new("pkexec")
.args(["hostnamectl", "set-hostname", name]) .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()) 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)));
}
}

View file

@ -10,6 +10,8 @@
use serde::Serialize; use serde::Serialize;
use super::util;
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct AurResult { pub struct AurResult {
name: String, name: String,
@ -19,7 +21,11 @@ pub struct AurResult {
#[tauri::command] #[tauri::command]
pub async fn search_aur(query: String) -> Vec<AurResult> { pub async fn search_aur(query: String) -> Vec<AurResult> {
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(); return Vec::new();
}; };
let text = String::from_utf8_lossy(&output.stdout); let text = String::from_utf8_lossy(&output.stdout);
@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
while let Some(header) = lines.next() { while let Some(header) = lines.next() {
// "aur/name version (+votes score) [Orphaned]" — name/version are // "aur/name version (+votes score) [Orphaned]" — name/version are
// always the first two whitespace-separated fields after "aur/". // 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 mut parts = rest.split_whitespace();
let Some(name) = parts.next() else { continue }; let Some(name) = parts.next() else { continue };
let version = parts.next().unwrap_or("").to_string(); let version = parts.next().unwrap_or("").to_string();
let description = lines.next().unwrap_or("").trim().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 { if results.len() >= 50 {
break; break;
} }
@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
} }
#[tauri::command] #[tauri::command]
pub fn install_aur_package(pkg: String) { pub fn install_aur_package(pkg: String) -> Result<(), String> {
let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn(); 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(())
} }

View file

@ -35,22 +35,35 @@ pub struct BreadpadConfig {
pub fn get_breadpad_config() -> BreadpadConfig { pub fn get_breadpad_config() -> BreadpadConfig {
let doc = config::load_doc(&config_path()); let doc = config::load_doc(&config_path());
BreadpadConfig { 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), workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]), 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(), model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).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_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_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), ollama_confidence_threshold: config::get_f64(
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()), &doc,
reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60), &["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_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(), calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
calendar_username: config::get_str(&doc, &["calendar", "username"]).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); let mut doc = config::load_doc(&path);
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type); config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag); 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_str_list(
config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days); &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", "path"], &cfg.model_path);
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_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_bool(
config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint); &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_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_f64(
config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning); &mut doc,
config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes); &["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_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", "url"], &cfg.calendar_url);
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username); 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()) 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")
);
}
}

View file

@ -26,7 +26,12 @@ async fn list_timezones() -> Vec<String> {
.output() .output()
.await .await
.ok() .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() .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] #[tauri::command]
pub async fn set_timezone(tz: String) -> Result<(), String> { 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") let output = Command::new("pkexec")
.args(["timedatectl", "set-timezone", &tz]) .args(["timedatectl", "set-timezone", tz])
.output() .output()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> { pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
let val = if enabled { "true" } else { "false" }; 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(()) 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"));
}
}

View file

@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() { return Err(if stderr.is_empty() {
match output.status.code() { 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}"), Some(code) => format!("pkexec exited with status {code}"),
None => "pkexec was terminated by a signal".to_string(), None => "pkexec was terminated by a signal".to_string(),
} }
@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
}); });
} }
let text = String::from_utf8_lossy(&output.stdout); 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 let rules = text
.lines() .lines()
.filter_map(|l| { .filter_map(|l| {
@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
} }
let (num, rest) = l.split_once(']')?; let (num, rest) = l.split_once(']')?;
let number = num.trim_start_matches('[').trim().to_string(); 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(); .collect();
Ok(FirewallStatus { active, rules }) Ok(FirewallStatus { active, rules })
@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
#[tauri::command] #[tauri::command]
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
let verb = if enabled { "enable" } else { "disable" }; 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() { if output.status.success() {
Ok(()) Ok(())
} else { } 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::<u16>() 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::<u32>().is_ok_and(|n| n > 0)
}
#[tauri::command] #[tauri::command]
pub async fn add_firewall_rule(rule: String) -> Result<(), String> { 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() { if output.status.success() {
Ok(()) Ok(())
} else { } else {
@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn remove_firewall_rule(number: String) -> Result<(), String> { 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() { if output.status.success() {
Ok(()) Ok(())
} else { } else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) 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(""));
}
}

View file

@ -7,13 +7,22 @@
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
use super::util;
async fn upower_device(kind: &str) -> Option<String> { async fn upower_device(kind: &str) -> Option<String> {
let out = Command::new("upower").arg("-e").output().await.ok()?; 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<String> { async fn upower_field(device: &str, field: &str) -> Option<String> {
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); let text = String::from_utf8_lossy(&out.stdout);
text.lines() text.lines()
.find(|l| l.trim_start().starts_with(field)) .find(|l| l.trim_start().starts_with(field))
@ -39,12 +48,18 @@ async fn battery_summary() -> Vec<(String, String)> {
if let Some(t) = t { if let Some(t) = t {
rows.push(("Time remaining".to_string(), t)); rows.push(("Time remaining".to_string(), t));
} }
let full: Option<f64> = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); let full: Option<f64> = upower_field(&bat, "energy-full")
let design: Option<f64> = .await
upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); .and_then(|v| v.split_whitespace().next()?.parse().ok());
let design: Option<f64> = 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 let (Some(full), Some(design)) = (full, design) {
if design > 0.0 { 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 rows
@ -64,12 +79,19 @@ async fn power_source() -> String {
async fn tlp_profile() -> Option<String> { async fn tlp_profile() -> Option<String> {
let out = Command::new("tlp-stat").arg("-s").output().await.ok()?; let out = Command::new("tlp-stat").arg("-s").output().await.ok()?;
let text = String::from_utf8_lossy(&out.stdout); 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<String> { async fn brightness_device() -> Option<String> {
let out = Command::new("brightnessctl").output().await.ok()?; 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<u32> { async fn brightness_pct() -> Option<u32> {
@ -98,7 +120,10 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)>
} }
fn read_threshold(path: &std::path::Path) -> i64 { 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)] #[derive(Serialize)]
@ -130,16 +155,57 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> {
return Err("No controllable backlight found".into()); return Err("No controllable backlight found".into());
}; };
let pct = format!("{percent}%"); 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(()) 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] #[tauri::command]
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> { 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 { let Some((start, end)) = charge_threshold_paths() else {
return Err("No charge threshold support on this hardware".into()); return Err("No charge threshold support on this hardware".into());
}; };
let path = if which == "start" { start } else { end }; 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())?; // GNU tee writes stdin to its path operands — the percent must be piped,
Ok(()) // 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());
}
} }

View file

@ -21,6 +21,19 @@ pub enum ServiceAction {
Restart, 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 { async fn systemctl_active(unit: &str) -> bool {
Command::new("systemctl") Command::new("systemctl")
.args(["--user", "is-active", "--quiet", unit]) .args(["--user", "is-active", "--quiet", unit])
@ -40,15 +53,21 @@ async fn systemctl_enabled(unit: &str) -> bool {
} }
#[tauri::command] #[tauri::command]
pub async fn get_service_status(unit: String) -> ServiceStatus { pub async fn get_service_status(unit: String) -> Result<ServiceStatus, String> {
ServiceStatus { if !allowed_unit(&unit) {
return Err("unknown service".into());
}
Ok(ServiceStatus {
active: systemctl_active(&unit).await, active: systemctl_active(&unit).await,
enabled: systemctl_enabled(&unit).await, enabled: systemctl_enabled(&unit).await,
} })
} }
#[tauri::command] #[tauri::command]
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> { pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
if !allowed_unit(&unit) {
return Err("unknown service".into());
}
let verb = match action { let verb = match action {
ServiceAction::Start => "start", ServiceAction::Start => "start",
ServiceAction::Stop => "stop", 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 /// panel, no reason to pull an open-ended `journalctl -f` tail into the
/// webview. /// webview.
#[tauri::command] #[tauri::command]
pub fn open_logs(unit: String) { pub fn open_logs(unit: String) -> Result<(), String> {
let _ = std::process::Command::new("kitty") if !allowed_unit(&unit) {
return Err("unknown service".into());
}
std::process::Command::new("kitty")
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"]) .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"));
}
} }

View file

@ -3,9 +3,10 @@
//! `pkexec`. //! `pkexec`.
use serde::Serialize; use serde::Serialize;
use tokio::io::AsyncWriteExt;
use tokio::process::Command; use tokio::process::Command;
use super::util;
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct Account { pub struct Account {
username: String, username: String,
@ -26,10 +27,16 @@ fn list_accounts() -> Vec<Account> {
let shell = f[6]; let shell = f[6];
// Real human accounts: normal UID range, a real login shell // Real human accounts: normal UID range, a real login shell
// (excludes system/service accounts like greeter, avahi, etc). // (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; 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() .collect()
} }
@ -42,29 +49,69 @@ pub struct UsersInfo {
#[tauri::command] #[tauri::command]
pub fn get_users_info() -> UsersInfo { 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 /// shadow-utils `USER_NAME_MAX` is 32; keep chpasswd/useradd operands inside it.
/// "user:password" format). `pkexec` inherits the spawning process's stdin const USERNAME_MAX: usize = 32;
/// only when explicitly piped, so this pipes it through.
async fn run_with_stdin(args: &[&str], input: String) -> bool { /// `[a-z_][a-z0-9_-]*`, length-capped, no leading `-`. Also rejects `:`,
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() /// newlines, and other chpasswd field/line separators.
else { fn valid_username(name: &str) -> bool {
let bytes = name.as_bytes();
if bytes.is_empty() || bytes.len() > USERNAME_MAX {
return false; 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<String, String> {
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] #[tauri::command]
pub async fn change_password(username: String, password: String) -> Result<(), String> { pub async fn change_password(username: String, password: String) -> Result<(), String> {
let input = format!("{username}:{password}\n"); let input = chpasswd_input(&username, &password)?;
if run_with_stdin(&["pkexec", "chpasswd"], input).await { if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
Ok(()) Ok(())
} else { } else {
Err("Failed to change password".into()) Err("Failed to change password".into())
@ -73,7 +120,13 @@ pub async fn change_password(username: String, password: String) -> Result<(), S
#[tauri::command] #[tauri::command]
pub async fn remove_user(username: String) -> Result<(), String> { 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, &current)?;
let output = Command::new("pkexec")
.args(["userdel", "-r", &username])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() { if output.status.success() {
Ok(()) Ok(())
} else { } else {
@ -83,22 +136,68 @@ pub async fn remove_user(username: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> { pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
let username = username.trim().to_string(); let username = username.trim();
let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()]; 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() { if !full_name.trim().is_empty() {
useradd_args.push("-c".to_string()); useradd_args.push("-c".to_string());
useradd_args.push(full_name.trim().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 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() { if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
} }
let input = format!("{username}:{password}\n"); if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
Ok(()) Ok(())
} else { } else {
Err("User created, but setting the password failed.".into()) 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());
}
}

View file

@ -90,6 +90,30 @@ pub fn bos_settings_dir() -> PathBuf {
config::config_dir().join("bos-settings") 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, /// Atomic write with mode 0600 set on the new inode before/after replace,
/// matching breadcrumbs' `networks.toml` care. /// matching breadcrumbs' `networks.toml` care.
pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> { pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> {