Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-priv/src/shared.rs

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.
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<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());
}
}