diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index ae06640b..4b2f3aaa 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -3007,13 +3007,14 @@ fn write_bridge_dns_marker(container: &str, isolation: &NetworkIsolation) -> Res /// - `/run/hive-agent/` (web socket dir, bind-mounted into container) const TMPFILES_PATH: &str = "/etc/tmpfiles.d/hyperhive-agents.conf"; -async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, String)> { +/// The `tmpfiles.d` body, built without touching the filesystem. +/// +/// Split out of `sync_agent_tmpfiles` so the modes below can be asserted: the +/// caller writes the file and then shells out to `systemd-tmpfiles`, neither of +/// which a test can do, and a mode nobody can assert is a mode that drifts. +fn agent_tmpfiles_content(agents: &[AgentTmpfilesEntry]) -> String { use std::fmt::Write as _; - for entry in agents { - validate_agent_name(&entry.name)?; - } - // Build tmpfiles.d content. Root dirs first, then per-agent. let mut content = String::from("# managed by hive-c0re — do not edit (regenerated on spawn/destroy)\n"); // Parent dirs — created with permissive mode so hive-c0re can make subdirs. @@ -3075,6 +3076,14 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S writeln!(content, "d {SOCKET_DIR_ROOT}/{name} 0777 root root -").ok(); } } + content +} + +async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, String)> { + for entry in agents { + validate_agent_name(&entry.name)?; + } + let content = agent_tmpfiles_content(agents); // Atomic write: write to a tmp file then rename so a concurrent reader // always sees a complete file. @@ -3103,11 +3112,11 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S #[cfg(test)] mod tests { use super::{ - BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, - clear_runner_credentials, contains_secret_shaped_run, git_overlay_flags, - limits_dropin_body, matrix_token_filename, partial_name, redact_secret_line, - remove_marker_in, single_output_path, toplevel_attr, validate_account_name, - write_agent_dir_file, write_state_file_nofollow, + AgentTmpfilesEntry, BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, + agent_tmpfiles_content, check_fd_agreement, clear_runner_credentials, + contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, matrix_token_filename, + partial_name, redact_secret_line, remove_marker_in, single_output_path, toplevel_attr, + validate_account_name, write_agent_dir_file, write_state_file_nofollow, }; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; @@ -3120,6 +3129,49 @@ mod tests { } } + /// `/run/hyperhive` is declared twice — here and as `hive-c0re.service`'s + /// `RuntimeDirectory`/`RuntimeDirectoryMode`. When the two disagree, + /// whichever runs last wins, and the loser's mode is silently re-applied on + /// every agent spawn. `0751` is the value the socket's own `0660 + /// hive-admin` gate depends on: `o=--x` is what lets an admin who is not in + /// `hive-core` traverse to it at all. + #[test] + fn hyperhive_runtime_dir_keeps_the_traversable_mode() { + let out = agent_tmpfiles_content(&[]); + assert!( + out.contains("d /run/hyperhive 0751 hive-core hive-core -\n"), + "{out}" + ); + // The specific regression: 0750 denies traversal before host.sock's own + // permissions are consulted. + assert!(!out.contains("d /run/hyperhive 0750"), "{out}"); + } + + /// Control for the assertion above: an empty roster still emits the parent + /// dirs, and a populated one adds per-agent lines — so a `contains` check + /// over this body is reading a body that was actually built. + #[test] + fn tmpfiles_body_grows_with_the_roster() { + let empty = agent_tmpfiles_content(&[]); + let one = agent_tmpfiles_content(&[AgentTmpfilesEntry { + name: "probe".to_owned(), + uid: Some(1234), + gid: Some(1234), + }]); + assert!(one.len() > empty.len(), "empty={empty}\none={one}"); + assert!( + one.contains("d /run/hive-agent/probe 0751 1234 1234 -\n"), + "{one}" + ); + // Every per-agent line the builder emits, so no mode here is + // unasserted — an unpinned mode is one that drifts. + assert!( + one.contains("d /run/hyperhive/agents/probe 0755 hive-core hive-core -\n"), + "{one}" + ); + assert!(!empty.contains("probe"), "{empty}"); + } + /// Pins the exact attr path we hand to `nix build` — it has to match /// `hive-c0re`'s own `lifecycle::prebuild_toplevel` construction, since /// that step's whole point is warming the store for this later build.