Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-priv/src/shared.rs
atlas 6ac637c7e3 hive-priv: split main.rs into one module per op family
No behaviour change: a pure move plus pub(crate) visibility. PrivRequest,
hive-priv-sock and every validation rule are untouched; the 58 tests move
next to the code they test, unchanged.

Why (#5476): the 28 root ops shared one 4.3k-line file, so no op family
could be reviewed on its own and every new root op landed there by default.

main.rs keeps the socket loop and the dispatch table. container (lifecycle),
container_config (nspawn flags, systemd drop-ins, daemon-reload), storage
(btrfs subvolumes, quotas, snapshots, du), files (pause marker, socket dir,
legacy tmpfiles) and infra (gateway nginx, forge admin, infra containers)
each carry their own validation; shared holds the agent-name checks, the
credential-name charset and StagedFile, plus the tests' scratch helpers
(one counter crate-wide, so test dirs stay unique).

hive-priv/README.md is in the diff because it named src/main.rs as the
home of container_flake_action and now describes the module layout.
2026-10-18 03:55:37 +02:00

342 lines
14 KiB
Rust

//! 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<std::fs::File> {
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<Self> {
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<String> {
let mut v: Vec<String> = 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());
}
}