skade-flight/src/dataroot.rs
//! **Can this process write the catalog it is about to open?** — asked by name,
//! before redb answers with a permission error that names nothing.
//!
//! skade-flight is a read-only door, and it still needs WRITE access to
//! `catalog.redb`: redb 2 has no read-only open (`Database::create` is the one
//! constructor `skade-katalog` has), and the timed refresh re-reads through the
//! same handle. So the data root must be owned by the uid the door runs as. On
//! a systemd host `--install` hands the root to the service user and the question
//! never arises. In a CONTAINER it arises every time: a host directory bound into
//! the container shows up as the HOST's uid:gid, and the image's service user
//! (knut's pattern: `useradd --uid 10055`, `USER knut`) is not that uid. The
//! measured symptom (first Windows/ODBC test, 2026-09-09) was a redb open error
//! that led the tester to run the container as root "to get around the bind
//! mount" — which the README forbids but did not diagnose.
//!
//! [`write_refusal`] turns that into a sentence naming the file, its owner, this
//! uid and the two correct fixes. It is asked in the binary before
//! `Warehouse::open`; the wording is a pure function of the facts so it is
//! tested without a filesystem.
use std::path::{Path, PathBuf};
/// The real uid this process runs as, read from `/proc/self/status` (`Uid:`
/// line, real uid first). `None` where `/proc` is not mounted — then no refusal
/// is worded, redb's own error stands.
pub fn current_uid() -> Option<u32> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
parse_uid_line(&status)
}
fn parse_uid_line(status: &str) -> Option<u32> {
status
.lines()
.find_map(|l| l.strip_prefix("Uid:"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|s| s.parse().ok())
}
/// What a stat of the catalog (or, when it does not exist yet, of the warehouse
/// directory that must receive it) found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Owner {
/// The path that was examined — `catalog.redb`, or the directory when the
/// file is not there yet.
pub path: PathBuf,
pub uid: u32,
pub gid: u32,
/// Permission bits (`0o640` …).
pub mode: u32,
}
/// Stat the catalog file, or the directory if the file is absent.
#[cfg(unix)]
pub fn owner_of(warehouse: &Path) -> Option<Owner> {
use std::os::unix::fs::MetadataExt;
let file = warehouse.join("catalog.redb");
let (path, md) = match std::fs::metadata(&file) {
Ok(md) => (file, md),
Err(_) => (warehouse.to_path_buf(), std::fs::metadata(warehouse).ok()?),
};
Some(Owner { path, uid: md.uid(), gid: md.gid(), mode: md.mode() & 0o777 })
}
/// **The one-line verdict.** `None` ⇒ this uid may write; `Some(text)` ⇒ it may
/// not, and `text` says who owns the path, who is asking, and the two fixes —
/// never "run as root".
///
/// Pure: the caller passes the facts. Owner-uid match ⇒ writable when the owner
/// bit is set; otherwise the world-write bit is the only thing left to check
/// (group membership is not known here, so a group-writable path owned by
/// another uid is reported honestly as "maybe: your groups decide").
pub fn write_refusal(owner: &Owner, my_uid: u32) -> Option<String> {
let user_w = owner.mode & 0o200 != 0;
let group_w = owner.mode & 0o020 != 0;
let other_w = owner.mode & 0o002 != 0;
if owner.uid == my_uid && user_w {
return None;
}
if other_w {
return None;
}
let p = owner.path.display();
let mut m = format!(
"skade-flight runs as uid {my_uid} and cannot write {p} (owner {}:{} mode {:o}); redb needs write \
access to the catalog even to SERVE READS, and this door refreshes through the same handle.",
owner.uid, owner.gid, owner.mode
);
if owner.uid != my_uid && group_w {
m.push_str(&format!(
" The path is group-writable (gid {}): if uid {my_uid} is in that group this will work and the \
error you are about to see is something else.",
owner.gid
));
return Some(m);
}
m.push_str(
" Give the data root to this uid: on a systemd host `skade-flight --install` does that for the \
service user; in a container, bind the warehouse with `podman run --userns keep-id` (rootless) \
or keep it on a named volume owned by the image's service user. Do NOT run the container as root \
to get past a bind mount.",
);
Some(m)
}
/// [`owner_of`] + [`current_uid`] + [`write_refusal`], for the binary.
#[cfg(unix)]
pub fn refusal_for(warehouse: &Path) -> Option<String> {
let owner = owner_of(warehouse)?;
let uid = current_uid()?;
write_refusal(&owner, uid)
}
#[cfg(test)]
mod tests {
use super::*;
fn owner(uid: u32, mode: u32) -> Owner {
Owner { path: PathBuf::from("/data/warehouse/catalog.redb"), uid, gid: uid, mode }
}
/// **The container shape: a host-owned bind mount, an image service user.**
/// uid 10055 (knut's pattern) meets a catalog owned by uid 1000 at 0640. The
/// refusal must name the file, both uids, redb's need, and both fixes — and
/// must never suggest root. RED direction: return `None` whenever
/// `owner.uid != my_uid` and the first assertion fails; drop the fixes from
/// the text and the last two fail.
#[test]
fn a_host_owned_catalog_under_an_image_user_is_refused_by_name_with_both_fixes() {
let m = write_refusal(&owner(1000, 0o640), 10055).expect("uid 10055 cannot write a 1000:1000 0640 file");
assert!(m.contains("uid 10055") && m.contains("owner 1000:1000 mode 640"), "{m}");
assert!(m.contains("/data/warehouse/catalog.redb"), "{m}");
assert!(m.contains("redb needs write access") && m.contains("SERVE READS"), "{m}");
assert!(m.contains("--userns keep-id") && m.contains("named volume"), "container fix: {m}");
assert!(m.contains("--install"), "systemd fix: {m}");
assert!(m.contains("Do NOT run the container as root"), "{m}");
}
/// The owner with the owner-write bit, or a world-writable path, passes; an
/// owner whose own write bit is off does not.
#[test]
fn the_owner_and_a_world_writable_path_pass_and_a_stripped_owner_bit_does_not() {
assert_eq!(write_refusal(&owner(1000, 0o640), 1000), None);
assert_eq!(write_refusal(&owner(1000, 0o666), 10055), None);
assert!(write_refusal(&owner(1000, 0o440), 1000).is_some(), "0440 owned by me is still unwritable");
}
/// Group-writable by a foreign owner is a MAYBE, said as one.
#[test]
fn a_group_writable_foreign_path_is_a_maybe_not_a_verdict() {
let m = write_refusal(&owner(1000, 0o660), 10055).unwrap();
assert!(m.contains("group-writable (gid 1000)") && m.contains("in that group"), "{m}");
assert!(!m.contains("--userns"), "no fix is prescribed for a maybe: {m}");
}
#[test]
fn the_uid_line_is_the_real_uid() {
assert_eq!(parse_uid_line("Name:\tx\nUid:\t111\t111\t111\t111\nGid:\t1 1 1 1\n"), Some(111));
assert_eq!(parse_uid_line("Name:\tx\n"), None);
}
/// Live stat on a temp dir: a directory this test owns is writable; when the
/// file exists it is the file that is examined.
#[cfg(unix)]
#[test]
fn owner_of_examines_the_file_when_present_and_the_dir_otherwise() {
let dir = tempfile::tempdir().unwrap();
let o = owner_of(dir.path()).unwrap();
assert_eq!(o.path, dir.path());
assert_eq!(refusal_for(dir.path()), None, "my own temp dir");
std::fs::write(dir.path().join("catalog.redb"), b"x").unwrap();
let o = owner_of(dir.path()).unwrap();
assert_eq!(o.path, dir.path().join("catalog.redb"));
}
}