openmoat-core 0.9.0

Policy model and decision engine for OpenMoat. Pure: no I/O, no OS calls.
Documentation
//! Where a copy or a move writes (`cp`, `mv`, `ln`, `rsync`, `scp`, `ditto`,
//! `pax -rw`).
//!
//! Into a directory, the command writes the entry named after each source, not
//! the directory: `cp f ~/` writes `~/f`. A destination is a directory when it
//! ends with `/`, is `~`, `.`, `..` or `$HOME`, or is given with `-t DIR` /
//! `--target-directory`. Any other destination (`mv dir newname`) may be
//! replaced itself, so that word stays the write, as it does under `-T`.
//!
//! When an entry name cannot be read from the command line, any entry of the
//! directory may be replaced, so the directory itself stays the write: a source
//! ending with `/` (`rsync -a dist/ ~/` copies the contents of `dist`), a
//! directory word (`cp -r ~ /backup/`), one the shell expands (`$X`, `*`,
//! `{a,b}`) or a remote spec (`scp host:f ~/`). Every operand that is not the
//! destination counts as a source, option values included: a spurious source
//! only adds a write.

mod links;

use super::tables::WRITE_LAST_PATH;
use crate::paths;

/// Programs that take GNU's `-t DIR` / `--target-directory` and `-T`.
const TARGET_DIRECTORY_PROGRAMS: &[&str] = &["cp", "mv", "ln"];

/// Words that name a directory whatever follows them.
const DIRECTORY_WORDS: &[&str] = &["~", ".", "..", "$HOME", "${HOME}"];

/// Programs to which `host:path` is on another machine (`scp f host:/tmp/`).
const REMOTE_PROGRAMS: &[&str] = &["rsync", "scp"];

/// Characters that hide the entry name: the shell expands the word into names
/// it does not show, or `:` makes it a remote spec (`host:f`).
const UNKNOWN_NAME: &[char] = &['$', '`', '*', '?', '[', '{', ':'];

/// The destination of a copy or a move.
#[derive(Debug)]
pub(super) struct Destination<'a> {
    /// Index in argv of the word that holds it (`DIR`, or `-tDIR` itself).
    pub index: usize,
    pub word: &'a str,
    directory: bool,
}

/// The destination of `argv` when `program` copies or moves, if any.
pub(super) fn destination<'a>(argv: &'a [String], program: &str) -> Option<Destination<'a>> {
    if !(WRITE_LAST_PATH.contains(&program) || program == "mv")
        || (program == "pax" && !pax_copies(argv))
    {
        return None;
    }
    let gnu = TARGET_DIRECTORY_PROGRAMS.contains(&program);
    if gnu && let Some(target) = target_option(argv) {
        return Some(target);
    }
    let index = (1..argv.len()).rev().find(|&i| !argv[i].starts_with('-'))?;
    let word = argv[index].as_str();
    if REMOTE_PROGRAMS.contains(&program) && is_remote(word) {
        return None;
    }
    let no_target = gnu && argv[1..index].iter().any(|a| is_no_target(a));
    let directory = !no_target
        && (word.ends_with('/')
            || word.ends_with("/.")
            || word.ends_with("/..")
            || DIRECTORY_WORDS.contains(&word));
    Some(Destination {
        index,
        word,
        directory,
    })
}

/// The paths written at `dest` when `sources` are copied or moved there.
pub(super) fn written(dest: &Destination<'_>, sources: &[&str]) -> Vec<String> {
    let names: Option<Vec<&str>> = sources.iter().map(|s| entry_name(s)).collect();
    match names {
        Some(names) if dest.directory && !names.is_empty() => {
            let dir = dest.word.trim_end_matches('/');
            names.iter().map(|name| format!("{dir}/{name}")).collect()
        }
        _ => vec![dest.word.to_owned()],
    }
}

/// Everything a copy, a move or a link writes: [`written`], and when it makes
/// hard links, each file it links to. A hard link is a second name for the
/// same file, so a later write through the new name (a project file to every
/// check) writes the original: `ln ~/.claude/settings.json s.json`, then an
/// edit of `s.json`, rewrites Claude Code's hooks. So the original is a write
/// too, and a deny on writing it (`kernel-self`) holds. Hard links come from
/// `ln` without `-s`, `link`, `cp -l` / `--link`, and the files under each
/// `rsync --link-dest DIR` (relative to the destination, as rsync reads it).
pub(super) fn writes(
    argv: &[String],
    program: &str,
    dest: &Destination<'_>,
    sources: &[&str],
) -> Vec<String> {
    let mut out = written(dest, sources);
    if matches!(program, "ditto" | "pax") {
        out.extend(merged(dest, program, sources));
    }
    if links::hard_links(argv, program) {
        out.extend(sources.iter().map(|s| (*s).to_owned()));
    }
    if program == "rsync" {
        out.extend(link_dests(argv).map(|dir| relative_to(dest.word, dir)));
    }
    out
}

