simit 0.17.3

Semver-aware git commit helper for Rust projects
Documentation
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;

use tempfile::TempDir;

mod common;

fn simit() -> Command {
    common::simit()
}

fn write_executable(path: &Path, body: &str) {
    let shell = std::env::var("BASH")
        .or_else(|_| std::env::var("SHELL"))
        .unwrap_or_else(|_| "/bin/sh".to_owned());
    fs::write(path, format!("#!{shell}\n{body}")).unwrap();
    let mut permissions = fs::metadata(path).unwrap().permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).unwrap();
}

fn fake_tools() -> TempDir {
    let temp = TempDir::new().unwrap();
    write_executable(
        &temp.path().join("minisign"),
        r#"set -euo pipefail
mode=""
secret=""
pub=""
message=""
sig=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    -G|-S|-V) mode="$1"; shift ;;
    -s) secret="$2"; shift 2 ;;
    -p) pub="$2"; shift 2 ;;
    -m) message="$2"; shift 2 ;;
    -x) sig="$2"; shift 2 ;;
    *) shift ;;
  esac
done
case "$mode" in
  -G)
    cat >/dev/null
    printf 'secret generated\n' > "$secret"
    printf 'public generated\n' > "$pub"
    ;;
  -S)
    cat >/dev/null
    test -s "$secret"
    test -s "$message"
    printf 'signature\n' > "$sig"
    ;;
  -V)
    test -s "$pub"
    test -s "$message"
    test -s "$sig"
    ;;
  *) exit 2 ;;
esac
"#,
    );
    write_executable(
        &temp.path().join("curl"),
        r#"set -euo pipefail
method="GET"
url=""
payload=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    --config|-H) shift 2 ;;
    -fsS) shift ;;
    -X) method="$2"; shift 2 ;;
    --data-binary) payload="${2#@}"; shift 2 ;;
    http*) url="$1"; shift ;;
    *) shift ;;
  esac
done
if [ "$method" = "PUT" ]; then
  secret="${url##*/}"
  test -s "$payload"
  printf '%s\n' "$secret" >> "${FAKE_CURL_LOG:?}"
  exit 0
fi
printf '[{"name":"MINISIGN_SECRET_KEY"},{"name":"MINISIGN_PASSWORD"},{"name":"codeberg_token"}]\n'
"#,
    );
    temp
}

fn init_project() -> TempDir {
    let temp = TempDir::new().unwrap();
    fs::write(
        temp.path().join("Cargo.toml"),
        r#"[package]
name = "demo"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Demo command"
homepage = "https://example.com/demo"
"#,
    )
    .unwrap();
    fs::create_dir(temp.path().join("src")).unwrap();
    fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
    fs::create_dir(temp.path().join("keys")).unwrap();
    fs::write(temp.path().join("keys/minisign.pub"), "public existing\n").unwrap();
    temp
}

fn init_release_project() -> TempDir {
    let project = init_project();
    fs::write(
        project.path().join("simit.toml"),
        r#"[release.codeberg]
repo = "example/demo"
token_secret = "CODEBERG_TOKEN"

[release.artifacts]
runner = "atlas"
build_commands = ["mkdir -p release"]
sign = true

[aur]
download_repo = "example/demo"

[copr]
download_repo = "example/demo"
project = "example/demo"
login_secret = "COPR_LOGIN"
username_secret = "COPR_USERNAME"
token_secret = "COPR_TOKEN"

[apt]
repo_url = "ssh://git@codeberg.org/example/demo-apt.git"

[chocolatey]
download_repo = "example/demo"
id = "demo"
title = "Demo"
description = "Demo package"
project_url = "https://example.com/demo"
api_key_secret = "CHOCOLATEY_API_KEY"
api_key_env = "CHOCOLATEY_API_KEY"

[scoop]
bucket_url = "https://github.com/example/scoop-bucket.git"
download_repo = "example/demo"
"#,
    )
    .unwrap();
    project
}

