Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b114f778 | ||
|
|
77b5a223d8 | ||
|
|
a6cb245ae4 |
13 changed files with 909 additions and 270 deletions
456
src/Cargo.lock
generated
456
src/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bos-settings"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
description = "System settings app for BOS (Bread Operating System)"
|
||||
authors = ["Breadway"]
|
||||
edition = "2021"
|
||||
|
|
@ -36,7 +36,7 @@ toml_edit = "0.22"
|
|||
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
|
||||
notify = "7"
|
||||
regex = "1"
|
||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
|
||||
anyhow = "1"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<u64>().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::<u64>().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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
};
|
||||
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() {
|
||||
// "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<AurResult> {
|
|||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ async fn list_timezones() -> Vec<String> {
|
|||
.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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
|||
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<FirewallStatus, String> {
|
|||
});
|
||||
}
|
||||
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<FirewallStatus, String> {
|
|||
}
|
||||
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<FirewallStatus, String> {
|
|||
#[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::<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]
|
||||
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(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,22 @@
|
|||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util;
|
||||
|
||||
async fn upower_device(kind: &str) -> Option<String> {
|
||||
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> {
|
||||
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<f64> = upower_field(&bat, "energy-full").await.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());
|
||||
let full: Option<f64> = upower_field(&bat, "energy-full")
|
||||
.await
|
||||
.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 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<String> {
|
||||
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<String> {
|
||||
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> {
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ServiceStatus, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,26 @@ use tauri::{AppHandle, Emitter, Manager};
|
|||
|
||||
/// Initial theme fetch — called once by the frontend at startup.
|
||||
#[tauri::command]
|
||||
pub fn get_theme_css() -> String {
|
||||
render_theme_css()
|
||||
pub fn get_theme_css(window: tauri::WebviewWindow) -> String {
|
||||
render_theme_css(&palette_for_window(&window))
|
||||
}
|
||||
|
||||
fn render_theme_css() -> String {
|
||||
let palette = bread_theme::load_palette();
|
||||
fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette {
|
||||
window
|
||||
.current_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|m| m.name().map(|s| s.to_string()))
|
||||
.map(|name| bread_theme::load_palette_for(&name))
|
||||
.unwrap_or_else(bread_theme::load_palette)
|
||||
}
|
||||
|
||||
fn render_theme_css(palette: &bread_theme::Palette) -> String {
|
||||
// bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the
|
||||
// later css_custom_properties / css_tokens helpers (those landed after
|
||||
// the tag). Emit the same :root custom-property names the Svelte app
|
||||
// already uses so a tag pin doesn't require a web-side rename.
|
||||
format!("{}\n{}", css_custom_properties(&palette), css_tokens())
|
||||
format!("{}\n{}", css_custom_properties(palette), css_tokens())
|
||||
}
|
||||
|
||||
fn css_custom_properties(p: &bread_theme::Palette) -> String {
|
||||
|
|
@ -103,7 +112,11 @@ pub fn watch_and_emit(app: &AppHandle) {
|
|||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
) && event.paths.iter().any(|p| p == &target_for_watcher);
|
||||
if touches_target {
|
||||
let _ = app_for_watcher.emit("theme-changed", render_theme_css());
|
||||
let css = app_for_watcher
|
||||
.get_webview_window("main")
|
||||
.map(|w| render_theme_css(&palette_for_window(&w)))
|
||||
.unwrap_or_else(|| render_theme_css(&bread_theme::load_palette()));
|
||||
let _ = app_for_watcher.emit("theme-changed", css);
|
||||
}
|
||||
},
|
||||
notify::Config::default(),
|
||||
|
|
|
|||
|
|
@ -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<Account> {
|
|||
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<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]
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue