debrepo 0.4.0

Library for manifest-driven Debian/Ubuntu bootstrap and APT archive resolution.
Documentation
mod common;

use {
    clap::Parser,
    common::{
        one, read_manifest_doc, update_manifest_file, write_executable, CurrentDirGuard, EnvGuard,
        TestConfig, TestProvider, ARCH,
    },
    debrepo::{
        cli::{cmd, Command},
        Manifest,
    },
    std::path::PathBuf,
};

fn editor_path(path: &std::path::Path) -> String {
    path.to_string_lossy().into_owned()
}

async fn create_locked_edit_manifest(path: &std::path::Path) {
    let provider = TestProvider::new();
    let mut manifest = Manifest::new(path, ARCH, None);
    manifest
        .set_spec_meta(None, "owner", "seed")
        .expect("set default spec meta");
    manifest
        .resolve(one(), &provider)
        .await
        .expect("resolve manifest");
    manifest.store().await.expect("store manifest");
}

#[test]
fn edit_env_and_script_cover_editor_resolution_and_updates() {
    let dir = tempfile::tempdir().expect("tempdir");
    let manifest_path = dir.path().join("Manifest.toml");
    smol::block_on(create_locked_edit_manifest(&manifest_path));
    let conf = TestConfig::new(manifest_path.clone(), TestProvider::new());

    let editor_env = dir.path().join("edit-env.sh");
    write_executable(
        &editor_env,
        "#!/bin/sh\ncat > \"$1\" <<'EOF'\nFOO=bar\n# keep comment\nBAR=baz\nEOF\n",
    );
    let mut env = EnvGuard::new();
    env.remove("VISUAL");
    env.set("EDITOR", &editor_env);

    cmd::Edit::try_parse_from(["edit", "env"])
        .expect("parse edit env")
        .exec(&conf)
        .expect("edit env");

    let (manifest, has_valid_lock) = smol::block_on(Manifest::from_file(
        &manifest_path,
        ARCH,
        &TestProvider::new(),
    ))
    .expect("reload manifest");
    assert!(has_valid_lock);
    let spec = manifest.lookup_spec(None).expect("default spec");
    assert_eq!(
        spec.build_env()
            .iter()
            .map(|(key, value)| (key.to_string(), value.clone()))
            .collect::<Vec<_>>(),
        vec![
            ("FOO".to_string(), "bar".to_string()),
            ("BAR".to_string(), "baz".to_string()),
        ]
    );
    assert!(std::fs::read_to_string(&manifest_path)
        .expect("read manifest")
        .contains("# keep comment"));

    let editor_script = dir.path().join("edit-script.sh");
    write_executable(
        &editor_script,
        "#!/bin/sh\ncat > \"$1\" <<'EOF'\n#!/bin/sh\necho edited-script\nEOF\n",
    );
    cmd::Edit::try_parse_from(["edit", "--editor", &editor_path(&editor_script), "script"])
        .expect("parse edit script")
        .exec(&conf)
        .expect("edit script");

    let (manifest, has_valid_lock) = smol::block_on(Manifest::from_file(
        &manifest_path,
        ARCH,
        &TestProvider::new(),
    ))
    .expect("reload manifest");
    assert!(has_valid_lock);
    assert_eq!(
        manifest
            .lookup_spec(None)
            .expect("default spec")
            .build_script(),
        Some("#!/bin/sh\necho edited-script\n")
    );

    let clear_script = dir.path().join("clear-script.sh");
    write_executable(&clear_script, "#!/bin/sh\nprintf '   \\n' > \"$1\"\n");
    cmd::Edit::try_parse_from(["edit", "--editor", &editor_path(&clear_script), "script"])
        .expect("parse clear script")
        .exec(&conf)
        .expect("clear script");

    let (manifest, has_valid_lock) = smol::block_on(Manifest::from_file(
        &manifest_path,
        ARCH,
        &TestProvider::new(),
    ))
    .expect("reload manifest");
    assert!(has_valid_lock);
    assert_eq!(
        manifest
            .lookup_spec(None)
            .expect("default spec")
            .build_script(),
        None
    );
}

