pmacs/src/state.rs

513 lines
19 KiB
Rust

// state.rs --- persistent editor state directory (Arc 3, Q#PS2).
//! The `$XDG_STATE_HOME/pmacs/` base that all persisted editor state
//! lives under: minibuffer history (the original tenant), plus the
//! Arc 3 persistence features (recent files, saveplace, desktop, and
//! autosave recovery).
//!
//! Env is passed in as arguments, never read inline, so the pure
//! resolver is testable without touching the process environment
//! (`#![forbid(unsafe_code)]` rules out `env::set_var`).
use std::ffi::OsStr;
use std::path::PathBuf;
/// The base state directory `.../pmacs`, or `None` when neither
/// `XDG_STATE_HOME` nor `HOME` is usably set.
///
/// Order: `$XDG_STATE_HOME/pmacs`, then `$HOME/.local/state/pmacs`.
///
/// A **blank** `XDG_STATE_HOME` is treated as *absent* (Q#PS2 fix): the
/// prior history resolver returned a *relative* `pmacs/…` for
/// `Some("")`, which would write state into the process's current
/// directory — a latent bug. Here an empty (or all-whitespace) value
/// falls through to `HOME`, and a `HOME` that is itself blank yields
/// `None` rather than a relative path.
#[must_use]
pub fn state_dir(xdg_state: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
// `XDG_STATE_HOME` must be an absolute path per the XDG spec; a
// relative value would root state at a *cwd-relative* `pmacs/...`
// (the same latent bug the empty case had). Ignore relative values
// and fall through to `HOME`, which likewise must be absolute.
if let Some(xdg) = xdg_state.filter(|s| !is_blank(s)) {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return Some(p.join("pmacs"));
}
}
home.filter(|s| !is_blank(s)).and_then(|h| {
let p = PathBuf::from(h);
p.is_absolute()
.then(|| p.join(".local").join("state").join("pmacs"))
})
}
/// Resolve the base state directory from the process environment.
///
/// A `PMACS_STATE_HOME` override wins over `XDG_STATE_HOME`/`HOME` when
/// set (and non-blank): `.../pmacs` under it. This is the redirect a
/// test harness, CI, or a privacy-conscious user points at a scratch
/// dir so persistence never touches the real `~/.local/state/pmacs`
/// (integration tests link the lib without `cfg(test)`, so the
/// startup wiring runs — the override is how they stay clean).
#[must_use]
pub fn user_state_dir() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("PMACS_STATE_HOME")
.as_deref()
.filter(|s| !is_blank(s))
{
// The override must also be absolute — a relative redirect would
// reintroduce the cwd-relative-state footgun.
let p = PathBuf::from(over);
if p.is_absolute() {
return Some(p.join("pmacs"));
}
}
state_dir(
std::env::var_os("XDG_STATE_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
)
}
/// True when `s` is empty or all ASCII/Unicode whitespace — an
/// unusable env value we treat as unset.
fn is_blank(s: &OsStr) -> bool {
match s.to_str() {
Some(text) => text.trim().is_empty(),
// Non-UTF-8 path bytes are a real (if exotic) directory name;
// only the empty OsStr counts as blank there.
None => s.is_empty(),
}
}
// ---------------------------------------------------------------------------
// Confined key→file store (Q#PS2)
// ---------------------------------------------------------------------------
use std::path::Path;
/// Validate a state key so `pmacs.state.*` can never escape the state
/// directory (Q#PS2 path confinement). A key is a **relative** path of
/// one or more `/`-separated components, each non-empty and drawn from
/// `[A-Za-z0-9._-]`, and no component may be `.` or `..`. Everything
/// else — an absolute path, an empty key, a `.`/`..` component, `//`,
/// or any other byte (separators, control chars, spaces) — is rejected.
///
/// Without this, a state binding meant to *avoid* raw `io.open` would
/// become an arbitrary read/write anywhere on disk.
///
/// # Errors
/// Returns a static message describing the first rule the key violates.
pub fn validate_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("state key is empty");
}
// Reject a leading `/` up front so the split below can't be fooled.
if name.starts_with('/') {
return Err("state key must be relative");
}
let mut components = 0usize;
for part in name.split('/') {
if part.is_empty() {
return Err("state key has an empty path component");
}
if part == "." || part == ".." {
return Err("state key may not contain `.` or `..`");
}
if !part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err("state key component has a disallowed character");
}
components += 1;
}
if components == 0 {
return Err("state key is empty");
}
Ok(())
}
/// Resolve a validated key to its path under `base`, refusing any
/// route that could escape the state directory.
///
/// Two guards beyond [`validate_name`]'s lexical rules:
/// 1. a `starts_with(base)` belt (redundant with `validate_name`, kept
/// as defense in depth);
/// 2. **symlink confinement** — every *existing* component the key adds
/// under `base` is `lstat`'d, and a symlink (live *or* broken) is
/// rejected. Without this, a `base/autosave` symlink pointing at
/// `/tmp/out` would let `state.write("autosave/x", …)` write outside
/// `base` — the lexical check alone can't catch it. `base` itself may
/// be a symlink (a dotfile-managed `~/.local/state`); only the
/// components the *key* contributes are guarded.
///
/// # Errors
/// Propagates [`validate_name`], or errors on an escaping / symlinked key.
pub fn resolve(base: &Path, name: &str) -> Result<PathBuf, &'static str> {
validate_name(name)?;
let path = base.join(name);
if !path.starts_with(base) {
return Err("state key escapes the state directory");
}
let mut cur = base.to_path_buf();
for part in name.split('/') {
cur.push(part);
if let Ok(meta) = std::fs::symlink_metadata(&cur)
&& meta.file_type().is_symlink()
{
return Err("state key traverses a symlink");
}
}
Ok(path)
}
/// Read a state file's contents, or `Ok(None)` when it does not exist.
///
/// # Errors
/// Invalid key, or an IO error other than not-found.
pub fn read(base: &Path, name: &str) -> Result<Option<String>, StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
match std::fs::read_to_string(&path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StateError::Io(e)),
}
}
/// Atomically write `content` to a state file (creating parents),
/// via [`crate::file_io::save_atomic`] — same durability the editor's
/// own saves get, and no raw `io.open`.
///
/// # Errors
/// Invalid key, or a save failure.
pub fn write(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> {
write_inner(base, name, content, None)
}
/// Like [`write`], but the parent directory is created `0700` and the
/// file written `0600` (Arc 3 Q#AS11).
///
/// Autosave stores **unsaved file contents**, a different class of secret
/// from saveplace's cursor offsets or recentf's path list. The default
/// path would give a new recovery file the umask default (typically
/// `0644`) and its directory `0755` — leaving a recovery copy of an
/// unsaved edit to a `0600` file *more exposed than the original*. The
/// mode is applied to the temp before the rename, so there is no window
/// at a laxer mode.
///
/// Permissions are Unix-only; elsewhere this is [`write`].
///
/// # Errors
/// Invalid key, or a save failure.
pub fn write_private(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> {
write_inner(base, name, content, Some(0o600))
}
/// True when a state file exists (no read, no parse).
///
/// # Errors
/// Invalid key.
pub fn exists(base: &Path, name: &str) -> Result<bool, StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
Ok(path.exists())
}
fn write_inner(
base: &Path,
name: &str,
content: &[u8],
mode: Option<u32>,
) -> Result<(), StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
if let Some(parent) = path.parent() {
create_dir_all_with_mode(parent, mode.map(|_| 0o700)).map_err(StateError::Io)?;
// A directory *we* own beneath the state root (e.g. `autosave/`)
// must actually be `0700`, even if a previous run — or a user —
// created it laxer. Otherwise the mode only applies to the dirs
// this call happened to create, and a pre-existing `0755`
// `autosave/` would still leak recovery-file names, sizes, and
// mtimes despite the `0600` contents.
//
// Never re-mode `base` itself: the state root is a directory the
// user may already have, shared with history/recentf/desktop.
if mode.is_some() && parent != base {
enforce_dir_mode(parent, 0o700).map_err(StateError::Io)?;
}
}
crate::file_io::save_atomic_with_mode(&path, content, mode).map_err(StateError::Save)?;
Ok(())
}
/// `create_dir_all`, birthing any directory this call creates at `mode`
/// (so it is never briefly world-readable).
fn create_dir_all_with_mode(dir: &Path, mode: Option<u32>) -> std::io::Result<()> {
#[cfg(unix)]
if let Some(m) = mode {
use std::os::unix::fs::DirBuilderExt as _;
return std::fs::DirBuilder::new()
.recursive(true)
.mode(m)
.create(dir);
}
#[cfg(not(unix))]
let _ = mode;
std::fs::create_dir_all(dir)
}
/// Tighten an existing directory to `mode` if it is laxer. No-op on
/// non-Unix, and cheap when already correct.
fn enforce_dir_mode(dir: &Path, mode: u32) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let current = std::fs::metadata(dir)?.permissions().mode() & 0o777;
if current != mode {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode))?;
}
}
#[cfg(not(unix))]
let _ = (dir, mode);
Ok(())
}
/// Read a state file's raw bytes, or `Ok(None)` when it does not exist.
///
/// [`read`] returns a `String` (`read_to_string`), which fails on
/// non-UTF-8 content. pmacs buffers hold arbitrary bytes, so an autosave
/// recovery file cannot be read that way (Arc 3 Q#AS4).
///
/// # Errors
/// Invalid key, or an IO error other than not-found.
pub fn read_bytes(base: &Path, name: &str) -> Result<Option<Vec<u8>>, StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
match std::fs::read(&path) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StateError::Io(e)),
}
}
/// Remove a state file. Missing file is success (idempotent).
///
/// # Errors
/// Invalid key, or an IO error other than not-found.
pub fn remove(base: &Path, name: &str) -> Result<(), StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(StateError::Io(e)),
}
}
/// Error from a confined state operation.
#[derive(Debug)]
pub enum StateError {
/// The key failed [`validate_name`].
Name(&'static str),
/// An underlying IO failure (read / remove).
Io(std::io::Error),
/// An atomic-write failure.
Save(crate::file_io::SaveError),
}
impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateError::Name(m) => write!(f, "invalid state key: {m}"),
StateError::Io(e) => write!(f, "{e}"),
StateError::Save(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for StateError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_xdg_state_home() {
let d = state_dir(Some(OsStr::new("/x/state")), Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(d, PathBuf::from("/x/state/pmacs"));
}
#[test]
fn falls_back_to_home_local_state() {
let d = state_dir(None, Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(d, PathBuf::from("/home/u/.local/state/pmacs"));
}
#[test]
fn none_when_neither_is_set() {
assert!(state_dir(None, None).is_none());
}
#[test]
fn validate_name_accepts_keys_and_subpaths() {
for ok in ["recentf", "places", "autosave/deadbeef", "a.b_c-1/x2"] {
assert!(validate_name(ok).is_ok(), "{ok:?} should be accepted");
}
}
#[test]
fn validate_name_rejects_escapes() {
for bad in [
"",
"/etc/passwd",
"..",
"../x",
"a/../b",
"a//b",
"a/",
"/a",
".",
"a/.",
"with space",
"tab\t",
"null\0",
"sub/../../x",
"..\\x",
] {
assert!(validate_name(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn resolve_stays_under_base() {
let base = PathBuf::from("/state/pmacs");
assert_eq!(
resolve(&base, "autosave/x").unwrap(),
PathBuf::from("/state/pmacs/autosave/x")
);
assert!(resolve(&base, "../escape").is_err());
}
#[cfg(unix)]
#[test]
fn resolve_rejects_symlink_components() {
let root = std::env::temp_dir().join(format!("pmacs-symlink-{}", std::process::id()));
let base = root.join("pmacs");
let outside = root.join("outside");
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// A live symlink `base/evil -> outside` must be refused (else a
// write through it escapes the state dir).
let evil = base.join("evil");
std::os::unix::fs::symlink(&outside, &evil).unwrap();
assert!(resolve(&base, "evil/x").is_err(), "live symlink escape");
assert!(write(&base, "evil/x", b"nope").is_err());
assert!(!outside.join("x").exists(), "nothing was written outside");
// A broken symlink component is also refused (lstat sees it).
let broken = base.join("broken");
std::os::unix::fs::symlink(root.join("does-not-exist"), &broken).unwrap();
assert!(resolve(&base, "broken/y").is_err(), "broken symlink escape");
// A plain subdir is fine.
assert!(resolve(&base, "autosave/ok").is_ok());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn relative_xdg_and_home_are_ignored() {
// A relative XDG_STATE_HOME (spec violation) must not root state
// at a cwd-relative path; it falls through to HOME.
let d = state_dir(Some(OsStr::new("relstate")), Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(d, PathBuf::from("/home/u/.local/state/pmacs"));
// Relative XDG and relative HOME → None, never a relative root.
assert!(state_dir(Some(OsStr::new("rel")), Some(OsStr::new("relhome"))).is_none());
}
#[test]
fn write_read_remove_round_trip() {
let dir = std::env::temp_dir().join(format!("pmacs-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(read(&dir, "recentf").unwrap().is_none(), "absent → None");
write(&dir, "recentf", b"a\nb\n").unwrap();
assert_eq!(read(&dir, "recentf").unwrap().as_deref(), Some("a\nb\n"));
// Subpath creates its parent dir.
write(&dir, "autosave/h1", b"x").unwrap();
assert_eq!(read(&dir, "autosave/h1").unwrap().as_deref(), Some("x"));
remove(&dir, "recentf").unwrap();
assert!(read(&dir, "recentf").unwrap().is_none(), "removed → None");
remove(&dir, "recentf").unwrap(); // idempotent
// An invalid key errors rather than escaping.
assert!(read(&dir, "../x").is_err());
assert!(write(&dir, "/abs", b"x").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_bytes_round_trips_non_utf8() {
let dir = std::env::temp_dir().join(format!("pmacs-bytes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Invalid UTF-8 — what `read` (read_to_string) would choke on.
let raw = [0xffu8, 0xfe, b'\n', 0x00, b'a'];
write(&dir, "blob", &raw).unwrap();
assert_eq!(read_bytes(&dir, "blob").unwrap().as_deref(), Some(&raw[..]));
assert!(read(&dir, "blob").is_err(), "read_to_string rejects it");
assert!(read_bytes(&dir, "absent").unwrap().is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn write_private_uses_0700_dir_and_0600_file() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!("pmacs-priv-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
write_private(&dir, "autosave/secret", b"unsaved contents").unwrap();
let file = dir.join("autosave").join("secret");
let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
assert_eq!(fmode, 0o600, "recovery file is 0600, not umask default");
let dmode = std::fs::metadata(dir.join("autosave"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dmode, 0o700, "autosave dir is 0700");
// Rewriting keeps the private mode (save_atomic inherits it).
write_private(&dir, "autosave/secret", b"more").unwrap();
let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
assert_eq!(fmode, 0o600);
// The plain `write` path is unchanged (umask default, not 0600).
write(&dir, "plain", b"x").unwrap();
let pmode = std::fs::metadata(dir.join("plain"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_ne!(pmode, 0o600, "plain write keeps existing behavior");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn blank_xdg_falls_through_to_home_not_a_relative_path() {
// The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT
// produce a relative `pmacs/...` (which would write into the
// cwd). It falls through to HOME instead.
for blank in ["", " ", "\t"] {
let d = state_dir(Some(OsStr::new(blank)), Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(
d,
PathBuf::from("/home/u/.local/state/pmacs"),
"blank XDG {blank:?} must fall through to HOME"
);
assert!(d.is_absolute(), "state dir is never relative");
}
// Blank XDG and no HOME → None, not a relative path.
assert!(state_dir(Some(OsStr::new("")), None).is_none());
// A blank HOME is likewise unusable.
assert!(state_dir(None, Some(OsStr::new(" "))).is_none());
}
}