Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

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"));
    }
}