bread-theme/bread-utils: XDG env hardening and safer temp-file writes
- `XDG_CONFIG_HOME` / `XDG_RUNTIME_DIR` are honoured only when absolute (XDG spec; matches `bread_utils::xdg`), in layerrules and output. - `output::atomic_write` pid-suffixes its temp name (`.<name>.tmp.<pid>`) so two concurrent `bread-theme generate-output` runs writing the same `themes/<output>.css` can't race on one shared `.tmp`. - `hypr::socket_path` reconstructs `/run/user/<uid>` from the process's real uid when `XDG_RUNTIME_DIR` is unset, instead of assuming 1000. - `bread-theme --help` prints to stdout (pipeable); usage on an error path (missing args) goes to stderr.
This commit is contained in:
parent
2affeabce0
commit
aa326c4ecb
4 changed files with 102 additions and 22 deletions
|
|
@ -29,8 +29,9 @@ fn write_and_report(verb: &str) -> ExitCode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help_to(mut w: impl std::io::Write) {
|
||||||
eprintln!(
|
let _ = write!(
|
||||||
|
&mut w,
|
||||||
"bread-theme — shared stylesheet generator\n\n\
|
"bread-theme — shared stylesheet generator\n\n\
|
||||||
USAGE:\n\
|
USAGE:\n\
|
||||||
\x20 bread-theme [generate|reload|path|print|layerrules]\n\
|
\x20 bread-theme [generate|reload|path|print|layerrules]\n\
|
||||||
|
|
@ -54,19 +55,29 @@ fn print_help() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Usage/help text. Explicitly requested help (`--help`/`-h`) goes to
|
||||||
|
/// **stdout** so it can be piped/grepped; the same text on an error path
|
||||||
|
/// (e.g. `generate-output` with no args) goes to stderr via
|
||||||
|
/// [`print_help_err`].
|
||||||
|
fn print_help() {
|
||||||
|
print_help_to(std::io::stdout());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help_err() {
|
||||||
|
print_help_to(std::io::stderr());
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_output_cmd() -> ExitCode {
|
fn generate_output_cmd() -> ExitCode {
|
||||||
let args: Vec<String> = std::env::args().skip(2).collect();
|
let args: Vec<String> = std::env::args().skip(2).collect();
|
||||||
if args.is_empty()
|
if args.iter().any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) {
|
||||||
|| args
|
|
||||||
.iter()
|
|
||||||
.any(|a| matches!(a.as_str(), "-h" | "--help" | "help"))
|
|
||||||
{
|
|
||||||
print_help();
|
print_help();
|
||||||
return if args.is_empty() {
|
return ExitCode::SUCCESS;
|
||||||
ExitCode::FAILURE
|
}
|
||||||
} else {
|
if args.is_empty() {
|
||||||
ExitCode::SUCCESS
|
// Missing arguments is an error, not a help request — usage goes to
|
||||||
};
|
// stderr.
|
||||||
|
print_help_err();
|
||||||
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = args[0].as_str();
|
let output = args[0].as_str();
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ use std::path::PathBuf;
|
||||||
use crate::shell::ShellTheme;
|
use crate::shell::ShellTheme;
|
||||||
|
|
||||||
fn config_home() -> PathBuf {
|
fn config_home() -> PathBuf {
|
||||||
|
// XDG spec: `XDG_CONFIG_HOME` is only honored when it's an *absolute*
|
||||||
|
// path; a relative value must be ignored (matches `bread_utils::xdg`).
|
||||||
if let Ok(v) = std::env::var("XDG_CONFIG_HOME") {
|
if let Ok(v) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
if !v.is_empty() {
|
let p = PathBuf::from(&v);
|
||||||
return PathBuf::from(v);
|
if p.is_absolute() {
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
|
|
@ -110,6 +113,32 @@ mod tests {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if any file under `dir` has a name containing `.tmp.` (a
|
||||||
|
/// leftover from `output::atomic_write`'s pid-suffixed temp files).
|
||||||
|
fn has_leftover_tmp(dir: &Path) -> bool {
|
||||||
|
fn walk(p: &Path) -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir(p) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if walk(&path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.contains(".tmp."))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
walk(dir)
|
||||||
|
}
|
||||||
|
|
||||||
/// `load_named("liquid-motion")` resolves through discovery (user dir,
|
/// `load_named("liquid-motion")` resolves through discovery (user dir,
|
||||||
/// then system dir, then the compiled-in builtin) — isolate
|
/// then system dir, then the compiled-in builtin) — isolate
|
||||||
/// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real
|
/// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real
|
||||||
|
|
@ -166,7 +195,7 @@ mod tests {
|
||||||
assert_eq!(path, dir.join("hypr").join("layerrules.json"));
|
assert_eq!(path, dir.join("hypr").join("layerrules.json"));
|
||||||
assert!(path.is_file());
|
assert!(path.is_file());
|
||||||
// No leftover .tmp file after the atomic rename.
|
// No leftover .tmp file after the atomic rename.
|
||||||
assert!(!path.with_file_name("layerrules.json.tmp").exists());
|
assert!(!has_leftover_tmp(dir));
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(&path).unwrap();
|
let contents = std::fs::read_to_string(&path).unwrap();
|
||||||
let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
||||||
|
|
@ -176,7 +205,7 @@ mod tests {
|
||||||
// leave a stale temp file behind.
|
// leave a stale temp file behind.
|
||||||
let path2 = write_layerrules_active().unwrap();
|
let path2 = write_layerrules_active().unwrap();
|
||||||
assert_eq!(path, path2);
|
assert_eq!(path, path2);
|
||||||
assert!(!path.with_file_name("layerrules.json.tmp").exists());
|
assert!(!has_leftover_tmp(dir));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@ use crate::{load_palette, stylesheet};
|
||||||
|
|
||||||
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
||||||
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
||||||
|
// XDG spec: `XDG_RUNTIME_DIR` is only honored when set to a non-empty,
|
||||||
|
// *absolute* path; a relative value must be ignored.
|
||||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
if !rt.is_empty() {
|
let p = PathBuf::from(&rt);
|
||||||
return PathBuf::from(rt).join("bread");
|
if !rt.is_empty() && p.is_absolute() {
|
||||||
|
return p.join("bread");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dirs::cache_dir()
|
dirs::cache_dir()
|
||||||
|
|
@ -58,9 +61,13 @@ pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
// Pad the temp name with the pid (matching `bread_utils::atomic`) so two
|
||||||
|
// concurrent writers for the same target can't race on one shared `.tmp`
|
||||||
|
// file — e.g. two `bread-theme generate-output` runs writing the same
|
||||||
|
// `themes/<output>.css` at once.
|
||||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
||||||
Some(name) => path.with_file_name(format!("{name}.tmp")),
|
Some(name) => path.with_file_name(format!(".{name}.tmp.{}", std::process::id())),
|
||||||
None => path.with_extension("tmp"),
|
None => path.with_extension(format!("tmp.{}", std::process::id())),
|
||||||
};
|
};
|
||||||
std::fs::write(&tmp, contents)?;
|
std::fs::write(&tmp, contents)?;
|
||||||
std::fs::rename(&tmp, path)?;
|
std::fs::rename(&tmp, path)?;
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,17 @@ pub enum Socket {
|
||||||
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
||||||
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
||||||
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
||||||
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
|
/// `/run/user/<uid>` (this process's real uid, or the historical
|
||||||
|
/// `/run/user/1000` if that can't be read) when unset.
|
||||||
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
||||||
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||||
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
// `XDG_RUNTIME_DIR` is normally `/run/user/<uid>`; when it's unset,
|
||||||
|
// reconstruct that from the process's real uid instead of assuming uid
|
||||||
|
// 1000, so the socket path is right for any account.
|
||||||
|
let rt = env::var("XDG_RUNTIME_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(fallback_runtime_dir);
|
||||||
let file = match kind {
|
let file = match kind {
|
||||||
Socket::Request => ".socket.sock",
|
Socket::Request => ".socket.sock",
|
||||||
Socket::Events => ".socket2.sock",
|
Socket::Events => ".socket2.sock",
|
||||||
|
|
@ -47,6 +54,21 @@ pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
||||||
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `XDG_RUNTIME_DIR`'s conventional `/run/user/<uid>` value, derived from
|
||||||
|
/// this process's real uid (`Uid:` line in `/proc/self/status`). Falls back
|
||||||
|
/// to the historical `/run/user/1000` if that can't be read.
|
||||||
|
fn fallback_runtime_dir() -> String {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
||||||
|
for line in status.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("Uid:") {
|
||||||
|
if let Some(uid) = rest.split_whitespace().next() {
|
||||||
|
return format!("/run/user/{uid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/run/user/1000".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
||||||
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
||||||
/// matches every current consumer (breadbox, breadclip), which call it from
|
/// matches every current consumer (breadbox, breadclip), which call it from
|
||||||
|
|
@ -222,6 +244,17 @@ mod tests {
|
||||||
assert!(win.fullscreen.is_fullscreen());
|
assert!(win.fullscreen.is_fullscreen());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_runtime_dir_is_user_uid_shaped() {
|
||||||
|
let dir = fallback_runtime_dir();
|
||||||
|
assert!(dir.starts_with("/run/user/"), "got {dir}");
|
||||||
|
let uid = dir.trim_start_matches("/run/user/");
|
||||||
|
assert!(
|
||||||
|
!uid.is_empty() && uid.chars().all(|c| c.is_ascii_digit()),
|
||||||
|
"fallback uid is not numeric: {uid}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Both env-var-dependent cases share one test function: `set_var`/
|
// Both env-var-dependent cases share one test function: `set_var`/
|
||||||
// `remove_var` are process-global, and cargo runs tests in parallel
|
// `remove_var` are process-global, and cargo runs tests in parallel
|
||||||
// threads by default, so two separate #[test] fns racing on the same
|
// threads by default, so two separate #[test] fns racing on the same
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue