airs-cli 0.4.1

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()
}

fn unreadable(path: &OsStr, limit: u64) -> CliError {
    let size = if limit == PROMPTS_LIMIT {
        "16 MiB"
    } else {
        "1 MiB"
    };
    CliError::operational(format!(
        "Cannot read {} (missing, not a regular file, unreadable or larger than {size})",
        shown(path)
    ))
}

/// 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> {
    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());
    }
    String::from_utf8(bytes)
        .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(())
}

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