Promote util::run to bread_utils::proc; delegate write_atomic

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.
This commit is contained in:
Breadway 2026-07-17 09:35:23 +08:00
parent b786060517
commit 59416fb273
3 changed files with 191 additions and 140 deletions

176
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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.