8000 feat(minimal,diagnostics): add the min bug host diagnostic bundle command by norrietaylor · Pull Request #816 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/diagnostics/Cargo.toml
Original file line number Diff line number Diff line change
8000 Expand Up @@ -15,6 +15,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
walkdir.workspace = true

[target.'cfg(unix)'.dependencies]
Expand Down
57 changes: 33 additions & 24 deletions crates/diagnostics/src/bundle.rs
8000
Original file line number Diff line number Diff line change
Expand Up @@ -141,30 +141,7 @@ impl<W: BundleSink> BundleWriter<W> {
src: &Path,
cap: u64,
) -> Result<(), anyhow::Error> {
// Open without following symlinks: a symlink planted in a tool-owned
// dir must not steer an unrelated target (a host key, another user's
// log) into a bundle the user then shares. O_NOFOLLOW refuses the
// link at open time — no check-then-open window to race. (It guards
// the final component only; intermediate directories are the tool's
// own, per the threat model.)
let mut opts = tokio::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
opts.custom_flags(libc::O_NOFOLLOW);
let mut file = opts
.open(src)
.await
.with_context(|| format!("opening {}", src.display()))?;
// fstat the descriptor we actually read from: rejects directories and
// devices, and doubles as the symlink guard where O_NOFOLLOW is
// unavailable.
let meta = file
.metadata()
.await
.with_context(|| format!("statting {}", src.display()))?;
if !meta.is_file() {
anyhow::bail!("{} is not a regular file", src.display());
}
let (mut file, meta) = open_regular_nofollow(src).await?;
let len = meta.len();
let capped = len > cap;
if capped {
Expand Down Expand Up @@ -269,6 +246,38 @@ impl<W: BundleSink> BundleWriter<W> {
}
}

/// Opens `src` for reading without following symlinks and verifies on the
/// descriptor that it is a regular file — the shared no-follow discipline
/// for every content read that ends up in a bundle.
///
/// A symlink planted in a tool-owned dir must not steer an unrelated target
/// (a host key, another user's log) into a bundle the user then shares.
/// O_NOFOLLOW refuses the link at open time — no check-then-open window to
/// race — and the fstat rejects directories and devices, doubling as the
/// symlink guard where O_NOFOLLOW is unavailable. (It guards the final
/// component only; intermediate directories are the tool's own, per the
/// threat model.)
pub async fn open_regular_nofollow(
src: &Path,
) -> Result<(tokio::fs::File, std::fs::Metadata), anyhow::Error> {
let mut opts = tokio::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
opts.custom_flags(libc::O_NOFOLLOW);
let file = opts
.open(src)
.await
.with_context(|| format!("opening {}", src.display()))?;
let meta = file
.metadata()
.await
.with_context(|| format!("statting {}", src.display()))?;
if !meta.is_file() {
anyhow::bail!("{} is not a regular file", src.display());
}
Ok((file, meta))
}

#[cfg(test)]
pub(crate) mod tests {
use super::*;
Expand Down
31 changes: 31 additions & 0 deletions crates/diagnostics/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ pub async fn command_capture(
})
}