#[test]
fn imports_minisign_secrets_without_printing_values() {
    let project = init_project();
    let tools = fake_tools();
    let secret_key = project.path().join("minisign.sec");
    let password = project.path().join("minisign.password");
    let token = project.path().join("token");
    let log = project.path().join("curl.log");
    fs::write(&secret_key, "PRIVATE-SECRET-VALUE\n").unwrap();
    fs::write(&password, "PASSWORD-SECRET-VALUE\n").unwrap();
    fs::write(&token, "TOKEN-SECRET-VALUE\n").unwrap();

    let path = format!(
        "{}:{}",
        tools.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );
    let output = simit()
        .current_dir(project.path())
        .env("PATH", path)
        .env("FAKE_CURL_LOG", &log)
        .args([
            "release",
            "secrets",
            "init",
            "--repo",
            "example/demo",
            "--token-file",
        ])
        .arg(&token)
        .arg("--minisign-secret-key-file")
        .arg(&secret_key)
        .arg("--minisign-password-file")
        .arg(&password)
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stdout.contains("uploaded release secrets for example/demo"));
    assert!(!stdout.contains("PRIVATE-SECRET-VALUE"));
    assert!(!stdout.contains("PASSWORD-SECRET-VALUE"));
    assert!(!stdout.contains("TOKEN-SECRET-VALUE"));
    assert!(!stderr.contains("PRIVATE-SECRET-VALUE"));
    assert!(!stderr.contains("PASSWORD-SECRET-VALUE"));
    assert!(!stderr.contains("TOKEN-SECRET-VALUE"));
    let log = fs::read_to_string(log).unwrap();
    assert!(log.contains("MINISIGN_SECRET_KEY"));
    assert!(log.contains("MINISIGN_PASSWORD"));
}

#[test]
fn rotate_minisign_updates_public_key_only_with_flag() {
    let project = init_project();
    let tools = fake_tools();
    let token = project.path().join("token");
    let log = project.path().join("curl.log");
    fs::write(&token, "TOKEN-SECRET-VALUE\n").unwrap();
    let before = fs::read_to_string(project.path().join("keys/minisign.pub")).unwrap();
    let path = format!(
        "{}:{}",
        tools.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );

    let failed = simit()
        .current_dir(project.path())
        .env("PATH", &path)
        .env("FAKE_CURL_LOG", &log)
        .args([
            "release",
            "secrets",
            "init",
            "--repo",
            "example/demo",
            "--token-file",
        ])
        .arg(&token)
        .output()
        .unwrap();
    assert!(!failed.status.success());
    assert_eq!(
        fs::read_to_string(project.path().join("keys/minisign.pub")).unwrap(),
        before
    );

    let rotated = simit()
        .current_dir(project.path())
        .env("PATH", path)
        .env("FAKE_CURL_LOG", &log)
        .args([
            "release",
            "secrets",
            "init",
            "--repo",
            "example/demo",
            "--token-file",
        ])
        .arg(&token)
        .arg("--rotate-minisign")
        .output()
        .unwrap();
    assert!(
        rotated.status.success(),
        "{}",
        String::from_utf8_lossy(&rotated.stderr)
    );
    assert_eq!(
        fs::read_to_string(project.path().join("keys/minisign.pub")).unwrap(),
        "public generated\n"
    );
}

#[test]
fn check_accepts_account_level_codeberg_token() {
    let project = init_project();
    let tools = fake_tools();
    let token = project.path().join("token");
    fs::write(&token, "TOKEN-SECRET-VALUE\n").unwrap();
    let path = format!(
        "{}:{}",
        tools.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );
    let output = simit()
        .current_dir(project.path())
        .env("PATH", path)
        .args([
            "release",
            "secrets",
            "check",
            "--repo",
            "example/demo",
            "--token-file",
        ])
        .arg(&token)
        .arg("--assume-account-secret")
        .arg("codeberg_token")
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn contract_prints_generic_release_credentials_json() {
    let project = init_release_project();
    let output = simit()
        .current_dir(project.path())
        .args(["release", "secrets", "contract", "--json"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(!stdout.contains("canix"));
    assert!(!stdout.contains("secret-manager"));
    assert!(!stdout.contains("age/"));
    let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    let credentials = value.as_array().expect("contract is array");
    assert!(credentials.iter().any(|credential| {
        credential["name"] == "CODEBERG_TOKEN"
            && credential["kind"] == "secret"
            && credential["scope"] == "user"
            && credential["context"] == "secrets"
            && credential["channel"] == "codeberg"
            && credential["required"] == true
    }));
    assert!(credentials.iter().any(|credential| {
        credential["name"] == "COPR_USERNAME"
            && credential["kind"] == "variable"
            && credential["scope"] == "user"
            && credential["context"] == "vars"
            && credential["channel"] == "copr"
            && credential["required"] == true
    }));
    assert!(credentials.iter().any(|credential| {
        credential["name"] == "apt_repo_gpg_key_id"
            && credential["kind"] == "variable"
            && credential["scope"] == "repo"
            && credential["context"] == "vars"
            && credential["channel"] == "apt"
            && credential["required"] == true
    }));
    assert!(credentials.iter().any(|credential| {
        credential["name"] == "SCOOP_BUCKET_TOKEN"
            && credential["kind"] == "secret"
            && credential["scope"] == "user"
            && credential["channel"] == "scoop"
            && credential["required"] == false
    }));
}