blossom-cli 0.5.2

CLI client for Blossom blob storage — upload, download, mirror, keygen with BIP-340 Nostr auth
use sha2::{Digest, Sha256};
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
    let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("manifest dir"));
    let workspace_root = manifest_dir
        .parent()
        .expect("blossom-server should live in workspace")
        .to_path_buf();
    let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown-target".to_string());

    let files = discover_source_files(&workspace_root).expect("source file discovery should work");
    for relative in &files {
        println!(
            "cargo:rerun-if-changed={}",
            workspace_root.join(relative).display()
        );
    }

    let entries = files
        .iter()
        .map(|relative| {
            let absolute = workspace_root.join(relative);
            let hash = hash_file(&absolute).expect("source file should hash");
            serde_json::json!({
                "path": relative.to_string_lossy().replace('\\', "/"),
                "sha256": hash,
            })
        })
        .collect::<Vec<_>>();

    let aggregate_hash = aggregate_hash("source-build", &target, &entries);
    let manifest = serde_json::json!({
        "manifest_version": 1u32,
        "manifest_kind": "source-build",
        "hash_algorithm": "sha256",
        "target": target,
        "aggregate_hash": aggregate_hash,
        "entries": entries,
    });

    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
    let manifest_path = out_dir.join("source-build-manifest.json");
    std::fs::write(
        &manifest_path,
        serde_json::to_vec_pretty(&manifest).expect("manifest json"),
    )
    .expect("build manifest should write");

    let cargo_toml_hash = hash_file(&manifest_dir.join("Cargo.toml")).expect("hash Cargo.toml");
    println!("cargo:rustc-env=BLOSSOM_CARGO_TOML_HASH={cargo_toml_hash}");
    println!("cargo:rustc-env=BLOSSOM_SOURCE_BUILD_HASH={aggregate_hash}");
    println!("cargo:rustc-env=BLOSSOM_BUILD_TARGET={target}");
    println!(
        "cargo:rustc-env=BLOSSOM_SOURCE_BUILD_MANIFEST_PATH={}",
        manifest_path.display()
    );
}

fn discover_source_files(workspace_root: &Path) -> io::Result<Vec<PathBuf>> {
    if let Some(files) = git_ls_files(workspace_root)? {
        return Ok(files);
    }
    let mut files = Vec::new();
    walk_files(workspace_root, workspace_root, &mut files)?;
    files.sort();
    Ok(files)
}

fn git_ls_files(workspace_root: &Path) -> io::Result<Option<Vec<PathBuf>>> {
    let output = Command::new("git")
        .arg("ls-files")
        .arg("-z")
        .current_dir(workspace_root)
        .output();

    let output = match output {
        Ok(output) if output.status.success() => output,
        Ok(_) => return Ok(None),
        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(err),
    };

    let mut files = output
        .stdout
        .split(|b| *b == 0)
        .filter(|chunk| !chunk.is_empty())
        .filter_map(|chunk| std::str::from_utf8(chunk).ok().map(PathBuf::from))
        .filter(|path| include_source_file(path))
        .collect::<Vec<_>>();
    files.sort();
    Ok(Some(files))
}

fn walk_files(root: &Path, dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let relative = path.strip_prefix(root).unwrap_or(&path);
        if should_skip(relative) {
            continue;
        }
        if path.is_dir() {
            walk_files(root, &path, files)?;
        } else if path.is_file() && include_source_file(relative) {
            files.push(relative.to_path_buf());
        }
    }
    Ok(())
}

fn should_skip(path: &Path) -> bool {
    matches!(
        path.components()
            .next()
            .map(|c| c.as_os_str().to_string_lossy()),
        Some(first)
            if first == ".git"
                || first == "target"
                || first == ".idea"
                || first == ".vscode"
                || first == ".zed"
    )
}

fn include_source_file(path: &Path) -> bool {
    !should_skip(path)
        && path
            .file_name()
            .and_then(|name| name.to_str())
            .map(|name| !name.ends_with(".db") && !name.ends_with(".log"))
            .unwrap_or(true)
}

fn hash_file(path: &Path) -> io::Result<String> {
    let bytes = std::fs::read(path)?;
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    Ok(hex::encode(hasher.finalize()))
}

fn aggregate_hash(kind: &str, target: &str, entries: &[serde_json::Value]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(kind.as_bytes());
    hasher.update(b"\n");
    hasher.update(target.as_bytes());
    hasher.update(b"\n");
    for entry in entries {
        let path = entry
            .get("path")
            .and_then(|v| v.as_str())
            .unwrap_or_default();
        let sha = entry
            .get("sha256")
            .and_then(|v| v.as_str())
            .unwrap_or_default();
        hasher.update(path.as_bytes());
        hasher.update(b"\t");
        hasher.update(sha.as_bytes());
        hasher.update(b"\n");
    }
    hex::encode(hasher.finalize())
}