airs-cli 0.6.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! Bounded input-file reads for the M4 commands (`--config`, `--prompts`; DESIGN §13.2,
//! D103).
//!
//! A path is used as typed (relative to the working directory) and followed through
//! symlinks. It must name a regular file within the size limit, read whole, in UTF-8.
//! Every failure has a fixed message that names the path as typed (terminal-safe when
//! printed) and echoes neither the OS error nor any file content.

use crate::error::CliError;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::path::Path;

/// The `--config` limit (1 MiB).
pub const CONFIG_LIMIT: u64 = 1_048_576;
/// The `--prompts` limit (16 MiB).
pub const PROMPTS_LIMIT: u64 = 16_777_216;

/// The path as the messages show it (lossy for non-UTF-8 bytes).
pub fn shown(path: &OsStr) -> String {
    path.to_string_lossy().into_owned()
}

/// The red team `--script-file` limit (4 MiB, DESIGN §15.3).
pub const SCRIPT_LIMIT: u64 = 4 * 1_048_576;
/// The `prompt-sets upload` limit (7 MiB, below the SDK's 8 MiB request bound).
pub const UPLOAD_LIMIT: u64 = 7 * 1_048_576;

fn unreadable(path: &OsStr, limit: u64) -> CliError {
    CliError::operational(format!(
        "Cannot read {} (missing, not a regular file, unreadable or larger than {} MiB)",
        shown(path),
        limit / 1_048_576
    ))
}

/// Read the whole regular file at `path` (symlinks followed) of at most `limit` bytes.
pub fn read_bytes(path: &OsStr, limit: u64) -> Result<Vec<u8>, CliError> {
    let fail = || unreadable(path, limit);
    let target = Path::new(path);
    // A FIFO or device is refused before it is opened (opening a FIFO blocks).
    let metadata = std::fs::metadata(target).map_err(|_| fail())?;
    if !metadata.is_file() || metadata.len() > limit {
        return Err(fail());
    }
    let file = File::open(target).map_err(|_| fail())?;
    // The file that was opened must still be a regular file within the limit.
    let opened = file.metadata().map_err(|_| fail())?;
    if !opened.is_file() || opened.len() > limit {
        return Err(fail());
    }
    let mut bytes = Vec::new();
    file.take(limit + 1)
        .read_to_end(&mut bytes)
        .map_err(|_| fail())?;
    if bytes.len() as u64 > limit {
        return Err(fail());
    }
    Ok(bytes)
}

/// Read the whole file at `path` as UTF-8 text of at most `limit` bytes.
pub fn read_text(path: &OsStr, limit: u64) -> Result<String, CliError> {
    String::from_utf8(read_bytes(path, limit)?)
        .map_err(|_| CliError::operational(format!("{} is not valid UTF-8", shown(path))))
}

/// Read a `--config` file: one JSON value that is an object (the D44 classes count as
/// invalid JSON).
pub fn read_config(path: &OsStr) -> Result<serde_json::Map<String, serde_json::Value>, CliError> {
    let text = read_text(path, CONFIG_LIMIT)?;
    let value: serde_json::Value = serde_json::from_str(&text)
        .map_err(|_| CliError::operational(format!("{} is not valid JSON", shown(path))))?;
    match value {
        serde_json::Value::Object(object) => Ok(object),
        _ => Err(CliError::operational(format!(
            "{} must contain a JSON object",
            shown(path)
        ))),
    }
}

/// Create `path` and write `bytes` to it whole (`topics sample --output-file`, §13.11,
/// D114): `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC`, mode 0644 minus the
/// umask. An existing path (a symlink included) is never opened or changed; a write
/// that fails part-way removes the file it created.
pub fn write_new(path: &OsStr, bytes: &[u8]) -> Result<(), CliError> {
    use rustix::fs::{Mode, OFlags};
    use std::io::Write;
    let target = Path::new(path);
    let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    let cannot_write = || {
        CliError::operational(format!(
            "Cannot write {}; check that its directory exists and is writable",
            shown(path)
        ))
    };
    let fd = match rustix::fs::open(target, flags, Mode::from_raw_mode(0o644)) {
        Ok(fd) => fd,
        Err(rustix::io::Errno::EXIST) => {
            return Err(CliError::operational(format!(
                "{} already exists; choose another --output-file",
                shown(path)
            )));
        }
        Err(_) => return Err(cannot_write()),
    };
    let mut file = File::from(fd);
    if file.write_all(bytes).and_then(|()| file.flush()).is_err() {
        drop(file);
        let _ = std::fs::remove_file(target);
        return Err(cannot_write());
    }
    Ok(())
}

/// Whether a [`publish_new`] also synced its directory.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Published {
    Durable,
    /// Linked into place, but the directory fsync failed: print
    /// [`crate::store::atomic::dir_sync_warning`].
    DirectoryNotSynced,
}

