//! 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; /// 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. 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" ); } } } /// Scratch-directory helpers 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}; /// Unique scratch dir per test, no external tempfile dep. pub(crate) fn scratch() -> PathBuf { static CTR: AtomicU32 = AtomicU32::new(0); let n = CTR.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "hive-priv-nofollow-test-{}-{n}", std::process::id() )); std::fs::create_dir_all(&dir).unwrap(); dir } /// Names in `dir`, sorted — the residue check every publish test ends on. pub(crate) fn entries(dir: &PathBuf) -> Vec { let mut v: Vec = std::fs::read_dir(dir) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); v.sort(); v } } #[cfg(test)] mod tests { use super::test_support::{entries, scratch}; use super::{ StagedFile, ensure_plain_filename, open_dir, partial_name, validate_credential_name, }; /// The temp name is the whole reason the rename is safe to watch: a /// `systemd.path` unit globbing `matrix-token*` would fire on a temp that /// merely suffixed the real name, on exactly the empty file the rename /// exists to hide. #[test] fn a_temp_cannot_match_a_glob_on_the_name_it_publishes() { for real in ["matrix-token", "matrix-token-alice", "forge-token"] { let tmp = partial_name(real); assert!( !tmp.starts_with(real), "{tmp:?} matches a `{real}*` path unit while half-written" ); assert!(!tmp.contains('/'), "temp must stay in the same directory"); } } /// Any failure between staging and publishing drops the `StagedFile`. /// Until the rename a reader sees only the old file, and afterwards the /// old file is still whole and the temp is gone. #[test] fn a_failed_write_leaves_the_old_file_intact() { let dir = scratch(); let dest = dir.join("hyperhive-limits.conf"); std::fs::write(&dest, "OLD").unwrap(); let dir_fd = open_dir(&dir).unwrap(); let staged = StagedFile::write(&dir_fd, &dir, "hyperhive-limits.conf", b"NEW", 0o644).unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "OLD"); assert_eq!( entries(&dir).len(), 2, "control: the temp exists while staged" ); drop(staged); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "OLD"); assert_eq!(entries(&dir), ["hyperhive-limits.conf"], "temp left behind"); std::fs::remove_dir_all(&dir).ok(); } /// Two overlapping writes of one file each own a temp, so whichever /// publishes last lands whole — never the other's bytes, never a mix. #[test] fn overlapping_writes_of_one_file_never_share_a_temp() { let dir = scratch(); let dir_fd = open_dir(&dir).unwrap(); let name = "hyperhive-agents.conf"; let first = StagedFile::write(&dir_fd, &dir, name, b"roster A\n", 0o644).unwrap(); let second = StagedFile::write(&dir_fd, &dir, name, b"roster B, longer\n", 0o644).unwrap(); second.publish().unwrap(); first.publish().unwrap(); assert_eq!( std::fs::read_to_string(dir.join(name)).unwrap(), "roster A\n" ); assert_eq!(entries(&dir), [name], "temp left behind"); std::fs::remove_dir_all(&dir).ok(); } /// The only gate on the root-privileged socket for a `--load-credential` /// name. The doc comment is explicit that `.` is excluded on purpose /// ("this name gets interpolated into filesystem paths") — a future /// "let's allow dots for version numbers" loosening must fail this, /// not just the `/` case below. #[test] fn validate_credential_name_rejects_dot_slash_and_empty_but_allows_the_charset() { assert!( validate_credential_name("").is_err(), "empty must be rejected" ); assert!(validate_credential_name("valid-name_123").is_ok()); assert!( validate_credential_name("bad.name").is_err(), "dot must be rejected — see the fn's doc comment" ); assert!(validate_credential_name("bad/name").is_err()); } /// `ensure_plain_filename` is the gate between an agent-writable /// directory and a root-privileged file write. `.`/`..` would resolve /// to the directory itself or its parent; any `/` climbs into a path /// component the caller never named. #[test] fn ensure_plain_filename_rejects_dot_dotdot_and_any_slash() { assert!(ensure_plain_filename("test", ".").is_err()); assert!(ensure_plain_filename("test", "..").is_err()); assert!(ensure_plain_filename("test", "sub/dir").is_err()); assert!(ensure_plain_filename("test", "matrix-token").is_ok()); } }