hive-priv: filter/trim before counting output-path lines, fix log level

This commit is contained in:
damocles 2026-08-28 18:37:08 +02:00
commit b35063cf46
2 changed files with 67 additions and 26 deletions

View file

@ -82,8 +82,11 @@ the end — forwarding it the same way would risk interleaving a progress
line into the value this function hands back as `--system-path`, trading line into the value this function hands back as `--system-path`, trading
a closure-mixup bug for a corrupted-argument one. So stdout lines are a closure-mixup bug for a corrupted-argument one. So stdout lines are
accumulated silently and only consulted after the exit status is known accumulated silently and only consulted after the exit status is known
to be success — and even then, exactly one line is required (`nix build to be success — and even then, exactly one non-empty, trimmed line is
--print-out-paths` prints one line *per output*, not one line total; required (`nix build --print-out-paths` prints one line *per output*,
`config.system.build.toplevel` is single-output today, but a bare not one line total; `config.system.build.toplevel` is single-output
`.trim()` would silently hand a multi-line string to `--system-path` the today, but a bare whole-buffer `.trim()` would silently hand a
day that ever changes, so a wrong line count `bail!`s instead). multi-line string to `--system-path` the day that ever changes, and a
bare untrimmed/unfiltered `.lines()` turns a lone `"\n"` into a bogus
empty-string "path" — a wrong line count, or a blank/whitespace one,
`bail!`s instead).

View file

@ -20,7 +20,7 @@
use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd}; use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use hive_priv_sock::{ use hive_priv_sock::{
AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, AgentTmpfilesEntry, BindMount, AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, AgentTmpfilesEntry, BindMount,
CredentialMount, InfraAction, InfraContainer, JournalQuery, META_DIR, MIGRATE_STAGING_ROOT, CredentialMount, InfraAction, InfraContainer, JournalQuery, META_DIR, MIGRATE_STAGING_ROOT,
@ -747,7 +747,7 @@ async fn nix_build_toplevel(name: &str, mut writer: Option<&mut OwnedWriteHalf>)
// Streamed as it arrives — the progress the dashboard // Streamed as it arrives — the progress the dashboard
// and `journalctl -f` were missing. // and `journalctl -f` were missing.
Ok(Some(l)) => { Ok(Some(l)) => {
tracing::warn!(target: "nix-build-toplevel", "{l}"); tracing::info!(target: "nix-build-toplevel", "{l}");
if let Some(w) = writer.as_deref_mut() { if let Some(w) = writer.as_deref_mut() {
write_line_event(w, PrivStream::Stderr, &l).await; write_line_event(w, PrivStream::Stderr, &l).await;
} }
@ -778,23 +778,36 @@ async fn nix_build_toplevel(name: &str, mut writer: Option<&mut OwnedWriteHalf>)
stderr_buf.lines().last().unwrap_or("").trim() stderr_buf.lines().last().unwrap_or("").trim()
); );
} }
// `--print-out-paths` prints one line *per output*, not one line single_output_path(&stdout_buf)
// total: `nix build --no-link --print-out-paths nixpkgs#openssl` .map(str::to_owned)
// prints two (`…-bin`, `…-man`). `config.system.build.toplevel` is .map_err(|count| {
// single-output today, so this is one line in practice — but a bare anyhow!("nix build {attr} produced {count} output path(s), expected exactly 1: {stdout_buf:?}")
// `.trim()` would silently hand a multi-line string on to })
// `--system-path` the day that ever changes, which is the same }
// corrupted-argument failure this function exists to avoid. Require
// exactly one line and error otherwise, so a future multi-output /// Parse `nix build --print-out-paths`' stdout down to the single output
// attr fails loudly here instead of downstream in `nixos-container`. /// path this function's caller expects. `--print-out-paths` prints one
let lines: Vec<&str> = stdout_buf.lines().collect(); /// line *per output*, not one line total (`nix build --no-link
let [path] = lines[..] else { /// --print-out-paths nixpkgs#openssl` prints two: `…-bin`, `…-man`);
bail!( /// `config.system.build.toplevel` is single-output today, so this is one
"nix build {attr} produced {} output path(s), expected exactly 1: {stdout_buf:?}", /// line in practice — but a bare whole-buffer `.trim()` would silently
lines.len() /// hand a multi-line string on to `--system-path` the day that ever
); /// changes, the same corrupted-argument failure this function exists to
}; /// avoid. Trims and drops empty lines *before* counting, so a lone
Ok(path.to_owned()) /// `"\n"` (or trailing whitespace on the real line) can't be mistaken
/// for a present-but-blank path — see the unit tests below for the exact
/// table this closes. `Err` carries the surviving line count, for the
/// caller's error message.
fn single_output_path(stdout: &str) -> Result<&str, usize> {
let lines: Vec<&str> = stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
match lines[..] {
[path] => Ok(path),
_ => Err(lines.len()),
}
} }
/// `WriteNspawnFlags` — validate the container + every bind path + every /// `WriteNspawnFlags` — validate the container + every bind path + every
@ -2828,8 +2841,8 @@ mod tests {
use super::{ use super::{
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement,
clear_runner_credentials, contains_secret_shaped_run, git_overlay_flags, clear_runner_credentials, contains_secret_shaped_run, git_overlay_flags,
limits_dropin_body, redact_secret_line, remove_marker_in, toplevel_attr, limits_dropin_body, redact_secret_line, remove_marker_in, single_output_path,
write_state_file_nofollow, toplevel_attr, write_state_file_nofollow,
}; };
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@ -2855,6 +2868,31 @@ mod tests {
); );
} }
/// `--print-out-paths`' exact per-shape table (measured against real
/// `rustc` semantics, not just reasoned about) — a lone `"\n"` and a
/// trailing-space path are the two shapes a bare `.lines().collect()`
/// gets wrong, both catchable only by trimming + dropping empties
/// before counting rather than after.
#[test]
fn single_output_path_rejects_blank_and_trims_whitespace() {
assert_eq!(single_output_path(""), Err(0));
assert_eq!(single_output_path("\n"), Err(0));
assert_eq!(single_output_path("\n\n"), Err(0));
assert_eq!(single_output_path("/nix/store/abc\n"), Ok("/nix/store/abc"));
assert_eq!(
single_output_path("/nix/store/abc \n"),
Ok("/nix/store/abc")
);
assert_eq!(
single_output_path("/nix/store/abc\r\n"),
Ok("/nix/store/abc")
);
assert_eq!(
single_output_path("/nix/store/abc\n/nix/store/def\n"),
Err(2)
);
}
/// Every bound git repo gets its `.git` overlaid — the knowledge tree /// Every bound git repo gets its `.git` overlaid — the knowledge tree
/// and *each* config mount, an agent's own plus every child's. /// and *each* config mount, an agent's own plus every child's.
/// ///