mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 11:08:25 +00:00
Confine the profiler capture directory to the log root
The directory given to `profile tick on` travelled from the control socket straight into a root create_dir_all with no validation. The socket is reachable by the fips group, which docs/reference/security.md writes down as strictly weaker than root, so a group member could create a root-owned directory anywhere on the filesystem, including a path a later privileged component reads. A privileged daemon now resolves --dir against /var/log/fips: absolute, no `..` component, and still under the root once every existing ancestor has been followed through its symlinks, so a symlinked parent cannot launder a lexically clean path. Plain canonicalize cannot do this on its own, since the directory being created does not exist yet; the resolver canonicalizes the deepest existing ancestor and re-appends the tail. An unprivileged daemon crosses no boundary and takes --dir as given, which keeps the documented non-root `cargo run` capture working. Whether the process is privileged is a parameter rather than a geteuid() call inside the resolver, so the rules are exercised without depending on the uid of whoever ran the tests, and the lifecycle tests use a start_in that takes an already-resolved directory for the same reason. The sink itself is no longer written over whatever is at its path. The name is a one-second UTC stamp and therefore predictable, so File::create would have followed a symlink pre-planted at the next name and truncated any real file it found. The file is created only if it does not exist, and a capture started in the same second as a previous one takes the next free suffix rather than failing: stop-then-start inside one second is an ordinary sequence that used to succeed by truncating. Capture files are created private to their owner and the capture directory no longer inherits a permissive umask, since a capture carries the node npub, build, platform and a timing series. Confining a root daemon to a different log root is no longer possible and cli-fipsctl.md drops that use of --dir. This affects only a --features profiling build; the subcommand is absent from a stock package.
This commit is contained in:
@@ -540,6 +540,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
other packaging paths did, so the contact of record in the new `.pkg` artifact
|
other packaging paths did, so the contact of record in the new `.pkg` artifact
|
||||||
would have been unreachable at its first release.
|
would have been unreachable at its first release.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
#### Tick profiler
|
||||||
|
|
||||||
|
- The `--dir` given to `profile tick on` is now confined to `/var/log/fips`
|
||||||
|
when the daemon runs as root. The control socket is reachable by the `fips`
|
||||||
|
group, which the security model writes down as strictly weaker than root, yet
|
||||||
|
the directory travelled from the socket straight into a root `create_dir_all`
|
||||||
|
with no validation: a group member could create a root-owned directory
|
||||||
|
anywhere on the filesystem, including a path a later privileged component
|
||||||
|
reads. The path must now be absolute, must contain no `..`, and must still
|
||||||
|
resolve under the root once every existing ancestor has been followed through
|
||||||
|
its symlinks, so a symlinked parent does not launder a lexically clean path.
|
||||||
|
A daemon that is not running as root crosses no such boundary and takes
|
||||||
|
`--dir` as given, which keeps the documented non-root `cargo run` capture
|
||||||
|
working; the flag can no longer point a root daemon at a different log root,
|
||||||
|
which was its other documented use. This only ever affected a
|
||||||
|
`--features profiling` build: the subcommand is absent from a stock package.
|
||||||
|
|
||||||
|
- A capture file is no longer written over whatever is already at its path. The
|
||||||
|
name is a one-second UTC stamp and therefore predictable, so `File::create`
|
||||||
|
would have followed a symlink pre-planted at the next name, and it truncated
|
||||||
|
any real file it found. The file is created only if it does not exist, and a
|
||||||
|
capture started in the same second as a previous one takes the next free
|
||||||
|
`-N` suffix instead of failing. Capture files are created private to their
|
||||||
|
owner and the capture directory is no longer left world-accessible by a
|
||||||
|
permissive umask; a capture carries the node npub, build, platform and a
|
||||||
|
timing series.
|
||||||
|
|
||||||
## [0.4.2] - unreleased
|
## [0.4.2] - unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -241,9 +241,22 @@ package does not carry this subcommand and a stock daemon reports
|
|||||||
|
|
||||||
| Flag | Argument | Default | Description |
|
| Flag | Argument | Default | Description |
|
||||||
| ---- | -------- | ------- | ----------- |
|
| ---- | -------- | ------- | ----------- |
|
||||||
| `--dir` | directory path | `/var/log/fips` | Where to write the capture. Created if absent. Use it to profile a non-root `cargo run`, or on a platform whose log root differs. |
|
| `--dir` | directory path | `/var/log/fips` | Where to write the capture. Created if absent. Use it to profile a non-root `cargo run`. |
|
||||||
|
|
||||||
One file is written per capture, named `profile-<UTC timestamp>.tsv`.
|
Against a daemon running as root, `--dir` must name an absolute path
|
||||||
|
under `/var/log/fips`, and a path that resolves outside it through a
|
||||||
|
symlinked parent is refused. The control socket is reachable by the
|
||||||
|
`fips` group, which the security model treats as strictly weaker than
|
||||||
|
root, so a group member cannot steer a root directory creation at an
|
||||||
|
arbitrary path. A daemon that is not running as root crosses no such
|
||||||
|
boundary and takes `--dir` as given, which is what keeps a non-root
|
||||||
|
`cargo run` capture working. Confining a root daemon's captures to a
|
||||||
|
different log root is not currently supported.
|
||||||
|
|
||||||
|
One file is written per capture, named `profile-<UTC timestamp>.tsv`,
|
||||||
|
with `-1`, `-2` and so on appended if a capture in the same second
|
||||||
|
already claimed the name. The capture file is created private to its
|
||||||
|
owner and is never written over an existing file.
|
||||||
It opens with a `#`-prefixed header block (node npub, build version,
|
It opens with a `#`-prefixed header block (node npub, build version,
|
||||||
platform, configured tick period, flush interval, byte cap, start
|
platform, configured tick period, flush interval, byte cap, start
|
||||||
time), then a tab-separated column header, then one row per measured
|
time), then a tab-separated column header, then one row per measured
|
||||||
|
|||||||
+355
-16
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
@@ -20,9 +20,27 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|||||||
use super::recorder;
|
use super::recorder;
|
||||||
use super::writer;
|
use super::writer;
|
||||||
|
|
||||||
/// Default sink directory. Overridable per capture with `--dir`.
|
/// Default sink directory, and the root a privileged daemon confines `--dir`
|
||||||
|
/// to. Overridable per capture with `--dir`, within that root.
|
||||||
pub(crate) const DEFAULT_DIR: &str = "/var/log/fips";
|
pub(crate) const DEFAULT_DIR: &str = "/var/log/fips";
|
||||||
|
|
||||||
|
/// How many same-second filename collisions a capture steps around before
|
||||||
|
/// giving up. Stop-then-start inside one second is an ordinary operator
|
||||||
|
/// sequence and the stamp has one-second granularity, so a bare failure there
|
||||||
|
/// would be a regression. Raising this only widens how many restarts one
|
||||||
|
/// second can hold; lowering it turns a fast restart back into an error.
|
||||||
|
const NAME_COLLISION_RETRIES: u32 = 16;
|
||||||
|
|
||||||
|
/// Mode for a created capture file. A capture carries the node npub, build,
|
||||||
|
/// platform and a timing series, so it is not world-readable.
|
||||||
|
#[cfg(unix)]
|
||||||
|
const FILE_MODE: u32 = 0o600;
|
||||||
|
|
||||||
|
/// Mode for a created capture directory. `mkdir` can only tighten this with
|
||||||
|
/// the umask, never loosen it.
|
||||||
|
#[cfg(unix)]
|
||||||
|
const DIR_MODE: u32 = 0o750;
|
||||||
|
|
||||||
/// Writer flush interval.
|
/// Writer flush interval.
|
||||||
pub(crate) const INTERVAL: Duration = Duration::from_secs(10);
|
pub(crate) const INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
@@ -102,14 +120,133 @@ fn reap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arm a capture.
|
/// True when the process runs with root's privileges.
|
||||||
|
///
|
||||||
|
/// This is what makes an unconstrained `--dir` a privilege crossing: the
|
||||||
|
/// control socket is reachable by the `fips` group, which the security model
|
||||||
|
/// writes down as strictly weaker than root, so a group member must not be
|
||||||
|
/// able to steer a root `create_dir_all` at an arbitrary path.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn running_as_root() -> bool {
|
||||||
|
unsafe { libc::geteuid() == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No control socket and no `fips` group off unix, so there is no weaker
|
||||||
|
/// principal to confine and the parameter keeps its original meaning.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn running_as_root() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a requested capture directory against the root it must stay under.
|
||||||
|
///
|
||||||
|
/// `privileged` is a parameter rather than a `geteuid()` call so the rules can
|
||||||
|
/// be exercised without depending on the uid of whoever ran the tests.
|
||||||
|
///
|
||||||
|
/// - No `--dir` keeps today's default, `root`.
|
||||||
|
/// - An unprivileged daemon crosses no boundary: it can already write wherever
|
||||||
|
/// the invoking user can, so the path is taken as given. This is what keeps
|
||||||
|
/// the documented non-root `cargo run` capture working.
|
||||||
|
/// - Otherwise the path must be absolute, must contain no `..` component, and
|
||||||
|
/// must resolve under `root` once every existing ancestor has been followed
|
||||||
|
/// through its symlinks.
|
||||||
|
///
|
||||||
|
/// Accepted: resolution is check-then-act. A symlink planted between the check
|
||||||
|
/// here and the `create_dir_all` in `open_sink` would escape the root. Inside
|
||||||
|
/// `/var/log/fips` only root can plant one, which is the principal already
|
||||||
|
/// being trusted.
|
||||||
|
fn resolve_dir_under(dir: Option<&str>, root: &Path, privileged: bool) -> Result<PathBuf, String> {
|
||||||
|
let Some(dir) = dir else {
|
||||||
|
return Ok(root.to_path_buf());
|
||||||
|
};
|
||||||
|
if !privileged {
|
||||||
|
return Ok(PathBuf::from(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested = Path::new(dir);
|
||||||
|
if !requested.is_absolute() {
|
||||||
|
return Err(format!(
|
||||||
|
"profile directory must be an absolute path under {}: {dir}",
|
||||||
|
root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if requested
|
||||||
|
.components()
|
||||||
|
.any(|c| matches!(c, Component::ParentDir))
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"profile directory must not contain `..` components: {dir}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = resolve_through_existing(requested)?;
|
||||||
|
let root = resolve_through_existing(root)?;
|
||||||
|
if !resolved.starts_with(&root) {
|
||||||
|
return Err(format!(
|
||||||
|
"profile directory must be under {}: {dir}",
|
||||||
|
root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonicalize the deepest existing ancestor of `path` and re-append the tail
|
||||||
|
/// that does not exist yet.
|
||||||
|
///
|
||||||
|
/// Plain `canonicalize` returns `NotFound` for the directory a capture is
|
||||||
|
/// being asked to create, which is the ordinary case, so it cannot be used on
|
||||||
|
/// its own. Following the existing ancestors is what catches a lexically clean
|
||||||
|
/// path whose parent is a symlink out of the root.
|
||||||
|
fn resolve_through_existing(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||||
|
let mut probe = path.to_path_buf();
|
||||||
|
loop {
|
||||||
|
if let Ok(base) = probe.canonicalize() {
|
||||||
|
let mut out = base;
|
||||||
|
for part in tail.iter().rev() {
|
||||||
|
out.push(part);
|
||||||
|
}
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let Some(name) = probe.file_name().map(|n| n.to_os_string()) else {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot resolve profile directory {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
tail.push(name);
|
||||||
|
if !probe.pop() {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot resolve profile directory {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm a capture, confining `--dir` to the capture root.
|
||||||
///
|
///
|
||||||
/// Opens the sink first and only then starts the writer, so a bad `--dir` is
|
/// Opens the sink first and only then starts the writer, so a bad `--dir` is
|
||||||
/// reported to the caller rather than logged into the void.
|
/// reported to the caller rather than logged into the void. The directory is
|
||||||
|
/// resolved before the capture slot is claimed, so a rejected path leaves the
|
||||||
|
/// slot free.
|
||||||
pub(crate) fn start(
|
pub(crate) fn start(
|
||||||
dir: Option<&str>,
|
dir: Option<&str>,
|
||||||
node_npub: &str,
|
node_npub: &str,
|
||||||
tick_period_secs: u64,
|
tick_period_secs: u64,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let dir = resolve_dir_under(dir, Path::new(DEFAULT_DIR), running_as_root())?;
|
||||||
|
start_in(&dir, node_npub, tick_period_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm a capture in an already-resolved directory.
|
||||||
|
///
|
||||||
|
/// Split out from `start` so the confinement rules live in one place and the
|
||||||
|
/// lifecycle can be exercised without them.
|
||||||
|
pub(crate) fn start_in(
|
||||||
|
dir: &Path,
|
||||||
|
node_npub: &str,
|
||||||
|
tick_period_secs: u64,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
claim()?;
|
claim()?;
|
||||||
|
|
||||||
@@ -210,25 +347,80 @@ pub(crate) fn shutdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create the capture directory, giving it a mode rather than inheriting
|
||||||
|
/// whatever a permissive umask allows.
|
||||||
|
fn create_capture_dir(dir: &Path) -> std::io::Result<()> {
|
||||||
|
let mut builder = std::fs::DirBuilder::new();
|
||||||
|
builder.recursive(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
builder.mode(DIR_MODE);
|
||||||
|
}
|
||||||
|
builder.create(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the capture file, refusing to follow or truncate anything already at
|
||||||
|
/// the path.
|
||||||
|
///
|
||||||
|
/// The basename is a one-second UTC stamp and therefore predictable, so
|
||||||
|
/// `File::create` would follow a symlink pre-planted at the next name.
|
||||||
|
/// `create_new` refuses any existing entry; a same-second restart, which used
|
||||||
|
/// to succeed by silently truncating the previous capture, steps to the next
|
||||||
|
/// free suffix instead of failing.
|
||||||
|
fn create_capture_file(dir: &Path, stamp: &str) -> Result<(File, PathBuf), String> {
|
||||||
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
|
opts.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
opts.mode(FILE_MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last = None;
|
||||||
|
for n in 0..=NAME_COLLISION_RETRIES {
|
||||||
|
let name = if n == 0 {
|
||||||
|
format!("profile-{stamp}.tsv")
|
||||||
|
} else {
|
||||||
|
format!("profile-{stamp}-{n}.tsv")
|
||||||
|
};
|
||||||
|
let path = dir.join(name);
|
||||||
|
match opts.open(&path) {
|
||||||
|
Ok(file) => return Ok((file, path)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
last = Some((path, e));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot create profile file {}: {e}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, e) = last.expect("the loop runs at least once");
|
||||||
|
Err(format!(
|
||||||
|
"cannot create profile file {}: {e}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the sink file and write its header block. Returns the open file, its
|
/// Create the sink file and write its header block. Returns the open file, its
|
||||||
/// path, and the number of header bytes written.
|
/// path, and the number of header bytes written.
|
||||||
fn open_sink(
|
fn open_sink(
|
||||||
dir: Option<&str>,
|
dir: &Path,
|
||||||
node_npub: &str,
|
node_npub: &str,
|
||||||
tick_period_secs: u64,
|
tick_period_secs: u64,
|
||||||
) -> Result<(File, PathBuf, u64), String> {
|
) -> Result<(File, PathBuf, u64), String> {
|
||||||
let dir = PathBuf::from(dir.unwrap_or(DEFAULT_DIR));
|
create_capture_dir(dir)
|
||||||
std::fs::create_dir_all(&dir)
|
|
||||||
.map_err(|e| format!("cannot use profile directory {}: {e}", dir.display()))?;
|
.map_err(|e| format!("cannot use profile directory {}: {e}", dir.display()))?;
|
||||||
|
|
||||||
let start_unix = SystemTime::now()
|
let start_unix = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let path = dir.join(format!("profile-{}.tsv", compact_utc(start_unix)));
|
let (mut file, path) = create_capture_file(dir, &compact_utc(start_unix))?;
|
||||||
|
|
||||||
let mut file = File::create(&path)
|
|
||||||
.map_err(|e| format!("cannot create profile file {}: {e}", path.display()))?;
|
|
||||||
|
|
||||||
let header = format!(
|
let header = format!(
|
||||||
"# fips tick profile\n\
|
"# fips tick profile\n\
|
||||||
@@ -330,15 +522,14 @@ mod tests {
|
|||||||
fn capture_round_trip_writes_header_and_rows() {
|
fn capture_round_trip_writes_header_and_rows() {
|
||||||
let _guard = crate::instr::test_serial();
|
let _guard = crate::instr::test_serial();
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let dir_str = dir.path().to_str().unwrap().to_string();
|
|
||||||
|
|
||||||
let started = start(Some(&dir_str), "npub1test", 1).expect("start");
|
let started = start_in(dir.path(), "npub1test", 1).expect("start");
|
||||||
assert_eq!(started["state"], "running");
|
assert_eq!(started["state"], "running");
|
||||||
assert!(gate(), "gate must be armed while running");
|
assert!(gate(), "gate must be armed while running");
|
||||||
let path = PathBuf::from(started["path"].as_str().unwrap());
|
let path = PathBuf::from(started["path"].as_str().unwrap());
|
||||||
|
|
||||||
// A second `on` is refused while one is running, and names the file.
|
// A second `on` is refused while one is running, and names the file.
|
||||||
let refused = start(Some(&dir_str), "npub1test", 1).unwrap_err();
|
let refused = start_in(dir.path(), "npub1test", 1).unwrap_err();
|
||||||
assert!(refused.contains(&path.display().to_string()), "{refused}");
|
assert!(refused.contains(&path.display().to_string()), "{refused}");
|
||||||
|
|
||||||
// Feed one observation so the drained rows are not all zero.
|
// Feed one observation so the drained rows are not all zero.
|
||||||
@@ -400,14 +591,162 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn start_fails_loudly_on_an_unwritable_directory() {
|
fn start_fails_loudly_on_an_unwritable_directory() {
|
||||||
let _guard = crate::instr::test_serial();
|
let _guard = crate::instr::test_serial();
|
||||||
let err = start(Some("/proc/fips-profile-should-not-exist"), "npub1test", 1)
|
let err = start_in(
|
||||||
.expect_err("must fail");
|
Path::new("/proc/fips-profile-should-not-exist"),
|
||||||
|
"npub1test",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect_err("must fail");
|
||||||
assert!(err.contains("profile directory"), "{err}");
|
assert!(err.contains("profile directory"), "{err}");
|
||||||
// The failed attempt must leave the slot free for the next try.
|
// The failed attempt must leave the slot free for the next try.
|
||||||
assert_eq!(STATE.load(Ordering::Acquire), IDLE);
|
assert_eq!(STATE.load(Ordering::Acquire), IDLE);
|
||||||
assert!(!gate());
|
assert!(!gate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// `--dir` confinement
|
||||||
|
//
|
||||||
|
// All of these force `privileged = true` rather than reading the uid, so
|
||||||
|
// their verdict does not depend on who ran the suite.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_rejects_a_parent_traversal_escape() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let escape = root.path().join("..").join("..").join("etc");
|
||||||
|
let err = resolve_dir_under(Some(escape.to_str().unwrap()), root.path(), true)
|
||||||
|
.expect_err("a `..` escape must be refused");
|
||||||
|
assert!(err.contains(".."), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_rejects_a_relative_path() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let err = resolve_dir_under(Some("sub"), root.path(), true)
|
||||||
|
.expect_err("a relative path must be refused");
|
||||||
|
assert!(err.contains("absolute"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_rejects_an_absolute_path_outside_the_root() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let err = resolve_dir_under(Some("/etc/cron.d"), root.path(), true)
|
||||||
|
.expect_err("a path outside the root must be refused");
|
||||||
|
assert!(err.contains("must be under"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lexically clean escape: every component is innocent and an existing
|
||||||
|
/// ancestor is a symlink pointing out of the root.
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn profile_dir_rejects_a_symlinked_ancestor_pointing_out_of_the_root() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let outside = tempfile::tempdir().expect("tempdir");
|
||||||
|
let link = root.path().join("link");
|
||||||
|
std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
|
||||||
|
|
||||||
|
let asked = link.join("x");
|
||||||
|
let err = resolve_dir_under(Some(asked.to_str().unwrap()), root.path(), true)
|
||||||
|
.expect_err("a symlinked ancestor must not escape the root");
|
||||||
|
assert!(err.contains("must be under"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_accepts_the_root_and_a_subdirectory_that_does_not_exist_yet() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
resolve_dir_under(Some(root.path().to_str().unwrap()), root.path(), true)
|
||||||
|
.expect("the root itself must be allowed");
|
||||||
|
|
||||||
|
// Not yet created: the resolver must not depend on the path existing.
|
||||||
|
let fresh = root.path().join("run-1");
|
||||||
|
let got = resolve_dir_under(Some(fresh.to_str().unwrap()), root.path(), true)
|
||||||
|
.expect("a subdirectory that does not exist yet must be allowed");
|
||||||
|
assert!(got.ends_with("run-1"), "{}", got.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_defaults_to_the_root_when_none_is_given() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let got = resolve_dir_under(None, root.path(), true).expect("the default must be allowed");
|
||||||
|
assert_eq!(got, root.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_takes_the_unprivileged_bypass() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let got = resolve_dir_under(Some("/anywhere/at/all"), root.path(), false)
|
||||||
|
.expect("an unprivileged daemon crosses no boundary");
|
||||||
|
assert_eq!(got, PathBuf::from("/anywhere/at/all"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pre-planted entry at the predicted capture name must not be followed
|
||||||
|
/// or truncated. The stamp is one-second granular, so the name is
|
||||||
|
/// guessable to within a second.
|
||||||
|
#[test]
|
||||||
|
fn profile_sink_does_not_truncate_a_preexisting_path_at_the_capture_name() {
|
||||||
|
let _guard = crate::instr::test_serial();
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let start_unix = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let squatted = dir.path().join(format!(
|
||||||
|
"profile-{}.tsv",
|
||||||
|
compact_utc(start_unix.saturating_sub(1))
|
||||||
|
));
|
||||||
|
std::fs::write(&squatted, b"do not truncate me").expect("plant");
|
||||||
|
|
||||||
|
// Name the same stamp the squatter used, so the sink collides with it.
|
||||||
|
let (_file, path) =
|
||||||
|
create_capture_file(dir.path(), &compact_utc(start_unix.saturating_sub(1)))
|
||||||
|
.expect("the sink must step around the collision");
|
||||||
|
|
||||||
|
assert_ne!(path, squatted, "the sink must not reuse the existing name");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&squatted).expect("read"),
|
||||||
|
"do not truncate me",
|
||||||
|
"an existing file at the capture name must survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop-then-start inside one second is an ordinary operator sequence and
|
||||||
|
/// must produce a second capture, not an error.
|
||||||
|
#[test]
|
||||||
|
fn profile_sink_uniquifies_a_same_second_restart() {
|
||||||
|
let _guard = crate::instr::test_serial();
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let (_first, first_path) = create_capture_file(dir.path(), "20260727T191500Z").unwrap();
|
||||||
|
let (_second, second_path) = create_capture_file(dir.path(), "20260727T191500Z")
|
||||||
|
.expect("a same-second restart must not fail");
|
||||||
|
assert_ne!(first_path, second_path);
|
||||||
|
assert!(first_path.exists() && second_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn profile_sink_creates_a_private_file_and_directory() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let _guard = crate::instr::test_serial();
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let dir = root.path().join("captures");
|
||||||
|
create_capture_dir(&dir).expect("create dir");
|
||||||
|
let (_file, path) = create_capture_file(&dir, "20260727T191500Z").expect("create file");
|
||||||
|
|
||||||
|
// The umask can only tighten what `mkdir`/`open` were given, so the
|
||||||
|
// assertion is on the bits that must be absent rather than equality.
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o007,
|
||||||
|
0,
|
||||||
|
"the capture directory must not be world-accessible"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&path).unwrap().permissions().mode() & 0o077,
|
||||||
|
0,
|
||||||
|
"the capture file must be private to its owner"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_reports_the_bounds_it_is_enforcing() {
|
fn status_reports_the_bounds_it_is_enforcing() {
|
||||||
let _guard = crate::instr::test_serial();
|
let _guard = crate::instr::test_serial();
|
||||||
|
|||||||
Reference in New Issue
Block a user