cufflink-cli 0.22.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
//! A service as one file: what `cufflink build --bundle` writes and
//! `cufflink deploy --bundle` uploads. It is gzip-compressed JSON, written
//! so that one input gives one file byte for byte, and its SHA-256 can
//! name the build.

use crate::digest::sha256_hex;
use cufflink_types::ServiceMode;
use flate2::{bufread::GzDecoder, Compression, GzBuilder};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::{Path, PathBuf};

const FORMAT: u32 = 1;

/// What a bundle holds. `manifest` is what a deploy uploads: the module's
/// features are already among its `required_platform_features`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Bundle {
    pub service: String,
    pub mode: ServiceMode,
    pub manifest: serde_json::Value,
    pub wasm_base64: Option<String>,
    pub wasm_hash: Option<String>,
    /// Every platform feature a deploy of the bundle needs.
    pub features: Vec<String>,
    pub commit_hash: Option<String>,
    pub cli_version: String,
}

#[derive(Serialize)]
struct Numbered<'a> {
    bundle: u32,
    #[serde(flatten)]
    contents: &'a Bundle,
}

#[derive(Deserialize)]
struct Number {
    bundle: u32,
}

/// A bundle's file: how large, and the SHA-256 that names the build.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct File {
    pub size: usize,
    pub sha256: String,
}

impl File {
    fn of(bytes: &[u8]) -> Self {
        Self {
            size: bytes.len(),
            sha256: sha256_hex(bytes),
        }
    }
}

/// Why the bundle at `path` is not deployed.
pub(crate) fn refused(path: &Path, why: impl std::fmt::Display) -> eyre::Report {
    eyre::eyre!("Cannot deploy the bundle {}: {why}", path.display())
}

/// Where a bundle is written before it has its name, beside it so the
/// rename stays on one filesystem.
fn partial(path: &Path) -> PathBuf {
    let mut name = path.file_name().unwrap_or_default().to_os_string();
    name.push(format!(".{}.partial", std::process::id()));
    path.with_file_name(name)
}

fn write_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    let partial = partial(path);
    std::fs::write(&partial, bytes)
        .and_then(|()| std::fs::rename(&partial, path))
        .inspect_err(|_| {
            let _ = std::fs::remove_file(&partial);
        })
}

impl Bundle {
    /// The file's bytes. The gzip header carries no time and no name, and
    /// the fields are written in the order they are declared.
    fn encoded(&self) -> eyre::Result<Vec<u8>> {
        let mut gzip = GzBuilder::new()
            .mtime(0)
            .write(Vec::new(), Compression::default());
        serde_json::to_writer(
            &mut gzip,
            &Numbered {
                bundle: FORMAT,
                contents: self,
            },
        )?;
        Ok(gzip.finish()?)
    }

    /// The bundle `bytes` are, and nothing after it: a file with bytes
    /// after its gzip member is not what `build` wrote.
    fn decoded(bytes: &[u8]) -> eyre::Result<Self> {
        let mut json = Vec::new();
        let mut gzip = GzDecoder::new(bytes);
        gzip.read_to_end(&mut json)
            .map_err(|e| eyre::eyre!("it is not a whole gzip file ({e})"))?;
        if !gzip.into_inner().is_empty() {
            eyre::bail!("it has bytes after its end");
        }
        let Number { bundle } =
            serde_json::from_slice(&json).map_err(|e| eyre::eyre!("it is not a bundle ({e})"))?;
        if bundle != FORMAT {
            eyre::bail!(
                "it is bundle format {bundle}, and this CLI (v{}) reads format {FORMAT}: deploy \
                 it with the CLI that built it, or build it again with this one",
                env!("CARGO_PKG_VERSION")
            );
        }
        let bundle: Self = serde_json::from_slice(&json)
            .map_err(|e| eyre::eyre!("it is not a format {FORMAT} bundle ({e})"))?;
        bundle.verified_module().map(|()| bundle)
    }

    /// That the module the bundle carries, when it carries one, is the
    /// one its `wasm_hash` names.
    fn verified_module(&self) -> eyre::Result<()> {
        use base64::Engine as _;
        match (&self.wasm_base64, &self.wasm_hash) {
            (None, None) => Ok(()),
            (Some(encoded), Some(expected)) => {
                let module = base64::engine::general_purpose::STANDARD
                    .decode(encoded)
                    .map_err(|e| eyre::eyre!("its module is not base64 ({e})"))?;
                let found = sha256_hex(&module);
                if found != *expected {
                    eyre::bail!(
                        "its module was altered: the bundle says its SHA-256 is {expected}, \
                         and it is {found}"
                    );
                }
                Ok(())
            }
            _ => eyre::bail!("it has a module without its hash, or a hash without its module"),
        }
    }