#[test]
fn edit_artifact_creates_updates_and_rejects_non_text_artifacts() {
    let dir = tempfile::tempdir().expect("tempdir");
    let manifest_path = dir.path().join("Manifest.toml");
    std::fs::write(dir.path().join("payload.bin"), b"binary").expect("write payload");
    smol::block_on(create_locked_edit_manifest(&manifest_path));

    let _cwd = CurrentDirGuard::set(dir.path());
    let conf = TestConfig::new(PathBuf::from("Manifest.toml"), TestProvider::new());

    let create_editor = dir.path().join("create-artifact.sh");
    write_executable(
        &create_editor,
        "#!/bin/sh\nprintf 'first artifact body\\n' > \"$1\"\n",
    );
    cmd::Edit::try_parse_from([
        "edit",
        "--editor",
        &editor_path(&create_editor),
        "artifact",
        "note.txt",
        "--target",
        "/etc/note.txt",
        "--mode",
        "0640",
        "--only-arch",
        "amd64",
        "--stage",
    ])
    .expect("parse create artifact")
    .exec(&conf)
    .expect("create artifact");

    let doc = read_manifest_doc(&manifest_path);
    assert_eq!(
        doc["spec"]["stage"]
            .as_array()
            .expect("stage array")
            .iter()
            .filter_map(|item| item.as_str())
            .collect::<Vec<_>>(),
        vec!["note.txt"]
    );

    let (_, has_valid_lock) = smol::block_on(Manifest::from_file(
        "Manifest.toml",
        ARCH,
        &TestProvider::new(),
    ))
    .expect("reload manifest");
    assert!(has_valid_lock);
    let doc = read_manifest_doc(&manifest_path);
    let artifact = doc["artifact"]["note.txt"]
        .as_table()
        .expect("artifact table");
    assert_eq!(
        artifact.get("text").and_then(|item| item.as_str()),
        Some("first artifact body\n")
    );
    assert_eq!(
        artifact.get("mode").and_then(|item| item.as_integer()),
        Some(0o640)
    );
    assert_eq!(
        artifact.get("arch").and_then(|item| item.as_str()),
        Some("amd64")
    );

    let update_editor = dir.path().join("update-artifact.sh");
    write_executable(
        &update_editor,
        "#!/bin/sh\nprintf 'updated artifact body\\n' > \"$1\"\n",
    );
    cmd::Edit::try_parse_from([
        "edit",
        "--editor",
        &editor_path(&update_editor),
        "artifact",
        "note.txt",
        "--target",
        "/etc/note.txt",
    ])
    .expect("parse update artifact")
    .exec(&conf)
    .expect("update artifact");

    let (_, has_valid_lock) = smol::block_on(Manifest::from_file(
        "Manifest.toml",
        ARCH,
        &TestProvider::new(),
    ))
    .expect("reload manifest");
    assert!(has_valid_lock);
    let doc = read_manifest_doc(&manifest_path);
    let artifact = doc["artifact"]["note.txt"]
        .as_table()
        .expect("artifact table");
    assert_eq!(
        artifact.get("text").and_then(|item| item.as_str()),
        Some("updated artifact body\n")
    );
    assert_eq!(
        artifact.get("mode").and_then(|item| item.as_integer()),
        Some(0o640)
    );
    assert_eq!(
        artifact.get("arch").and_then(|item| item.as_str()),
        Some("amd64")
    );

    cmd::ArtifactAdd::try_parse_from(["artifact-add", "payload.bin", "/opt/payload.bin"])
        .expect("parse artifact add")
        .exec(&conf)
        .expect("add binary artifact");
    let err = cmd::Edit::try_parse_from([
        "edit",
        "--editor",
        &editor_path(&update_editor),
        "artifact",
        "payload.bin",
        "--target",
        "/opt/payload.bin",
    ])
    .expect("parse edit binary artifact")
    .exec(&conf)
    .expect_err("editing non-text artifact must fail");
    assert!(err
        .to_string()
        .contains("artifact payload.bin exists but is not text"));
}

#[test]
fn edit_manifest_refreshes_stale_lock_and_reports_editor_failures() {
    let dir = tempfile::tempdir().expect("tempdir");
    let manifest_path = dir.path().join("Manifest.toml");
    smol::block_on(create_locked_edit_manifest(&manifest_path));
    let conf = TestConfig::new(manifest_path.clone(), TestProvider::new());

    update_manifest_file(&manifest_path, |doc| {
        let mut meta = toml_edit::Array::default();
        meta.push("owner:stale");
        doc["spec"]["meta"] = toml_edit::Item::Value(toml_edit::Value::Array(meta));
    });

    let noop_editor = dir.path().join("noop-editor.sh");
    write_executable(&noop_editor, "#!/bin/sh\nexit 0\n");
    cmd::Edit::try_parse_from(["edit", "--editor", &editor_path(&noop_editor)])
        .expect("parse manifest edit")
        .exec(&conf)
        .expect("edit manifest");

    let (_, has_valid_lock) = smol::block_on(Manifest::from_file(
        &manifest_path,
        ARCH,
        &TestProvider::new(),
    ))
    .expect("reload manifest");
    assert!(
        has_valid_lock,
        "manifest edit should refresh the stale lock"
    );

    let failing_editor = dir.path().join("fail-editor.sh");
    write_executable(&failing_editor, "#!/bin/sh\nexit 7\n");
    let err = cmd::Edit::try_parse_from(["edit", "--editor", &editor_path(&failing_editor), "env"])
        .expect("parse failing edit")
        .exec(&conf)
        .expect_err("failing editor must bubble up");
    assert!(err.to_string().contains("editor exited with status"));
}

#[test]
fn edit_subcommands_reject_unlocked_manifests() {
    let dir = tempfile::tempdir().expect("tempdir");
    let manifest_path = dir.path().join("Manifest.toml");
    std::fs::write(&manifest_path, "").expect("write unlocked manifest");
    let conf = TestConfig::new(manifest_path, TestProvider::new());

    for argv in [
        vec!["edit", "--editor", " ", "env"],
        vec!["edit", "--editor", " ", "script"],
        vec![
            "edit",
            "--editor",
            " ",
            "artifact",
            "note.txt",
            "--target",
            "/etc/note.txt",
        ],
    ] {
        let err = cmd::Edit::try_parse_from(argv)
            .expect("parse edit command")
            .exec(&conf)
            .expect_err("unlocked manifest must fail");
        assert!(err
            .to_string()
            .contains("manifest lock is not live; run update first"));
    }
}