513 lines
19 KiB
Rust
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());
|
|
}
|
|
}
|