    /// Write the bundle at `path`. A write that fails leaves no file, and
    /// one that was there as it was.
    pub(crate) fn write(&self, path: &Path) -> eyre::Result<File> {
        let bytes = self.encoded()?;
        write_whole(path, &bytes)
            .map_err(|e| eyre::eyre!("Cannot write the bundle {}: {e}", path.display()))?;
        Ok(File::of(&bytes))
    }

    /// The bundle at `path`, of the format this CLI reads and with the
    /// module it was built with.
    pub(crate) fn read(path: &Path) -> eyre::Result<(Self, File)> {
        let bytes = std::fs::read(path).map_err(|e| refused(path, e))?;
        Self::decoded(&bytes)
            .map(|bundle| (bundle, File::of(&bytes)))
            .map_err(|why| refused(path, why))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use base64::Engine as _;
    use serde_json::json;

    const MODULE: &[u8] = b"\0asm\x01\0\0\0";

    fn bundle() -> Bundle {
        Bundle {
            service: "note-service".into(),
            mode: ServiceMode::Wasm,
            manifest: json!({"name": "note-service", "mode": "wasm", "tables": [],
                             "required_platform_features": ["db_v2"]}),
            wasm_base64: Some(base64::engine::general_purpose::STANDARD.encode(MODULE)),
            wasm_hash: Some(sha256_hex(MODULE)),
            features: vec!["db_v2".into()],
            commit_hash: Some("4f2a9c1".into()),
            cli_version: "0.21.0".into(),
        }
    }

    fn gzip(json: &serde_json::Value) -> Vec<u8> {
        let mut gzip = GzBuilder::new().write(Vec::new(), Compression::default());
        serde_json::to_writer(&mut gzip, json).expect("json");
        gzip.finish().expect("gzip")
    }

    fn refusal(bytes: &[u8]) -> String {
        Bundle::decoded(bytes)
            .expect_err("a file that is no bundle")
            .to_string()
    }

    #[test]
    fn a_bundle_is_read_as_it_was_written() {
        let dir = tempfile::tempdir().expect("dir");
        let path = dir.path().join("notes.bundle");
        for bundle in [
            bundle(),
            Bundle {
                mode: ServiceMode::Crud,
                wasm_base64: None,
                wasm_hash: None,
                commit_hash: None,
                features: Vec::new(),
                ..bundle()
            },
        ] {
            let written = bundle.write(&path).expect("written");
            let bytes = std::fs::read(&path).expect("file");
            assert_eq!(written, File::of(&bytes));
            assert_eq!(
                Bundle::read(&path).expect("read"),
                (bundle, written),
                "what was written, and the digest the write printed"
            );
        }
        assert_eq!(
            std::fs::read_dir(dir.path()).expect("dir").count(),
            1,
            "nothing is left beside the bundle"
        );
    }

    #[test]
    fn two_writes_of_one_input_are_byte_identical() {
        let first = bundle().encoded().expect("encoded");
        assert_eq!(bundle().encoded().expect("encoded"), first);
        assert_eq!(&first[4..8], [0u8; 4], "the gzip header carries no time");

        let mut json = String::new();
        GzDecoder::new(first.as_slice())
            .read_to_string(&mut json)
            .expect("gzip");
        let field = |name: &str| json.find(&format!("\"{name}\":")).expect(name);
        let order = [
            "bundle",
            "service",
            "mode",
            "manifest",
            "wasm_base64",
            "wasm_hash",
            "features",
            "commit_hash",
            "cli_version",
        ]
        .map(field);
        assert!(order.is_sorted(), "the fields are in one order: {json}");
        assert!(json.starts_with(r#"{"bundle":1,"service":"note-service","mode":"wasm","#));
    }

    #[test]
    fn a_bundle_of_another_format_is_refused_by_both_numbers() {
        let mut newer = serde_json::to_value(bundle()).expect("json");
        newer["bundle"] = json!(2);
        newer["signature"] = json!("…");
        assert_eq!(
            refusal(&gzip(&newer)),
            format!(
                "it is bundle format 2, and this CLI (v{}) reads format 1: deploy it with the \
                 CLI that built it, or build it again with this one",
                env!("CARGO_PKG_VERSION")
            )
        );
        assert!(refusal(&gzip(&json!({"service": "notes"}))).starts_with("it is not a bundle ("));
        assert!(refusal(&gzip(&json!("a tarball, say"))).starts_with("it is not a bundle ("));
        assert!(refusal(&gzip(&json!({"bundle": 1, "service": "notes"})))
            .starts_with("it is not a format 1 bundle (missing field"),);
    }

    #[test]
    fn a_bundle_whose_module_was_altered_is_refused() {
        let altered = Bundle {
            wasm_base64: Some(base64::engine::general_purpose::STANDARD.encode(b"\0asm\x02")),
            ..bundle()
        };
        assert_eq!(
            refusal(&altered.encoded().expect("encoded")),
            format!(
                "its module was altered: the bundle says its SHA-256 is {}, and it is {}",
                sha256_hex(MODULE),
                sha256_hex(b"\0asm\x02")
            )
        );
        for half in [
            Bundle {
                wasm_hash: None,
                ..bundle()
            },
            Bundle {
                wasm_base64: None,
                ..bundle()
            },
        ] {
            assert_eq!(
                refusal(&half.encoded().expect("encoded")),
                "it has a module without its hash, or a hash without its module"
            );
        }
        let garbled = Bundle {
            wasm_base64: Some("not base64!".into()),
            ..bundle()
        };
        assert!(
            refusal(&garbled.encoded().expect("encoded")).starts_with("its module is not base64")
        );
    }

    #[test]
    fn a_truncated_file_and_one_that_is_not_gzip_are_refused_with_their_path() {
        let dir = tempfile::tempdir().expect("dir");
        let path = dir.path().join("notes.bundle");
        let whole = bundle().encoded().expect("encoded");
        for (bytes, why) in [
            (&whole[..whole.len() / 2], "it is not a whole gzip file"),
            (&whole[..whole.len() - 4], "it is not a whole gzip file"),
            (&b"{\"bundle\": 1}"[..], "it is not a whole gzip file"),
            (&[][..], "it is not a whole gzip file"),
        ] {
            std::fs::write(&path, bytes).expect("file");
            let refused = Bundle::read(&path).expect_err("refused").to_string();
            assert!(
                refused.starts_with(&format!(
                    "Cannot deploy the bundle {}: {why}",
                    path.display()
                )),
                "{refused}"
            );
        }
        std::fs::write(&path, [whole.as_slice(), b"more"].concat()).expect("file");
        assert_eq!(
            Bundle::read(&path).expect_err("refused").to_string(),
            format!(
                "Cannot deploy the bundle {}: it has bytes after its end",
                path.display()
            ),
            "two files with two digests are never one bundle"
        );
        let missing = dir.path().join("none.bundle");
        assert!(Bundle::read(&missing)
            .expect_err("no file")
            .to_string()
            .starts_with(&format!("Cannot deploy the bundle {}: ", missing.display())));
    }

    #[test]
    fn a_write_that_fails_leaves_no_file_and_keeps_the_one_that_was_there() {
        let dir = tempfile::tempdir().expect("dir");
        let taken = dir.path().join("taken");
        std::fs::create_dir(&taken).expect("a directory where the bundle is to go");
        std::fs::write(taken.join("kept"), b"kept").expect("file");

        let failed = bundle()
            .write(&taken)
            .expect_err("a directory is in the way");

        assert!(failed
            .to_string()
            .starts_with(&format!("Cannot write the bundle {}: ", taken.display())));
        let left: Vec<_> = std::fs::read_dir(dir.path())
            .expect("dir")
            .map(|entry| entry.expect("entry").file_name())
            .collect();
        assert_eq!(left, ["taken"], "no partial file is left");
        assert_eq!(std::fs::read(taken.join("kept")).expect("file"), b"kept");

        let nowhere = dir.path().join("no-such-dir").join("notes.bundle");
        assert!(bundle()
            .write(&nowhere)
            .expect_err("no directory to write in")
            .to_string()
            .starts_with(&format!("Cannot write the bundle {}: ", nowhere.display())));
        assert!(!nowhere.exists());
    }
}