sequoia-git 0.5.0

A tool for managing and enforcing a commit signing policy.
Documentation
use std::fs;

mod common;
use common::Environment;
use common::Error;

#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
enum Change<'a> {
    AddUser(&'a str),
    RetireUser(&'a str),
}

fn assert_changes<'a>(json: &[u8], expected: &[Change]) {
    let json = String::from_utf8_lossy(json);
    let output: serde_json::Value = serde_json::from_str(&json)
        .expect(&format!("valid json: got {} bytes:\n{}",
                         json.len(), json));

    // At the top-level we have a map that contains an entry
    // "changes".
    let changes = &output["changes"];
    let changes = if let serde_json::Value::Array(changes) = changes {
        changes
    } else {
        panic!("Expected an array of changes, got: {:?}",
               changes);
    };

    // The changes entry is an array of maps.
    let mut got = Vec::new();
    for change in changes {
        // A map is corresponds to a single change, and looks looks
        // like this:
        //
        //   {
        //     "AddUser": "alice"
        //   },
        let change = if let serde_json::Value::Object(change) = change {
            change
        } else {
            panic!("Expected a map, got: {:?}", change);
        };

        if let Some(user) = change.get("AddUser") {
            if let serde_json::Value::String(user) = user {
                got.push(Change::AddUser(user));
            } else {
                panic!("Unknown change: {:?}", change);
            }
        } else if let Some(user) = change.get("RetireUser") {
            if let serde_json::Value::String(user) = user {
                got.push(Change::RetireUser(user));
            } else {
                panic!("Unknown change: {:?}", change);
            }
        } else if let Some(_) = change.get("AddRight") {
            // We don't check for this yet.
        } else if let Some(_) = change.get("RemoveRight") {
            // We don't check for this yet.
        } else if let Some(_) = change.get("AddCert") {
            // We don't check for this yet.
        } else if let Some(_) = change.get("RemoveCert") {
            // We don't check for this yet.
        } else {
            panic!("Unknown change: {:?}", change);
        }
    }

    got.sort();
    let mut expected = expected.to_vec();
    expected.sort();

    eprintln!("Expected changes: {:?}", expected);
    eprintln!("     Got changes: {:?}", got);

    assert_eq!(expected, got);
}