/// What `ditto` and `pax -rw` write besides [`written`]: `ditto SRC DST`
/// merges the contents of a source directory into `DST`, and `pax -rw SRC DIR`
/// writes each source under its path as given (`DIR/SRC`), so the destination
/// itself is written and, for `pax`, each source's path below it.
fn merged(dest: &Destination<'_>, program: &str, sources: &[&str]) -> Vec<String> {
    let mut out = vec![dest.word.to_owned()];
    if program == "pax" {
        let dir = dest.word.trim_end_matches('/');
        out.extend(sources.iter().map(|source| {
            let relative = source.trim_start_matches("./").trim_start_matches('/');
            format!("{dir}/{relative}")
        }));
    }
    out
}

/// `pax` copies only with both `-r` and `-w` (`pax -rw SRC DIR`); otherwise it
/// reads or writes an archive and its operands are patterns or files it reads.
fn pax_copies(argv: &[String]) -> bool {
    let options = || {
        argv.iter()
            .skip(1)
            .filter(|a| a.starts_with('-') && !a.starts_with("--"))
    };
    options().any(|a| a.contains('r')) && options().any(|a| a.contains('w'))
}

/// Each `DIR` of rsync's `--link-dest=DIR` or `--link-dest DIR` (or a prefix
/// of the option longer than `--link`, which `--links` shares).
fn link_dests(argv: &[String]) -> impl Iterator<Item = &str> {
    let name = |long: &str| long.starts_with("link-") && "link-dest".starts_with(long);
    argv.iter()
        .enumerate()
        .skip(1)
        .take_while(|(_, a)| *a != "--")
        .filter_map(move |(i, arg)| {
            let long = arg.strip_prefix("--")?;
            match long.split_once('=') {
                Some((key, dir)) => name(key).then_some(dir),
                None => argv.get(i + 1).map(String::as_str).filter(|_| name(long)),
            }
        })
}

/// `dir` as rsync reads a relative `--link-dest`: inside the destination.
fn relative_to(dest: &str, dir: &str) -> String {
    if paths::is_absolute(dir) || dir.starts_with(['~', '$']) {
        dir.to_owned()
    } else {
        format!("{}/{dir}", dest.trim_end_matches('/'))
    }
}

/// The name a source gets inside a directory, when the command line shows it.
fn entry_name(source: &str) -> Option<&str> {
    let name = source.rsplit('/').next().unwrap_or(source);
    let known = !name.is_empty() && !DIRECTORY_WORDS.contains(&name);
    (known && !source.contains(UNKNOWN_NAME)).then_some(name)
}

/// `-t DIR`, `-tDIR`, `-rt DIR`, `--target-directory=DIR` or an unambiguous
/// prefix of it (`--target DIR`), up to `--`.
fn target_option(argv: &[String]) -> Option<Destination<'_>> {
    for (i, arg) in argv.iter().enumerate().skip(1) {
        if arg == "--" {
            return None;
        }
        let inline = if let Some(long) = arg.strip_prefix("--") {
            let (name, value) = long
                .split_once('=')
                .map_or((long, None), |(n, v)| (n, Some(v)));
            if name.is_empty() || !"target-directory".starts_with(name) {
                continue;
            }
            value
        } else if let Some((_, rest)) = arg.strip_prefix('-').and_then(|s| s.split_once('t')) {
            Some(rest).filter(|r| !r.is_empty())
        } else {
            continue;
        };
        let (index, word) = match inline {
            Some(value) => (i, value),
            None => (i + 1, argv.get(i + 1)?.as_str()),
        };
        return Some(Destination {
            index,
            word,
            directory: true,
        });
    }
    None
}

/// A `:` before the first `/` names a host (`host:/tmp/`, `rsync://h/m`), unless
/// it ends a drive letter (`C:/backup/`).
fn is_remote(word: &str) -> bool {
    let head = word.split('/').next().unwrap_or(word);
    head.contains(':') && !paths::is_absolute(word)
}

/// `-T` / `--no-target-directory`: the destination is replaced even if it is a
/// directory.
fn is_no_target(arg: &str) -> bool {
    match arg.strip_prefix("--") {
        Some(long) => long.len() > 2 && "no-target-directory".starts_with(long),
        None => arg.starts_with('-') && arg.contains('T'),
    }
}

#[cfg(test)]
mod tests {
    use super::{destination, written};