/// First line of `cmd args…` stdout, or `None` when the command can't run,
/// fails, or times out. The one-liner for collectors that want a single
/// fact from a tool (`uname -srvm`) and treat any failure as "unknown".
pub async fn first_stdout_line(cmd: &str, args: &[&str], timeout: Duration) -> Option<String> {
let capture = command_capture(cmd, args, timeout).await.ok()?;
capture.status.is_some_and(|s| s.success()).then(|| {
String::from_utf8_lossy(&capture.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string()
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -154,4 +168,21 @@ mod tests {
.unwrap_err();
assert!(matches!(err, CaptureError::Spawn { .. }), "got: {err}");
}

#[tokio::test]
async fn first_stdout_line_takes_line_one_and_maps_failure_to_none() {
let line = first_stdout_line(
"sh",
&["-c", "echo first; echo second"],
Duration::from_secs(10),
)
.await;
assert_eq!(line.as_deref(), Some("first"));

let failed = first_stdout_line("sh", &["-c", "exit 1"], Duration::from_secs(10)).await;
assert_eq!(failed, None, "non-zero exit is None, not empty");
let missing =
first_stdout_line("definitely-not-a-real-binary", &[], Duration::from_secs(1)).await;
assert_eq!(missing, None);
}
}
51 changes: 51 additions & 0 deletions crates/diagnostics/src/disk.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Filesystem capacity probes for diagnostic bundles.
//!
//! Disk exhaustion is a perennial root cause; every bundle producer wants
//! "how full is the filesystem holding X" without shelling out to `df`.

use std::path::Path;

/// Capacity of the filesystem holding a path.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct DiskUsage {
pub total_bytes: u64,
/// Bytes available to an unprivileged caller (`f_bavail`, not
/// `f_bfree`) — the number that predicts the next write failing.
pub free_bytes: u64,
}

/// The capacity of the filesystem holding `path`, or `None` when the path
/// doesn't exist or can't be statted — for a collector, "unknown" is data.
pub fn disk_usage(path: &Path) -> Option<DiskUsage> {
use std::os::unix::ffi::OsStrExt as _;
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: valid NUL-terminated path and out-pointer to a zeroed struct.
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
return None;
}
let frsize = stat.f_frsize as u64;
Some(DiskUsage {
total_bytes: stat.f_blocks as u64 * frsize,
free_bytes: stat.f_bavail as u64 * frsize,
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn real_path_reports_nonzero_capacity() {
let tmp = tempfile::TempDir::new().unwrap();
let usage = disk_usage(tmp.path()).expect("tempdir filesystem is stattable");
assert!(usage.total_bytes > 0);
assert!(usage.free_bytes <= usage.total_bytes);
}

#[test]
fn missing_path_is_none() {
assert!(disk_usage(Path::new("/nonexistent/diag-disk-probe")).is_none());
}
}
25 changes: 21 additions & 4 deletions crates/diagnostics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,36 @@
//! through, file-backed (CLI) or streaming (daemon), which keeps the
//! manifest complete by construction
//! - [`manifest`] — the bundle's self-describing manifest types
//! - [`redact`] — key-based masking of secret-shaped values, applied
//! identically by every producer
//! - [`redact`] — key-based masking of secret-shaped values (JSON, TOML,
//! and the process environment), applied identically by every producer
//! - [`listing`] — bounded recursive directory listing (names/sizes/metadata)
//! - [`capture`] — bounded subprocess output capture for command-shaped
//! collectors
//! - [`logs`] — newest-first selection of rotated log files
//! - [`disk`] — filesystem capacity probes
//! - [`system`] — host identity/capability probe (OS, kernel, KVM, disks)
//!
//! The split with the app crates is mechanics vs. policy: the *how* lives
//! here; the *what* — which paths, which log prefixes, which env names are
//! allowlisted, where entries land in the archive — stays with the caller.

pub mod bundle;
pub mod capture;
#[cfg(unix)]
pub mod disk;
pub mod listing;
pub mod logs;
pub mod manifest;
pub mod redact;
#[cfg(unix)]
pub mod system;

pub use bundle::{BundleSink, BundleWriter, LOG_TAIL_CAP};
pub use capture::{Capture, CaptureError, command_capture};
pub use bundle::{BundleSink, BundleWriter, LOG_TAIL_CAP, open_regular_nofollow};
pub use capture::{Capture, CaptureError, command_capture, first_stdout_line};
#[cfg(unix)]
pub use disk::{DiskUsage, disk_usage};
pub use listing::{Listing, listing};
pub use logs::newest_rotated;
pub use manifest::{CollectedEntry, CollectorError, Manifest, Redaction, SkippedEntry};
#[cfg(unix)]
pub use system::{DiskInfo, SystemInfo, system_info};
4 changes: 4 additions & 0 deletions crates/diagnostics/src/listing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub struct Listing {
/// explicit trailing truncation marker. The walk is iterative (walkdir), so a
/// pathologically deep tree cannot blow the stack, and symlinks are never
/// followed.
///
/// The walk is synchronous. Async callers must wrap it in
/// `tokio::task::spawn_blocking`, or a wedged filesystem blocks the worker
/// thread and their timeouts never fire.
pub fn listing(root: &Path, max_entries: usize) -> Result<Listing, anyhow::Error> {
use std::fmt::Write as _;
use std::os::unix::fs::MetadataExt as _;
Expand Down
110 changes: 110 additions & 0 deletions crates/diagnostics/src/logs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! Selection of rotated log files for diagnostic bundles.

use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// The newest `limit` files in `dir` whose names start with `prefix`,
/// newest first.
///
/// "Newest" is modified-time order, which is rotation-scheme agnostic: it is
/// correct for date-suffixed rolling names (`app.log.2026-07-15`) and for
/// logrotate-style numeric shifting (`app.log` live, `app.log.1` the most
/// recently rotated) alike — filename order is not, since the two schemes
/// sort in opposite directions. Name order (descending) breaks ties for
/// determinism. A missing or unreadable `dir` yields an empty list — callers
/// that need to distinguish "no log dir" from "no matches" check the
/// directory first.
pub async fn newest_rotated(dir: &Path, prefix: &str, limit: usize) -> Vec<PathBuf> {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return Vec::new();
};
let mut matches: Vec<(SystemTime, PathBuf)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(prefix))
{
let mtime = entry
.metadata()
.await
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
matches.push((mtime, path));
}
}
matches.sort_by(|(at, ap), (bt, bp)| bt.cmp(at).then_with(|| bp.cmp(ap)));
matches.truncate(limit);
matches.into_iter().map(|(_, path)| path).collect()
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;

fn write_with_mtime(path: &Path, age_secs: u64) {
let file = std::fs::File::create(path).unwrap();
file.set_modified(SystemTime::now() - Duration::from_secs(age_secs))
.unwrap();
}

#[tokio::test]
async fn newest_first_capped_and_prefix_filtered() {
let tmp = tempfile::TempDir::new().unwrap();
for day in 1..=7 {
// Older dates get older mtimes, as rotation produces them.
write_with_mtime(
&tmp.path().join(format!("app.log.2026-07-{day:02}")),
(8 - day) * 100,
);
}
write_with_mtime(&tmp.path().join("other.log.2026-07-09"), 1);

let picked = newest_rotated(tmp.path(), "app.log", 5).await;
let names: Vec<&str> = picked
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(
names,
[
"app.log.2026-07-07",
"app.log.2026-07-06",
"app.log.2026-07-05",
"app.log.2026-07-04",
"app.log.2026-07-03",
],
"newest five, other prefixes excluded"
);
}

#[tokio::test]
async fn logrotate_style_numeric_suffixes_order_by_recency() {
let tmp = tempfile::TempDir::new().unwrap();
// logrotate-style shift: the live file is newest, `.1` the most
// recently rotated, higher suffixes older. Reverse filename order
// would return exactly the wrong end of this list.
write_with_mtime(&tmp.path().join("app.log"), 0);
write_with_mtime(&tmp.path().join("app.log.1"), 100);
write_with_mtime(&tmp.path().join("app.log.2"), 200);
write_with_mtime(&tmp.path().join("app.log.10"), 1000);

let picked = newest_rotated(tmp.path(), "app.log", 3).await;
let names: Vec<&str> = picked
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, ["app.log", "app.log.1", "app.log.2"]);
}

#[tokio::test]
async fn missing_dir_is_empty() {
assert!(
newest_rotated(Path::new("/nonexistent/diag-log-dir"), "app.log", 5)
.await
.is_empty()
);
}
}
Loading
0