cindy 0.2.1

Managing infrastructure at breakneck speed.
Documentation
//! Create an archive from a set of source paths on the remote machine.
//!
//! Supports tar (plain / gzip / xz / zstd), zip and 7z, all through
//! pure-Rust codecs. The output [`Format`] is inferred from the
//! destination's extension unless given explicitly.
//!
//! This module is **not idempotent**: archives embed timestamps and
//! ordering, so re-running always rewrites the destination and reports
//! [`Return::Changed`].

use std::io::Write as _;
use std::path::{Path, PathBuf};

use crate as cindy;
use crate::Context;

use super::Format;

#[derive(Clone, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
    /// Files and/or directories to put into the archive. Directories
    /// are added recursively.
    pub sources: Vec<PathBuf>,
    /// Destination archive path.
    pub dest: PathBuf,
    /// Archive format. `None` ⇒ infer from `dest`'s extension.
    pub format: Option<Format>,
    /// Mode to set on the produced archive. `None` ⇒ OS default.
    pub mode: Option<super::path::Mode>,
}

/// The name a source path is stored under inside the archive: its final
/// component (so `/var/log/app` is archived as `app/…`), matching the
/// intuition of `tar -C parent -c name`.
fn entry_name(path: &Path) -> crate::Result<&Path> {
    path.file_name()
        .map(Path::new)
        .context(format!("source path {} has no file name", path.display()))
}

/// Append one source (file or directory, recursively) to a tar builder.
fn append_to_tar<W: std::io::Write>(
    builder: &mut tar::Builder<W>,
    source: &Path,
) -> crate::Result<()> {
    let name = entry_name(source)?;
    let meta =
        std::fs::symlink_metadata(source).context(format!("Couldn't stat {}", source.display()))?;
    if meta.is_dir() {
        builder.append_dir_all(name, source).context(format!(
            "Couldn't add directory {} to archive",
            source.display()
        ))
    } else {
        builder
            .append_path_with_name(source, name)
            .context(format!("Couldn't add file {} to archive", source.display()))
    }
}

/// Build the tar byte stream for all sources in memory.
fn build_tar(sources: &[PathBuf]) -> crate::Result<Vec<u8>> {
    let mut builder = tar::Builder::new(Vec::new());
    for source in sources {
        append_to_tar(&mut builder, source)?;
    }
    builder
        .into_inner()
        .context("Couldn't finalise the tar stream")
}

/// Build a ZIP archive in memory.
fn build_zip(sources: &[PathBuf]) -> crate::Result<Vec<u8>> {
    let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
    let options: zip::write::FileOptions<()> =
        zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);

    for source in sources {
        add_to_zip(&mut zip, source, entry_name(source)?, options)?;
    }
    let cursor = zip.finish().context("Couldn't finalise the zip archive")?;
    Ok(cursor.into_inner())
}

/// Recursively add a path to a ZIP under `name`.
fn add_to_zip<W: std::io::Write + std::io::Seek>(
    zip: &mut zip::ZipWriter<W>,
    source: &Path,
    name: &Path,
    options: zip::write::FileOptions<()>,
) -> crate::Result<()> {
    let name_str = name.to_string_lossy().into_owned();
    let meta =
        std::fs::symlink_metadata(source).context(format!("Couldn't stat {}", source.display()))?;

    if meta.is_dir() {
        zip.add_directory(format!("{name_str}/"), options)
            .context(format!("Couldn't add dir {name_str} to zip"))?;
        for entry in
            std::fs::read_dir(source).context(format!("Couldn't read dir {}", source.display()))?
        {
            let entry = entry.context("Couldn't read directory entry")?;
            add_to_zip(zip, &entry.path(), &name.join(entry.file_name()), options)?;
        }
    } else {
        zip.start_file(name_str.clone(), options)
            .context(format!("Couldn't start zip entry {name_str}"))?;
        let bytes = std::fs::read(source).context(format!("Couldn't read {}", source.display()))?;
        zip.write_all(&bytes)
            .context(format!("Couldn't write zip entry {name_str}"))?;
    }
    Ok(())
}

/// Recursively add a path to a 7z writer under `name` (base-name
/// relative, matching the tar/zip layout).
fn add_to_7z<W: std::io::Write + std::io::Seek>(
    writer: &mut sevenz_rust2::ArchiveWriter<W>,
    source: &Path,
    name: &Path,
) -> crate::Result<()> {
    let name_str = name.to_string_lossy().into_owned();
    let meta =
        std::fs::symlink_metadata(source).context(format!("Couldn't stat {}", source.display()))?;

    if meta.is_dir() {
        let entry = sevenz_rust2::ArchiveEntry::from_path(source, name_str);
        writer
            .push_archive_entry::<&[u8]>(entry, None)
            .context(format!("Couldn't add dir {} to 7z", source.display()))?;
        for entry in
            std::fs::read_dir(source).context(format!("Couldn't read dir {}", source.display()))?
        {
            let entry = entry.context("Couldn't read directory entry")?;
            add_to_7z(writer, &entry.path(), &name.join(entry.file_name()))?;
        }
    } else {
        let file =
            std::fs::File::open(source).context(format!("Couldn't open {}", source.display()))?;
        let entry = sevenz_rust2::ArchiveEntry::from_path(source, name_str);
        writer
            .push_archive_entry(entry, Some(file))
            .context(format!("Couldn't add file {} to 7z", source.display()))?;
    }
    Ok(())
}

/// Build a 7z archive in memory.
fn build_7z(sources: &[PathBuf]) -> crate::Result<Vec<u8>> {
    let mut buf = std::io::Cursor::new(Vec::new());
    let mut writer =
        sevenz_rust2::ArchiveWriter::new(&mut buf).context("Couldn't create the 7z writer")?;
    for source in sources {
        add_to_7z(&mut writer, source, entry_name(source)?)?;
    }
    writer
        .finish()
        .context("Couldn't finalise the 7z archive")?;
    Ok(buf.into_inner())
}

/// Create an archive on the remote machine.
#[crate::remote]
pub fn archive(state: State) -> crate::Result<super::Return> {
    if state.sources.is_empty() {
        crate::bail!("`archive` needs at least one source path");
    }

    let format = match state.format {
        Some(f) => f,
        None => Format::from_path(&state.dest)?,
    };

    eprintln!(
        "archive {} source(s) -> {} ({:?})",
        state.sources.len(),
        state.dest.display(),
        format,
    );

    let bytes = match format {
        // Tar-based: build the tar stream, then hand it to the
        // `compress` module's codec (no codec logic lives here).
        // `None` means an uncompressed tar (== `Codec::Store`).
        Format::Tar(codec) => {
            let tar_bytes = build_tar(&state.sources)?;
            super::compress::compress::inner(tar_bytes, codec.unwrap_or(super::Codec::Store))?
        }
        // Container formats carry their own per-entry compression.
        Format::Zip => build_zip(&state.sources)?,
        Format::SevenZ => build_7z(&state.sources)?,
    };

    // Materialise the archive via the `path` module: it owns the
    // on-disk state-transition logic (replacing an existing file, dir,
    // or symlink at `dest`) and emits the diff.
    // `path` requires a total owner/mode; archive only carries an
    // optional mode, so default the owner to the worker's identity and
    // an unspecified mode to `0o644`.
    let (user, group) = super::current_owner_names();
    super::path::file_raw::inner(
        state.dest,
        bytes,
        user,
        group,
        state.mode.unwrap_or_else(|| 0o644.into()),
    )?;
    Ok(super::Return::Changed)
}