From 59416fb273d20313e236e03c0f839a371192534f Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 09:35:23 +0800 Subject: [PATCH] Promote util::run to bread_utils::proc; delegate write_atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This crate's util::run/run_with_stdin/run_ok/Output was cited as the one correct, timeout-guarded subprocess implementation in the ecosystem worth sharing — it's now promoted verbatim into bread-utils and re-exported here so every existing crate::util::{run, ...} call site (notify.rs, nm.rs, status.rs, tailscale.rs) keeps working unchanged. write_atomic similarly delegates to bread_utils::atomic::write_atomic (path dependency for now, see the TODO in Cargo.toml). home_dir/command_exists/timestamp stay local — they're breadcrumbs-specific, not duplicated elsewhere. New worktree for this repo (none existed from tonight's earlier passes, since breadcrumbs needed no audit fixes then). Builds clean; all 74 unit tests + 25 CLI integration tests pass. --- Cargo.lock | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + src/util.rs | 153 +++++---------------------------------------- 3 files changed, 191 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 222284d..0efeaea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,19 +49,35 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "bread-utils" +version = "0.2.3" +dependencies = [ + "dirs", + "serde", + "serde_json", ] [[package]] name = "breadcrumbs" version = "2.1.5" dependencies = [ + "bread-utils", "clap", "serde", "serde_json", "toml", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -108,12 +124,44 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -148,6 +196,21 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" @@ -160,6 +223,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -178,6 +247,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "serde" version = "1.0.228" @@ -247,6 +327,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.23" @@ -300,12 +400,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -315,6 +430,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index b62a77a..25c0424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" serde_json = "1" +# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern +bread-utils = { path = "../bread-ecosystem-fix-worktree/bread-utils" } [profile.release] opt-level = "s" diff --git a/src/util.rs b/src/util.rs index db9bc8d..ffd63d2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,9 +1,5 @@ -use std::fs; -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub fn home_dir() -> PathBuf { std::env::var_os("HOME") @@ -16,35 +12,13 @@ pub fn home_dir() -> PathBuf { /// concurrent reader (the watch daemon reloads config every tick) and never /// leaves a half-written file behind on crash. Because the temp file is created /// with `mode` up front, secrets never exist world-readable even briefly. +/// +/// Delegates to `bread_utils::atomic::write_atomic` — this crate's own +/// version of this function was promoted verbatim into the shared +/// `bread-utils` crate (see that crate's `atomic` module doc comment) as +/// the one implementation in the ecosystem that already got this right. pub fn write_atomic(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> { - let dir = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(dir)?; - let stem = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("breadcrumbs"); - let tmp = dir.join(format!(".{stem}.tmp.{}", std::process::id())); - - let mut open = fs::OpenOptions::new(); - open.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - open.mode(mode); - } - #[cfg(not(unix))] - let _ = mode; - - let res = (|| { - let mut f = open.open(&tmp)?; - f.write_all(contents.as_bytes())?; - f.sync_all()?; - fs::rename(&tmp, path) - })(); - if res.is_err() { - let _ = fs::remove_file(&tmp); - } - res + bread_utils::atomic::write_atomic(path, contents, Some(mode)) } pub fn command_exists(name: &str) -> bool { @@ -58,111 +32,14 @@ pub fn command_exists(name: &str) -> bool { false } -#[derive(Debug, Clone)] -pub struct Output { - pub success: bool, - pub stdout: String, - pub stderr: String, -} - -impl Output { - pub fn failed() -> Output { - Output { - success: false, - stdout: String::new(), - stderr: String::new(), - } - } -} - -/// Run a command with a hard timeout. The child is killed if it overruns so a -/// hung nmcli/tailscale can never wedge the daemon. -pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { - run_with_stdin(prog, args, None, timeout) -} - -/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand -/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv, -/// where any local user could read them via `ps`. -pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { - let stdin_cfg = if stdin.is_some() { - Stdio::piped() - } else { - Stdio::null() - }; - let mut child = match Command::new(prog) - .args(args) - // Pin the C locale so message text we parse (nmcli states, monitor - // lines) is stable English regardless of the user's LANG. SSID/value - // bytes are unaffected. - .env("LC_ALL", "C") - .env("LANG", "C") - .stdin(stdin_cfg) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(_) => return Output::failed(), - }; - - let mut stdout_pipe = child.stdout.take(); - let mut stderr_pipe = child.stderr.take(); - - let out_handle = thread::spawn(move || { - let mut buf = String::new(); - if let Some(ref mut p) = stdout_pipe { - let _ = p.read_to_string(&mut buf); - } - buf - }); - let err_handle = thread::spawn(move || { - let mut buf = String::new(); - if let Some(ref mut p) = stderr_pipe { - let _ = p.read_to_string(&mut buf); - } - buf - }); - - // Feed stdin only after the reader threads are draining stdout/stderr, so a - // child that writes more than a pipe buffer before consuming stdin can't - // deadlock against our blocking write. - if let Some(data) = stdin { - if let Some(mut sink) = child.stdin.take() { - let _ = sink.write_all(data.as_bytes()); - // Drop closes the pipe so the child's read sees EOF. - } - } - - let start = Instant::now(); - let status = loop { - match child.try_wait() { - Ok(Some(s)) => break Some(s), - Ok(None) => { - if start.elapsed() >= timeout { - let _ = child.kill(); - let _ = child.wait(); - break None; - } - thread::sleep(Duration::from_millis(50)); - } - Err(_) => break None, - } - }; - - let stdout = out_handle.join().unwrap_or_default(); - let stderr = err_handle.join().unwrap_or_default(); - - Output { - success: status.map(|s| s.success()).unwrap_or(false), - stdout, - stderr, - } -} - -pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { - run(prog, args, timeout).success -} +// `Output`, `run`, `run_with_stdin`, and `run_ok` used to be implemented +// directly in this module; that implementation was promoted verbatim into +// `bread_utils::proc` as the shared timeout-guarded subprocess runner for +// the whole ecosystem (several sibling repos shelled out with no timeout at +// all). Re-exported here so every existing `crate::util::{run, ...}` call +// site in this crate keeps working unchanged. +#[allow(unused_imports)] // Output isn't named directly elsewhere in this crate, but stays part of this module's public API. +pub use bread_utils::proc::{run, run_ok, run_with_stdin, Output}; /// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling /// back to a dependency-free UTC computation if it is unavailable.