//! Helpers more than one op family uses: agent-name validation and the //! `h-` container prefix, the credential-name charset, and [`StagedFile`], //! the no-follow write-then-rename every root-side file write goes through. use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_priv_sock::AGENT_PREFIX; use tokio::process::Command; /// Return the system container name for a logical agent name. /// All agents (including the manager) use the `h-` prefix. /// /// **This prefix is what confines the lifecycle verbs to agent containers** — /// not their name validation, which only checks characters. A caller cannot /// name `hive-forge` and reach it: it becomes `h-hive-forge`. No lifecycle /// verb takes a sibling name: the forge and matrix are swarm services a hive /// does not start or stop, and the gateway is reached through /// [`hive_priv_sock::PrivRequest::ControlInfraContainer`] and the /// [`hive_priv_sock::InfraContainer`] enum. pub(crate) fn container_system_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } /// Validate a logical agent name (the name hive-c0re uses internally, /// before the `h-` container prefix is applied). pub(crate) fn validate_agent_name(name: &str) -> Result<()> { validate_name_chars(name)?; Ok(()) } pub(crate) fn validate_name_chars(name: &str) -> Result<()> { if name.is_empty() || !name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { bail!("invalid name {name:?}: must be non-empty lowercase ascii + digits + hyphens"); } Ok(()) } /// A systemd credential id must be a short token — restrict to /// `[A-Za-z0-9_-]` (no `.`) so it can't inject extra `--load-credential` /// argv or break the `name:path` shape. `.` is deliberately excluded, not /// just a bare `..`: this name gets interpolated into filesystem paths /// (snapshot labels) and there's no legitimate need for a dot in either a /// systemd credential id or a `hive-`-prefixed snapshot label — we're /// defining this token format from scratch, so keep it maximally strict /// rather than allow-then-patch each traversal-adjacent character /// (mara: "we are making up the rules here, lets go strict"). pub(crate) fn validate_credential_name(name: &str) -> Result<()> { if name.is_empty() || !name .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) { bail!("invalid credential name {name:?}: must be non-empty [A-Za-z0-9_-]"); } Ok(()) } /// A leaf safe to join onto an agent-owned directory: one plain component, so /// it can neither climb out of the directory nor name the directory itself. pub(crate) fn ensure_plain_filename(who: &str, filename: &str) -> Result<()> { if filename.is_empty() || filename == "." || filename == ".." || filename.contains('/') { bail!("{who}: refusing non-plain filename {filename:?}"); } Ok(()) } /// Name the content is written under before being renamed onto `filename`, /// unique per call so two concurrent writes of one file never share a temp. /// /// The leading dot and the `.partial` suffix are load-bearing: a reader /// globbing on a published name's prefix, or systemd reading `*.conf` out of /// `tmpfiles.d` and drop-in dirs, cannot pick up a half-written temp. pub(crate) fn partial_name(filename: &str) -> String { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); format!(".{filename}.{}-{n}.partial", std::process::id()) } /// Open `path` as a directory fd for [`StagedFile`] to work relative to. pub(crate) fn open_dir(path: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt as _; std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY) .open(path) .with_context(|| format!("open directory {}", path.display())) } /// A file written under a [`partial_name`] in its destination directory and /// then renamed onto its final name, so a reader sees either the old file or /// the complete new one, never a partial or wrong-mode one. Mode, and owner /// when the caller `fchown`s [`file`](Self::file), are on the inode before the /// rename makes it visible. /// /// Every step is relative to the `dir` fd, so it is safe in a directory an /// agent or container controls: the temp is created `O_EXCL|O_NOFOLLOW`, which /// refuses any entry already planted at its name, and `rename` replaces /// whatever sits at the final name without following it. /// /// Dropped without [`publish`](Self::publish), it unlinks the temp, so a /// failure between the two leaves the old file untouched and no residue. pub(crate) struct StagedFile<'a> { dir: &'a std::fs::File, tmp: std::ffi::CString, dest: std::ffi::CString, dest_path: PathBuf, file: std::fs::File, published: bool, } impl<'a> StagedFile<'a> { /// Create the temp at `mode` and write `content` to it. The mode is set /// again on the fd after the write, so the umask cannot change it. pub(crate) fn write( dir: &'a std::fs::File, dir_path: &Path, filename: &str, content: &[u8], mode: u32, ) -> Result { use std::io::Write as _; use std::os::unix::fs::PermissionsExt as _; ensure_plain_filename("StagedFile::write", filename)?; let dest_path = dir_path.join(filename); let tmp_name = partial_name(filename); let tmp = std::ffi::CString::new(tmp_name.as_str()) .with_context(|| format!("NUL in filename {filename:?}"))?; let dest = std::ffi::CString::new(filename) .with_context(|| format!("NUL in filename {filename:?}"))?; // SAFETY: `dir` is an open directory fd and `tmp` is NUL-terminated; // the result is checked before it is wrapped. let fd = unsafe { libc::openat( dir.as_raw_fd(), tmp.as_ptr(), libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, mode, ) }; if fd < 0 { return Err(std::io::Error::last_os_error()) .with_context(|| format!("create {}", dir_path.join(&tmp_name).display())); } // SAFETY: `fd` is a fresh, valid fd that nothing else owns. let file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) }); let mut staged = Self { dir, tmp, dest, dest_path, file, published: false, }; staged .file .write_all(content) .with_context(|| format!("write {}", staged.dest_path.display()))?; staged .file .set_permissions(std::fs::Permissions::from_mode(mode)) .with_context(|| format!("chmod {mode:o} {}", staged.dest_path.display()))?; Ok(staged) } /// The temp's open fd, for an `fchown` that must land before publishing. pub(crate) fn file(&self) -> &std::fs::File { &self.file } /// Flush the content, rename the temp onto the final name, and flush the /// directory: without the last step a crash can lose the rename itself. pub(crate) fn publish(mut self) -> Result<()> { self.file .sync_all() .with_context(|| format!("fsync {}", self.dest_path.display()))?; let fd = self.dir.as_raw_fd(); // SAFETY: `fd` is an open directory fd; both names are NUL-terminated. if unsafe { libc::renameat(fd, self.tmp.as_ptr(), fd, self.dest.as_ptr()) } != 0 { return Err(std::io::Error::last_os_error()) .with_context(|| format!("publishing {}", self.dest_path.display())); } self.published = true; self.dir .sync_all() .with_context(|| format!("fsync directory of {}", self.dest_path.display())) } } impl Drop for StagedFile<'_> { fn drop(&mut self) { if self.published { return; } // SAFETY: `self.dir` is an open directory fd; `self.tmp` is // NUL-terminated. if unsafe { libc::unlinkat(self.dir.as_raw_fd(), self.tmp.as_ptr(), 0) } != 0 { tracing::warn!( dest = %self.dest_path.display(), error = %std::io::Error::last_os_error(), "failed to remove unpublished temp" ); } } } /// How an op starts a host program. [`RealSpawn`] in production; tests /// substitute stand-ins, so no root, container or btrfs filesystem is needed. pub(crate) trait Spawn { /// The `program` command, ready for arguments. fn command(&self, program: &str) -> Command; } /// Starts the program `program` names on `PATH`. pub(crate) struct RealSpawn; impl Spawn for RealSpawn { fn command(&self, program: &str) -> Command { Command::new(program) } } /// Scratch directories and stand-in tools shared by every module's tests. One counter for /// the whole crate, so no two tests get the same directory. #[cfg(test)] pub(crate) mod test_support { use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; use tokio::process::Command; use super::Spawn; /// Runs every program as `sh -c