    /// [`written`] or, with `all`, [`super::writes`] of `cmd`, its sources
    /// taken as `commands.rs` takes them.
    fn of(cmd: &str, all: bool) -> Vec<String> {
        let argv: Vec<String> = cmd.split(' ').map(str::to_owned).collect();
        let dest = destination(&argv, &argv[0]).expect("a copy");
        let end = argv.iter().position(|a| a == "--");
        let sources: Vec<&str> = argv
            .iter()
            .enumerate()
            .skip(1)
            .filter(|(i, a)| {
                *i != dest.index
                    && *a != "--"
                    && (end.is_some_and(|e| *i > e) || !a.starts_with('-'))
            })
            .map(|(_, a)| a.as_str())
            .collect();
        if all {
            super::writes(&argv, &argv[0], &dest, &sources)
        } else {
            written(&dest, &sources)
        }
    }

    fn writes(cmd: &str) -> Vec<String> {
        of(cmd, false)
    }

    #[test]
    fn a_hard_link_also_writes_the_file_it_links_to() {
        assert_eq!(
            of("ln ~/.claude/settings.json s.json", true),
            ["s.json", "~/.claude/settings.json"]
        );
        assert_eq!(of("ln -f -- ~/.moat/x y", true), ["y", "~/.moat/x"]);
        assert_eq!(of("ln -t . ~/.moat/x", true), ["./x", "~/.moat/x"]);
        assert_eq!(of("link ~/.moat/x y", true), ["y", "~/.moat/x"]);
        assert_eq!(of("cp -al ~/.moat ./m", true), ["./m", "~/.moat"]);
        assert_eq!(of("cp --li ~/.moat/x y", true), ["y", "~/.moat/x"]);
        assert_eq!(
            of("rsync -a --link-dest=/h/.moat src/ out/", true),
            ["out/", "/h/.moat"]
        );
        assert_eq!(
            of("rsync -a --link-dest ../prev src/ out/", true),
            ["out/", "out/../prev"]
        );
    }

    #[test]
    fn a_symbolic_link_or_a_plain_copy_writes_only_its_destination() {
        for cmd in [
            "ln -s ~/.moat/x y",
            "ln -sf ~/.moat/x y",
            "ln --sym ~/.moat/x y",
            "cp ~/.moat/x y",
            "cp -L ~/.moat/x y",
            "cp --links-not-an-option ~/.moat/x y",
            "rsync -a --links src/ out/",
        ] {
            assert_eq!(of(cmd, true), of(cmd, false), "{cmd}");
        }
        // the letters after `-t` and `-S` are their value, not options
        assert_eq!(of("ln -s -S.l ~/.moat/x y", true), ["y"]);
        assert_eq!(of("cp -t/tmp/l ~/.moat/x", true), ["/tmp/l/x"]);
    }

    #[test]
    fn into_a_directory_writes_the_entry() {
        assert_eq!(writes("cp f ~/"), ["~/f"]);
        assert_eq!(writes("mv src/a.rs ~"), ["~/a.rs"]);
        assert_eq!(writes("cp -r assets /"), ["/assets"]);
        assert_eq!(writes("cp a b ./out/"), ["./out/a", "./out/b"]);
        assert_eq!(writes("rsync -a dist ~/"), ["~/dist"]);
        assert_eq!(writes("cp -t ~ f"), ["~/f"]);
        assert_eq!(writes("cp --target-directory=~ f"), ["~/f"]);
        assert_eq!(writes("mv --target ~ f"), ["~/f"]);
        assert_eq!(writes("cp -rt ~ f"), ["~/f"]);
    }

    #[test]
    fn otherwise_the_destination_itself() {
        assert_eq!(writes("mv dir newname"), ["newname"]);
        assert_eq!(writes("cp -T f ~/"), ["~/"]);
        assert_eq!(writes("mv --no-target-directory f ~"), ["~"]);
        assert_eq!(writes("rsync -a dist/ ~/"), ["~/"]);
        assert_eq!(writes("cp -r ~ /backup/"), ["/backup/"]);
        assert_eq!(writes("cp * ~/"), ["~/"]);
        assert_eq!(writes("cp $X ~/"), ["~/"]);
        assert_eq!(writes("cp ~/"), ["~/"]);
        assert_eq!(writes("scp host:.zshrc notes ~/"), ["~/"]);
        assert!(destination(&["rm".into(), "~".into()], "rm").is_none());
    }

    #[test]
    fn a_remote_destination_is_no_local_write() {
        for cmd in [
            "scp key host:/tmp/",
            "scp key u@host:",
            "rsync -a d host::m/",
            "rsync d rsync://host/m",
        ] {
            let argv: Vec<String> = cmd.split(' ').map(str::to_owned).collect();
            assert!(destination(&argv, &argv[0]).is_none(), "{cmd}");
        }
        assert_eq!(writes("scp f C:/backup/"), ["C:/backup/f"]);
        assert_eq!(writes("cp f host:/tmp/"), ["host:/tmp/f"]);
    }
}