#[test]
fn policy_diff() -> anyhow::Result<()> {
    let e = Environment::new()?;
    let p = e.git_state();

    let (alice, alice_pgp) = e.gen("alice", None, None);
    let alice_fpr = &alice.fingerprint().to_string();
    let (bob, bob_pgp) = e.gen("bob", None, None);
    let bob_fpr = &bob.fingerprint().to_string();
    let (_carol, carol_pgp) = e.gen("carol", None, None);
    let (_dave, dave_pgp) = e.gen("dave", None, None);

    // Add an initial commit without a policy.
    fs::write(p.join("0"), "0.")?;
    e.git(&["add", "0"])?;
    e.git(&[
        "commit",
        "-m", "Initial commit.  No policy yet"
    ])?;
    let c0 = e.git_current_commit()?;

    // Alice adds herself as the project maintainer.
    e.sq_git(&[
        "policy",
        "authorize",
        "alice",
        "--cert-file", &alice_pgp,
        "--project-maintainer"
    ])?;
    e.git(&["add", "openpgp-policy.toml"])?;
    e.git(&[
        "commit",
        "-m", "Add Alice to the policy file.",
        &format!("-S{}", alice_fpr),
    ])?;
    let root = e.git_current_commit()?;

    e.git(&["log"])?;
    e.sq_git(&["log", "--trust-root", &root])?;

    // Check that she can add commits.
    fs::write(p.join("2"), "2.")?;
    e.git(&["add", "2"])?;
    e.git(&[
        "commit",
        "-m", "Alice adds a commit.",
        &format!("-S{}", alice_fpr),
    ])?;
    let c2 = e.git_current_commit()?;

    e.git(&["log"])?;
    e.sq_git(&["log", "--trust-root", &root])?;

    // Alice authorizes Bob.
    e.sq_git(&[
        "policy",
        "authorize",
        "bob",
        "--cert-file", &bob_pgp,
        "--committer"
    ])?;
    e.git(&["add", "openpgp-policy.toml"])?;
    e.git(&[
        "commit",
        "-m", "Alice authorizes Bob.",
        &format!("-S{}", alice_fpr),
    ])?;
    let c3 = e.git_current_commit()?;

    e.git(&["log"])?;
    e.sq_git(&["log", "--trust-root", &root])?;

    // Check that he can add commits.
    fs::write(p.join("4"), "4.")?;
    e.git(&["add", "4"])?;
    e.git(&[
        "commit",
        "-m", "Bob adds a commit.",
        &format!("-S{}", bob_fpr),
    ])?;
    let c4 = e.git_current_commit()?;

    e.git(&["log"])?;
    e.sq_git(&["log", "--trust-root", &root])?;


    let check = |args: &[&str], expected: &[Change]| {
        let mut a = vec!["policy", "diff"];
        a.extend(args);

        let output = match e.sq_git(&a) {
            Ok(output) => {
                assert!(expected.is_empty());
                output
            }
            Err(err) => {
                match err {
                    Error::CliError(_, output) => {
                        output
                    }
                    err => {
                        panic!("Unexpected failure: {}", err);
                    }
                }
            }
        };

        assert_changes(&output.stdout, expected)
    };

    // The status quo:
    //
    // c0: No policy.
    // root: Alice added to policy
    // c2: Policy unchanged.
    // c3: Bob added to policy.
    // c4 / HEAD: Policy unchanged.

    // HEAD -> working directory
    check(&[], &[]);

    // Commit with itself.
    check(&[&root, &root], &[]);
    // Commits without a policy
    check(&[&c0, &c0], &[]);

    // The commit where Alice is added.
    check(&[&c0, &root],
          &[ Change::AddUser("alice") ]);
    check(&[&root, &c0],
          &[ Change::RetireUser("alice") ]);

    check(&[&root, &c2], &[]);
    check(&[&c2, &root], &[]);

    // The commit where Bob is added.
    check(&[&c2, &c3],
          &[ Change::AddUser("bob") ]);
    check(&[&c3, &c2],
          &[ Change::RetireUser("bob") ]);

    // Two commits, no policy change.
    check(&[&c3, &c4], &[]);
    check(&[&c4, &c3], &[]);

    check(&["--old-commit", &c0, "--new-commit", &c4],
          &[Change::AddUser("alice"), Change::AddUser("bob")]);
    check(&["--new-commit", &c4, "--old-commit", &c0],
          &[Change::AddUser("alice"), Change::AddUser("bob")]);

    // From the first commit without a policy to HEAD.
    check(&[&c0, &c4], &[Change::AddUser("alice"), Change::AddUser("bob")]);
    check(&[&c4, &c0], &[Change::RetireUser("alice"), Change::RetireUser("bob")]);

    // With a symbolic name.
    check(&[&c0, "HEAD"], &[Change::AddUser("alice"), Change::AddUser("bob")]);
    check(&["HEAD", &c0], &[Change::RetireUser("alice"), Change::RetireUser("bob")]);


    // Replace the policy file, but don't check it in.
    let policy_toml = p.join("openpgp-policy.toml");
    std::fs::remove_file(&policy_toml).expect("can remove");
    let policy_toml = &policy_toml.display().to_string()[..];
    e.sq_git(&[
        "policy",
        "authorize",
        "carol",
        "--cert-file", &carol_pgp,
        "--project-maintainer" // All capabilities.
    ])?;

    check(&[&c4, policy_toml],
          &[
              Change::RetireUser("alice"),
              Change::RetireUser("bob"),
              Change::AddUser("carol")
          ]);
    // If the second argument is not provided we use the policy in the
    // working directory.
    check(&[&c4],
          &[
              Change::RetireUser("alice"),
              Change::RetireUser("bob"),
              Change::AddUser("carol")
          ]);
    // If no argumens are provided we compare HEAD, i.e., c4 to the
    // current file.
    check(&[],
          &[
              Change::RetireUser("alice"),
              Change::RetireUser("bob"),
              Change::AddUser("carol")
          ]);

    // Relative to itself.
    check(&[policy_toml, policy_toml], &[]);
    // If the second argument is not provided we use the policy in the
    // working directory.
    check(&[policy_toml], &[]);


    // Don't use the DWIM interface.
    check(&["--old-commit", &c4, "--new-file", policy_toml],
          &[
              Change::RetireUser("alice"),
              Change::RetireUser("bob"),
              Change::AddUser("carol")
          ]);

    check(&["--new-file", policy_toml, "--old-commit", &c4],
          &[
              Change::RetireUser("alice"),
              Change::RetireUser("bob"),
              Change::AddUser("carol")
          ]);


    // Create a second policy file.
    let carol_policy_toml = p.join("carol-policy.toml");
    std::fs::rename(&policy_toml, &carol_policy_toml).expect("can rename");
    let carol_policy_toml = &carol_policy_toml.display().to_string()[..];
    e.sq_git(&[
        "policy",
        "authorize",
        "dave",
        "--cert-file", &dave_pgp,
        "--project-maintainer" // All capabilities.
    ])?;

    check(&["--old-file", policy_toml, "--new-file", carol_policy_toml],
          &[
              Change::RetireUser("dave"),
              Change::AddUser("carol")
          ]);
    check(&["--new-file", policy_toml, "--old-file", carol_policy_toml],
          &[
              Change::AddUser("dave"),
              Change::RetireUser("carol")
          ]);

    Ok(())
}

#[test]
fn non_existant() -> anyhow::Result<()> {
    let e = Environment::new()?;
    let p = e.git_state();

    // Check that accessing a non-existant file returns in an error.
    let non_existent = p.join("non_existent");
    assert!(! non_existent.exists());
    let non_existent = &non_existent.display().to_string();

    assert!(e.sq_git(&[ non_existent, non_existent ]).is_err());

    assert!(e.sq_git(&[
        "--old-commit", non_existent, "--new-commit", non_existent
    ]).is_err());

    assert!(e.sq_git(&[
        "--old-file", non_existent, "--new-file", non_existent
    ]).is_err());

    Ok(())
}