/// `<path> already exists; choose another <flag>` (nothing is overwritten).
pub fn already_exists(path: &Path, flag: &str) -> CliError {
    CliError::operational(format!(
        "{} already exists; choose another {flag}",
        shown(path.as_os_str())
    ))
}

/// Refuse a destination where anything exists (`lstat`: a dangling symlink included),
/// before the tenant is read and any request is sent (§15.9, §15.18).
pub fn refuse_existing(path: &Path, flag: &str) -> Result<(), CliError> {
    match std::fs::symlink_metadata(path) {
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        _ => Err(already_exists(path, flag)),
    }
}

/// Publish `bytes` at `path` without ever overwriting (DESIGN §15.18 "Publishing", the TS
/// `writeReportFile`): create `.airs-<16 hex>.tmp` in the destination directory with
/// `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC`, `fchmod` 0600, write, `fsync`,
/// close; `link` it to `path` (an existing path, a symlink included, is
/// [`already_exists`]); unlink the temporary; `fsync` the directory. Any other failure is
/// `Cannot write <path>; check that its directory exists and is writable`, the temporary
/// removed. A reader never sees a partial file.
pub fn publish_new(path: &Path, bytes: &[u8], flag: &str) -> Result<Published, CliError> {
    use rustix::fs::{Mode, OFlags};
    use std::io::Write;
    let cannot_write = || {
        CliError::operational(format!(
            "Cannot write {}; check that its directory exists and is writable",
            shown(path.as_os_str())
        ))
    };
    let dir = match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
        _ => std::path::PathBuf::from("."),
    };
    let temp = dir.join(format!(
        ".airs-{}.tmp",
        &uuid::Uuid::new_v4().simple().to_string()[..16]
    ));
    let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    let fd =
        rustix::fs::open(&temp, flags, Mode::from_raw_mode(0o600)).map_err(|_| cannot_write())?;
    let written = rustix::fs::fchmod(&fd, Mode::from_raw_mode(0o600))
        .map_err(std::io::Error::from)
        .and_then(|()| {
            let mut file = File::from(fd);
            file.write_all(bytes)?;
            file.flush()?;
            file.sync_all()
        });
    if written.is_err() {
        let _ = std::fs::remove_file(&temp);
        return Err(cannot_write());
    }
    let linked = rustix::fs::link(&temp, path);
    let _ = std::fs::remove_file(&temp);
    match linked {
        Ok(()) => {}
        Err(rustix::io::Errno::EXIST) => return Err(already_exists(path, flag)),
        Err(_) => return Err(cannot_write()),
    }
    if crate::store::atomic::sync_dir(&dir) {
        Ok(Published::Durable)
    } else {
        Ok(Published::DirectoryNotSynced)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::OsString;
    use std::path::PathBuf;

    /// A fresh directory under the system temp directory, removed when dropped.
    struct TempDir(PathBuf);

    impl TempDir {
        fn new() -> Self {
            let dir = std::env::temp_dir()
                .join(format!("airs-files-test-{}", uuid::Uuid::new_v4().simple()));
            std::fs::create_dir_all(&dir).unwrap();
            Self(dir)
        }
        fn path(&self) -> &Path {
            &self.0
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    fn temp(name: &str, bytes: &[u8]) -> (TempDir, OsString) {
        let dir = TempDir::new();
        let path = dir.path().join(name);
        std::fs::write(&path, bytes).unwrap();
        (dir, path.into_os_string())
    }

    fn message(result: Result<impl std::fmt::Debug, CliError>) -> String {
        match result {
            Err(CliError::Operational(failure)) => failure.message,
            other => panic!("expected an operational error, got {other:?}"),
        }
    }

    #[test]
    fn config_files_are_bounded_utf8_json_objects() {
        let (_dir, path) = temp("ok.json", br#"{"topic_name": "t", "n": 1.0}"#);
        let object = read_config(&path).unwrap();
        assert_eq!(object["topic_name"], "t");
        let shown = path.to_string_lossy().into_owned();
        for (bytes, expected) in [
            (&b"[1]"[..], format!("{shown} must contain a JSON object")),
            (b"\"x\"", format!("{shown} must contain a JSON object")),
            (b"{", format!("{shown} is not valid JSON")),
            (b"{} {}", format!("{shown} is not valid JSON")),
            (br#"{"a": "\ud800"}"#, format!("{shown} is not valid JSON")),
            (br#"{"a": 1e999}"#, format!("{shown} is not valid JSON")),
            (b"{\"a\": \"\xff\"}", format!("{shown} is not valid UTF-8")),
        ] {
            std::fs::write(&path, bytes).unwrap();
            assert_eq!(message(read_config(&path)), expected, "{bytes:?}");
        }
    }

    #[test]
    fn new_files_are_created_exclusively() {
        let dir = TempDir::new();
        let path = dir.path().join("out.csv");
        write_new(path.as_os_str(), b"a,b\n").unwrap();
        assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n");
        let shown = path.to_string_lossy().into_owned();
        assert_eq!(
            message(write_new(path.as_os_str(), b"x")),
            format!("{shown} already exists; choose another --output-file")
        );
        assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n", "untouched");
        let link = dir.path().join("link.csv");
        std::os::unix::fs::symlink(dir.path().join("dangling"), &link).unwrap();
        assert!(
            message(write_new(link.as_os_str(), b"x"))
                .ends_with("already exists; choose another --output-file")
        );
        assert!(
            !dir.path().join("dangling").exists(),
            "the link is not followed"
        );
        let missing = dir.path().join("nodir").join("x.csv");
        assert_eq!(
            message(write_new(missing.as_os_str(), b"x")),
            format!(
                "Cannot write {}; check that its directory exists and is writable",
                missing.to_string_lossy()
            )
        );
    }

    #[test]
    fn published_files_are_private_exclusive_and_leave_no_temporary() {
        use std::os::unix::fs::PermissionsExt;
        let dir = TempDir::new();
        let path = dir.path().join("t.csv");
        assert_eq!(
            publish_new(&path, b"a,b\n", "--output-file").unwrap(),
            Published::Durable
        );
        assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n");
        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600);
        let shown = path.to_string_lossy().into_owned();
        assert_eq!(
            message(publish_new(&path, b"x", "--output-file")),
            format!("{shown} already exists; choose another --output-file")
        );
        assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n", "untouched");
        let link = dir.path().join("link.csv");
        std::os::unix::fs::symlink(dir.path().join("dangling"), &link).unwrap();
        assert!(
            message(refuse_existing(&link, "--output-dir"))
                .ends_with("choose another --output-dir")
        );
        assert!(message(publish_new(&link, b"x", "--output-file")).contains("already exists"));
        assert!(
            !dir.path().join("dangling").exists(),
            "the link is not followed"
        );
        assert!(refuse_existing(&dir.path().join("new.csv"), "--output-file").is_ok());
        let missing = dir.path().join("nodir").join("x.csv");
        assert!(message(publish_new(&missing, b"x", "--output-file")).starts_with("Cannot write "));
        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
            .unwrap()
            .flatten()
            .filter(|entry| entry.file_name().to_string_lossy().starts_with(".airs-"))
            .collect();
        assert!(leftovers.is_empty(), "no temporary is left");
    }

    #[test]
    fn missing_directories_fifos_and_large_files_are_unreadable() {
        let dir = TempDir::new();
        let missing = dir.path().join("missing.json").into_os_string();
        let expected = |path: &OsStr| {
            format!(
                "Cannot read {} (missing, not a regular file, unreadable or larger than 1 MiB)",
                path.to_string_lossy()
            )
        };
        assert_eq!(message(read_config(&missing)), expected(&missing));
        let directory = dir.path().as_os_str().to_owned();
        assert_eq!(message(read_config(&directory)), expected(&directory));
        let big = dir.path().join("big.json");
        std::fs::write(&big, vec![b' '; CONFIG_LIMIT as usize + 1]).unwrap();
        assert_eq!(
            message(read_config(big.as_os_str())),
            expected(big.as_os_str())
        );
        let exact = dir.path().join("exact.json");
        let mut bytes = vec![b' '; CONFIG_LIMIT as usize - 2];
        bytes.splice(0..0, *b"{}");
        std::fs::write(&exact, &bytes).unwrap();
        assert!(read_config(exact.as_os_str()).unwrap().is_empty());
        assert!(
            read_text(big.as_os_str(), PROMPTS_LIMIT).is_ok(),
            "a 1 MiB + 1 file is within the prompts limit"
        );
    }
}