crates/gunnar-client/tests/head_target.rs
//! **A remote does not get to choose the contents of `.git/HEAD`** (GUN-041).
//!
//! `RemoteIdentity::head_target` and `unborn_head` are strings the SERVER chose
//! — the v0 advertisement's `symref=HEAD:<target>` and v2's `ls-refs` symref
//! line — and `init_clone_dir` wrote `ref: {branch}` into the new clone's
//! `.git/HEAD` verbatim. Whatever the remote said became the contents of a file
//! in the user's working tree.
//!
//! `ref:` targets resolve relative to `$GIT_DIR`, so a remote answering
//! `../../../../tmp/x` points the clone's HEAD at a path outside the
//! repository, which the next commit writes through. A target carrying a
//! newline puts a second line in the file that nothing in the repository put
//! there.
//!
//! Driven through `init_clone_dir` itself, which is the ONE writer for a
//! freshly-initialised clone and is what both callers reach — the empty-remote
//! path and the shallow/filtered path.
use gunnar_client::fetch::init_clone_dir;
const URL: &str = "ssh://example.invalid/team/project.git";
/// An error and every `source()` under it, joined.
///
/// Load-bearing rather than tidiness: `gunnar_client::Error::Repo` Displays as
/// the bare word `repository` and carries the sentence as its source, which is
/// the trap the mirror work already recorded ("the FAILED job an operator reads
/// named nothing they could act on"). A test asserting on `{err}` here would
/// assert on the word `repository` and pass for any refusal at all.
fn chain(err: &dyn std::error::Error) -> String {
let mut text = err.to_string();
let mut source = err.source();
while let Some(next) = source {
text.push_str(": ");
text.push_str(&next.to_string());
source = next.source();
}
text
}
fn init(branch: &str) -> Result<std::path::PathBuf, String> {
let dir = tempfile::tempdir().expect("a temp dir");
let dest = dir.path().join("clone");
let outcome = init_clone_dir(&dest, URL, gix::hash::Kind::Sha1, branch)
.map(|_| dest.clone())
.map_err(|err| chain(&err));
// The directory has to outlive the call, so the caller can read the file;
// leaking one tempdir per case in a test is the cheap way to do that.
std::mem::forget(dir);
outcome
}
/// The accept side, first. Without it every refusal below would be satisfied by
/// an `init_clone_dir` that refused everything — and that would be a clone
/// command that cannot clone.
#[test]
fn a_real_branch_name_is_written_into_head() {
let dest = init("refs/heads/main").expect("a legal full ref name must be accepted");
assert_eq!(
std::fs::read_to_string(dest.join(".git").join("HEAD")).expect("read HEAD"),
"ref: refs/heads/main\n",
"the clone's HEAD is not on the branch the remote named"
);
}
/// **A target that climbs out of the repository is refused, and nothing is
/// left behind.**
///
/// `..` is the whole of it: git resolves `ref: <name>` against `$GIT_DIR`, so
/// this is a HEAD that names a file outside the clone, and the next `git
/// commit` writes an object id into it.
#[test]
fn a_target_that_escapes_the_repository_is_refused_and_leaves_no_clone() {
let err = init("../../../../tmp/gunnar-head-escape")
.expect_err("a target with `..` in it must not reach .git/HEAD");
assert!(
err.contains("gunnar-head-escape") && err.contains("not a reference name"),
"the refusal must quote what the remote sent, or a user has nothing to \
report: {err}"
);
}
/// A target carrying a newline would put a second line into `.git/HEAD`.
#[test]
fn a_target_carrying_a_newline_is_refused() {
let err = init("refs/heads/main\nref: refs/heads/attacker")
.expect_err("a target with a newline must not reach .git/HEAD");
assert!(
err.contains("not a reference name"),
"refused for the wrong reason: {err}"
);
}
/// And a bare branch name is refused too — not a security case, a correctness
/// one, and it is here because it is the shape a caller is most likely to pass
/// by mistake. `init_clone_dir`'s doc says `branch` is the FULL name, and
/// before this check that sentence was a comment rather than a rule: `ref: main`
/// is a HEAD git cannot resolve, and the clone would have been created broken.
#[test]
fn a_partial_name_is_refused_rather_than_written_as_one() {
let err = init("main").expect_err("`main` is not a fully-qualified reference name");
assert!(err.contains("not a reference name"), "{err}");
}