use super::*;
/// a bare repo to push into, and a work tree with two commits on main.
fn push_pair(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
let (hosted, work) = (root.join("hosted.git"), root.join("work"));
git(root, &["init", "-q", "--bare", hosted.to_str().unwrap()]);
git(root, &["init", "-q", "-b", "main", work.to_str().unwrap()]);
for text in ["one", "two"] {
std::fs::write(work.join("a.txt"), text).unwrap();
git(&work, &["add", "a.txt"]);
git(&work, &["commit", "-qm", text]);
}
(hosted, work)
}
/// pushes through our receive-pack: git runs `--receive-pack` in a shell, and the env vars go in as an `env` prefix.
fn push_through_ours(
config: &ValidatedConfig,
work: &std::path::Path,
hosted: &std::path::Path,
refspec: &str,
) -> std::process::Output {
let ours = receive_pack(config);
let line: Vec<String> = (ours.get_envs())
.filter_map(|(key, value)| Some(format!("{}={}", key.to_str()?, value?.to_str()?)))
.chain(std::iter::once("git".into()))
.chain(
ours.get_args()
.map(|arg| arg.to_string_lossy().into_owned()),
)
.collect();
crate::test_git()
.current_dir(work)
.args(["push", "-q", "--receive-pack"])
.arg(format!("env {}", line.join(" ")))
.arg(hosted)
.arg(refspec)
.output()
.unwrap()
}
#[test]
fn a_push_cannot_make_a_replace_ref() {
let root = tempfile::tempdir().unwrap();
let config = test_config(root.path());
let (hosted, work) = push_pair(root.path());
let first = git(&work, &["rev-parse", "HEAD~1"]);
assert!(
push_through_ours(&config, &work, &hosted, "main")
.status
.success()
);
git(&work, &["replace", &first, "HEAD"]);
let replace = format!("refs/replace/{first}");
let refused = push_through_ours(&config, &work, &hosted, &format!("{replace}:{replace}"));
assert!(!refused.status.success(), "a replace ref was pushed");
let stderr = String::from_utf8_lossy(&refused.stderr);
assert!(stderr.contains("hidden ref"), "{stderr}");
let listed = git(&hosted, &["for-each-ref", REPLACE_REFS]);
assert!(listed.is_empty(), "{listed}");
}
#[test]
fn a_push_runs_no_hook_the_hosted_repo_holds() {
use std::os::unix::fs::PermissionsExt as _;
let root = tempfile::tempdir().unwrap();
let config = test_config(root.path());
let (hosted, work) = push_pair(root.path());
let hooks = root.path().join("hooks");
std::fs::create_dir(&hooks).unwrap();
let names = [
"pre-receive",
"update",
"post-receive",
"post-update",
"reference-transaction",
"proc-receive",
];
for dir in [&hooks, &hosted.join("hooks")] {
for name in names {
let hook = dir.join(name);
std::fs::write(&hook, "#!/bin/sh\nenv > \"$0.ran\"\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
git(
&hosted,
&["config", "core.hooksPath", hooks.to_str().unwrap()],
);
let pushed = push_through_ours(&config, &work, &hosted, "main");
assert!(
pushed.status.success(),
"{}",
String::from_utf8_lossy(&pushed.stderr)
);
assert_eq!(
git(&hosted, &["rev-parse", "main"]),
git(&work, &["rev-parse", "HEAD"])
);
for dir in [&hooks, &hosted.join("hooks")] {
for name in names {
assert!(!dir.join(format!("{name}.ran")).exists(), "{name} ran");
}
}
}
#[test]
fn receive_pack_never_hands_git_the_master_key() {
let root = tempfile::tempdir().unwrap();
let mut config = test_config(root.path());
config.master_key_env = "KS_TEST_MASTER".to_string();
let ours = receive_pack(&config);
let envs: Vec<_> = ours.get_envs().collect();
for secret in ["KS_TEST_MASTER", "KNOT_MASTER_KEY"] {
assert!(
envs.contains(&(std::ffi::OsStr::new(secret), None)),
"{secret} is inherited: {envs:?}"
);
}
let args: Vec<_> = ours.get_args().map(|arg| arg.to_string_lossy()).collect();
assert!(
args.contains(&"core.hooksPath=/dev/null".into()),
"{args:?}"
);
}
fn test_config(root: &std::path::Path) -> ValidatedConfig {
let raw = format!(
"[knot]\nhostname = \"knot.test\"\nowner = \"did:plc:owner\"\n[repo]\nscan_path = \"{root}\"\n[secrets]\nsealed_key_file = \"{root}/keys.sealed\"\n[output]\nroot = \"{root}\"\n[atproto]\nplc_directory = \"https://127.0.0.1:9\"\n",
root = root.display()
);
ValidatedConfig::from_toml(&raw).unwrap()
}
fn git(repo: &std::path::Path, args: &[&str]) -> String {
let output = crate::test_git()
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
#[test]
fn a_push_that_moved_a_ref_leaves_a_commit_graph() {
let root = tempfile::tempdir().unwrap();
let config = test_config(root.path());
let repo_path = root.path().join("repo");
git(root.path(), &["init", "-q", "-b", "main", "repo"]);
std::fs::write(repo_path.join("a.txt"), "a").unwrap();
git(&repo_path, &["add", "-A"]);
git(&repo_path, &["commit", "-q", "-m", "pushed"]);
let pushed = git(&repo_path, &["rev-parse", "HEAD"]);
git(&repo_path, &["update-ref", "-d", "refs/heads/main"]);
let did = RepoDid::new("did:plc:graphafterpush").unwrap();
let (status, _) = receive_locked(&config, &did, &repo_path, || {
Ok(crate::test_git()
.args(["update-ref", "refs/heads/main", &pushed])
.current_dir(&repo_path)
.status()?)
})
.expect("receive-pack under the repo lock");
assert!(status.success());
let repo = knot_git::Repo::open(&repo_path).unwrap();
let graph = gix::commitgraph::Graph::from_info_dir(&repo.objects_dir().join("info"))
.expect("the push wrote a commit-graph");
assert!(
graph
.lookup(gix::ObjectId::from_hex(pushed.as_bytes()).unwrap())
.is_some(),
"the graph covers the commit the push brought"
);
}
fn shape(path: &RepoPath) -> String {
match path {
RepoPath::Did(did) => format!("did {did}"),
RepoPath::Owner(OwnerRef::Did(owner), name) => format!("owner {owner} {name}"),
RepoPath::Owner(OwnerRef::Handle(handle), name) => format!("handle {handle} {name}"),
RepoPath::Rkey(rkey) => format!("rkey {rkey}"),
}
}
#[test]
fn parses_transport_verbs_and_the_repo_forms_web_hands_out() {
for (cmd, verb, path) in [
(
"git-upload-pack '/ptr.pet/ark.git'",
Verb::UploadPack,
"handle ptr.pet ark.git",
),
(
"git-upload-pack 'did:plc:abc/ark'",
Verb::UploadPack,
"owner did:plc:abc ark",
),
(
"git-receive-pack 'did:plc:abc'",
Verb::ReceivePack,
"did did:plc:abc",
),
("git-upload-pack '/ark'", Verb::UploadPack, "rkey ark"),
(
"git receive-pack '/knot-static.git'",
Verb::ReceivePack,
"rkey knot-static",
),
] {
let parsed = parse_command(cmd).unwrap_or_else(|e| panic!("{cmd}: {e}"));
assert_eq!(parsed.verb, verb, "{cmd}");
assert_eq!(shape(&parsed.path), path, "{cmd}");
}
}
#[test]
fn a_path_that_is_neither_a_repo_did_nor_an_owner_and_name_is_refused() {
for cmd in [
"git-upload-pack '/x/y/z.git'",
"git-receive-pack 'did:nonsense'",
"git-receive-pack '/'",
] {
assert!(parse_command(cmd).is_err(), "{cmd}");
}
}
#[test]
fn rejects_non_git_commands() {
for cmd in [
"ls -la",
"rm -rf /",
"git push origin main",
"echo hi",
"",
"git-upload-archive '/ark'",
] {
assert!(parse_command(cmd).is_err(), "{cmd} must be refused");
}
}
#[test]
fn a_hostile_command_is_refused_before_it_names_a_repo() {
for cmd in [
"git-upload-pack 'ark'\n",
"git-upload-pack 'ark'\nrm -rf /",
"git-upload-pack 'a\0b'",
"git-upload-pack '\x1b[2Jark'",
"git-upload-pack\t'ark'",
"git-git-upload-pack 'ark'",
"git-upload-pack",
"git-upload-pack ''",
"git-upload-pack '//'",
"git upload-pack",
"sh -c 'git-upload-pack ark'",
"git-upload-pack.exe 'ark'",
"git -c core.sshCommand=sh upload-pack 'ark'",
"git --config=x upload-pack 'ark'",
"git-upload-pack '/x/../../etc'",
"git-upload-pack '--upload-pack=touch /tmp/pwned'",
"git git-upload-pack 'ark'",
] {
assert!(parse_command(cmd).is_err(), "{cmd:?} must be refused");
}
}
/// what an accepted path can carry: only a lookup key, never an argument git sees.
fn assert_path_is_data(raw: &str, path: &RepoPath) {
let safe_rkey = |rkey: &str| {
knot_types::RepoRkey::new(rkey).is_err()
|| rkey
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._:~-".contains(c))
};
match path {
RepoPath::Did(did) => assert!(
did.as_str().starts_with("did:") && !did.as_str().contains(char::is_whitespace),
"{raw:?}: {did}"
),
RepoPath::Owner(_, name) => {
if let Some(clone) = ClonePath::parse(name) {
for rkey in clone.rkeys() {
assert!(safe_rkey(rkey.as_str()), "{raw:?}: {rkey}");
}
for name in clone.names() {
assert!(
!name.as_str().contains(['/', '\\'])
&& !name.as_str().contains("..")
&& !name.as_str().contains(char::is_whitespace),
"{raw:?}: {name}"
);
}
}
}
RepoPath::Rkey(rkey) => {
assert!(!rkey.is_empty() && !rkey.contains('/'), "{raw:?}: {rkey}");
assert!(safe_rkey(rkey), "{raw:?}: {rkey}");
}
}
}
#[test]
fn an_option_shaped_path_parses_as_data_and_never_as_a_flag() {
for (cmd, expected) in [
(
"git-upload-pack '--upload-pack=touch'",
"rkey --upload-pack=touch",
),
("git-upload-pack '-c'", "rkey -c"),
("git-receive-pack '--config=x'", "rkey --config=x"),
("git-upload-pack 'ark; id'", "rkey ark; id"),
("git-upload-pack '$(id)'", "rkey $(id)"),
] {
let parsed = parse_command(cmd).unwrap_or_else(|e| panic!("{cmd}: {e}"));
assert!(
shape(&parsed.path).starts_with(expected),
"{cmd}: {}",
shape(&parsed.path)
);
assert_path_is_data(cmd, &parsed.path);
if let RepoPath::Rkey(rkey) = &parsed.path {
assert!(
knot_types::RepoRkey::new(rkey).is_err() || !rkey.contains([' ', ';', '$', '=']),
"{rkey} could be provisioned"
);
}
}
}
const COMMAND_TOKENS: &[&str] = &[
"git-upload-pack",
"git-receive-pack",
"git",
"upload-pack",
"receive-pack",
"git-upload-archive",
"git-",
" ",
" ",
"'",
"'",
"/",
"did:plc:abc",
"did:web:x.test",
"ptr.pet",
"ark",
".git",
"--upload-pack=touch /tmp/pwn",
"-c",
"core.sshCommand=sh",
"--config",
"../",
"..",
"\0",
"\n",
"\x1b",
"\u{85}",
";",
"$(id)",
"`id`",
"\\",
"\"",
"%2e%2e",
"~",
"-",
"é",
];
#[test]
fn parse_command_fuzz_accepts_only_fetch_or_push_of_a_plain_path() {
let mut accepted = 0;
for seed in 0..50_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let verb = match rng.below(3) {
0 => "",
_ => *rng.pick(&[
"git-upload-pack '",
"git-receive-pack ",
"git upload-pack '",
]),
};
let raw: String = std::iter::once(verb)
.chain((0..rng.range(1, 10)).map(|_| *rng.pick(COMMAND_TOKENS)))
.collect();
let Ok(parsed) = parse_command(&raw) else {
continue;
};
accepted += 1;
assert!(!raw.contains(char::is_control), "{raw:?}");
let verb = match parsed.verb {
Verb::UploadPack => "upload-pack ",
Verb::ReceivePack => "receive-pack ",
};
assert!(
["git-", "git ", ""]
.iter()
.any(|prefix| raw.starts_with(&format!("{prefix}{verb}"))),
"{raw:?} isn't {verb}"
);
assert_path_is_data(&raw, &parsed.path);
}
assert!(
accepted > 100,
"the fuzzer reached only {accepted} accepted commands"
);
}
fn cgi_request(
target: &str,
authorization: Option<&str>,
content_length: Option<usize>,
) -> crate::cgi::Request {
crate::cgi::Request {
method: "POST".into(),
target: target.into(),
authorization: authorization.map(Into::into),
content_length,
garbled_length: false,
content_type: String::new(),
content_encoding: "gzip".into(),
git_protocol: None,
inflating: false,
}
}
/// hands out at most `chunk` bytes per read, and counts what was taken.
struct Trickle<'a> {
bytes: &'a [u8],
chunk: usize,
taken: usize,
}
impl std::io::Read for Trickle<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = buf.len().min(self.chunk).min(self.bytes.len() - self.taken);
buf[..n].copy_from_slice(&self.bytes[self.taken..self.taken + n]);
self.taken += n;
Ok(n)
}
}
#[test]
fn read_body_fuzz_takes_exactly_a_declared_body_up_to_the_cap() {
use crate::cgi::MAX_BODY_BYTES;
let stream: Vec<u8> = (0..MAX_BODY_BYTES * 2).map(|at| (at % 251) as u8).collect();
for seed in 0..4_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let sent = match rng.below(4) {
0 => rng.below(64),
1 => MAX_BODY_BYTES - 2 + rng.below(5),
_ => rng.below(stream.len()),
};
let declared = match rng.below(5) {
0 => None,
1 => Some(sent + rng.range(1, 3)),
2 => Some(sent.saturating_sub(rng.range(1, 3))),
3 => Some(*rng.pick(&[usize::MAX, MAX_BODY_BYTES + 1, 0])),
_ => Some(sent),
};
let mut reader = Trickle {
bytes: &stream[..sent],
chunk: rng.range(1, 70_000),
taken: 0,
};
let mut body = Vec::new();
let read = crate::cgi::read_body(
&cgi_request("/xrpc/x", None, declared),
&mut reader,
&mut body,
);
let fits = sent <= MAX_BODY_BYTES && declared.is_none_or(|length| length == sent);
assert_eq!(
read.is_ok(),
fits,
"seed {seed}: sent {sent}, declared {declared:?}"
);
assert!(
reader.taken <= MAX_BODY_BYTES + 1,
"seed {seed}: read {} bytes",
reader.taken
);
match read {
Ok(()) => assert_eq!(body, &stream[..sent], "seed {seed}"),
Err(error) => assert!(
[
http::StatusCode::PAYLOAD_TOO_LARGE,
http::StatusCode::BAD_REQUEST
]
.contains(&error.status()),
"seed {seed}: {error}"
),
}
}
}
const TARGET_PIECES: &[&str] = &[
"/",
"/",
"xrpc",
"/xrpc/",
"com.atproto.repo.createRecord",
"org.tangled.review.submitPatch",
"com.atproto.repo.uploadBlob",
"did:plc:abc",
"did:web:x.test",
"..",
".",
"%2e%2e",
"%2f",
"?",
"#",
"&",
"=",
"service=git-receive-pack",
"service=git-upload-pack",
"info/refs",
"git-receive-pack",
"git-upload-pack",
"oauth",
"callback",
"_ks_push",
"_ks_inflate",
"\0",
"\r\n",
" ",
"é",
"//",
];
fn hostile_target(rng: &mut crate::test_rng::Rng) -> String {
let mut target = match rng.below(3) {
0 => format!(
"/{}/{}",
rng.pick(&["did:plc:abc", "did:web:x.test", "..", "did:plc:a/b"]),
rng.pick(&[
"info/refs?service=git-receive-pack",
"git-receive-pack",
"git-upload-pack"
])
),
_ => String::new(),
};
target.extend((0..rng.range(1, 9)).map(|_| *rng.pick(TARGET_PIECES)));
target
}
#[test]
fn route_fuzz_names_only_a_repo_did_or_an_exact_method() {
let mut routed = 0;
for seed in 0..30_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let target = hostile_target(rng);
let request = cgi_request(&target, None, None);
let path = request.path();
assert!(!path.contains(['?', '#']), "seed {seed}: {path:?}");
if let Some((route, did)) = crate::http_push::route(&request) {
routed += 1;
assert!(
!did.contains(['/', '?', '#']),
"seed {seed}: {target:?} -> {did:?}"
);
let tail = match route {
crate::http_push::Route::Advertise => "/info/refs",
crate::http_push::Route::Receive => "/git-receive-pack",
};
assert!(
target.starts_with(&format!("/{did}{tail}")),
"seed {seed}: {target:?}"
);
}
if let Ok(did) = crate::inflate::repo(&request) {
assert_eq!(path, format!("/{did}/git-upload-pack"), "seed {seed}");
assert!(
!did.as_str().contains(['/', '%', '.']) || did.as_str().starts_with("did:web:"),
"{did}"
);
}
if let Some(method) = path
.strip_prefix("/xrpc/")
.and_then(crate::cob_write::Method::from_nsid)
{
assert_eq!(path, format!("/xrpc/{}", method.nsid()), "seed {seed}");
}
}
assert!(routed > 50, "only {routed} targets routed");
}
fn jwt_part(rng: &mut crate::test_rng::Rng) -> String {
use base64::Engine as _;
let claims = match rng.below(4) {
0 => serde_json::json!({ "jti": "n1", "iss": "did:plc:x" }).to_string(),
1 => serde_json::json!({ "jti": 7 }).to_string(),
2 => "{".repeat(rng.range(1, 400)),
_ => (0..rng.below(40))
.map(|_| *rng.pick(&["a", "\0", "\"", "é"]))
.collect(),
};
match rng.below(3) {
0 => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims),
1 => base64::engine::general_purpose::STANDARD.encode(claims),
_ => claims,
}
}
#[test]
fn credential_fuzz_never_panics_and_reads_only_a_bearer_or_basic_password() {
use base64::Engine as _;
for seed in 0..20_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let token = format!("{}.{}.{}", jwt_part(rng), jwt_part(rng), jwt_part(rng));
let header = match rng.below(6) {
0 => format!("Bearer {token}"),
1 => format!("bEaReR {token} "),
2 => format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("x:{token}"))
),
3 => format!("Basic {token}"),
4 => format!("{} {token}", rng.pick(&["Digest", "Bearer\0", "", "Token"])),
_ => token.clone(),
};
let request = cgi_request("/xrpc/x", Some(&header), None);
if let Ok(jwt) = crate::cgi::bearer(&request) {
assert!(
header.to_ascii_lowercase().starts_with("bearer "),
"seed {seed}: {header:?}"
);
let _ = crate::cgi::token_jti(&jwt);
}
if let Ok(jwt) = crate::http_push::credential(&request) {
let lower = header.to_ascii_lowercase();
assert!(
lower.starts_with("bearer ") || lower.starts_with("basic "),
"seed {seed}: {header:?}"
);
let _ = crate::cgi::token_jti(&jwt);
}
}
}
fn gzipped(bytes: &[u8], level: u32) -> Vec<u8> {
use std::io::Write as _;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(level));
encoder.write_all(bytes).unwrap();
encoder.finish().unwrap()
}
#[test]
fn inflate_fuzz_bounds_what_a_gzip_body_becomes_and_drains_the_rest() {
use crate::inflate::MAX_INFLATED_BYTES;
let cap = MAX_INFLATED_BYTES as usize;
let (full, over) = (vec![0; cap], vec![0; cap + 1]);
let (fits, bomb) = (gzipped(&full, 9), gzipped(&over, 9));
assert!(bomb.len() < 64 * 1024, "the bomb is {} bytes", bomb.len());
for seed in 0..2_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let (plain, mut body) = match rng.below(40) {
0 => (full.clone(), fits.clone()),
1 => (over.clone(), bomb.clone()),
_ => {
let plain: Vec<u8> = (0..rng.below(4096)).map(|_| rng.below(256) as u8).collect();
let body = gzipped(&plain, rng.below(10) as u32);
(plain, body)
}
};
let mut intact = true;
match rng.below(8) {
0 => {
body.truncate(rng.below(body.len()));
intact = false;
}
1 => {
let at = rng.below(body.len());
body[at] ^= 1 + rng.below(255) as u8;
intact = false;
}
2 => body.extend((0..rng.range(1, 100)).map(|_| rng.below(256) as u8)),
_ => {}
}
let mut reader = Trickle {
bytes: &body,
chunk: rng.range(1, 9_000),
taken: 0,
};
let inflated = crate::inflate::inflated(&mut reader);
assert_eq!(
reader.taken,
body.len(),
"seed {seed}: the body wasn't drained"
);
match inflated {
Ok(inflated) => {
assert!(inflated.len() <= cap, "seed {seed}");
if intact {
assert_eq!(inflated, plain, "seed {seed}");
}
}
Err(error) => {
assert!(error.status().is_client_error(), "seed {seed}: {error}");
assert!(
!intact || plain.len() > cap,
"seed {seed}: refused a good body: {error}"
);
}
}
}
}
#[test]
fn handback_fuzz_redirects_only_to_a_pack_path() {
let pieces = [
"/_ks_pack",
"/",
"gen",
"tok_1",
"pack-abc",
"..",
"%2e",
"\r\n",
"X-Evil: 1",
"",
"a b",
"-",
];
for seed in 0..20_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let pack: String = (0..rng.range(1, 8)).map(|_| *rng.pick(&pieces)).collect();
let status =
http::StatusCode::from_u16(*rng.pick(&[200, 204, 206, 302, 404, 500])).unwrap();
let header = rng.odds(85).then_some(pack.as_str());
match crate::inflate::handed_back(status, header) {
Ok(Some(redirect)) => {
let path = redirect.as_str();
assert_eq!(status, http::StatusCode::NO_CONTENT, "seed {seed}");
assert!(
path.starts_with("/_ks_pack/") && path.split('/').count() == 5,
"seed {seed}: {path:?}"
);
assert!(
!path.contains("..") && !path.contains(['\r', '\n', ' ', '%']),
"seed {seed}: {path:?}"
);
}
Ok(None) => assert!(!status.is_success(), "seed {seed}"),
Err(error) => assert!(status.is_success(), "seed {seed}: {error}"),
}
}
}
#[test]
fn fork_url_fuzz_keeps_only_the_endpoint_and_the_repo() {
let repo = RepoDid::new("did:plc:fork12345678901234567890").unwrap();
let schemes = [
"https://",
"http://",
"file://",
"ftp://",
"javascript:",
"mailto:",
"data:,",
"",
];
let hosts = [
"knot.test",
"user:pw@knot.test",
"127.0.0.1:8080",
"[::1]",
"",
"knot.test:0",
];
let paths = [
"",
"/",
"/base",
"/base/",
"/a/../b",
"/%2e%2e/",
"/x?y=1",
"/x#frag",
"?q",
"#f",
"/did:plc:other",
];
for seed in 0..20_000u64 {
let rng = &mut crate::test_rng::Rng(seed);
let raw = format!(
"{}{}{}",
rng.pick(&schemes),
rng.pick(&hosts),
rng.pick(&paths)
);
let Ok(endpoint) = url::Url::parse(&raw) else {
continue;
};
let Ok(url) = crate::fork::fork_url(&endpoint, &repo) else {
assert!(
!["https", "http"].contains(&endpoint.scheme()) || endpoint.host().is_none(),
"seed {seed}: {raw}"
);
continue;
};
assert!(
["https", "http"].contains(&url.scheme()),
"seed {seed}: {url}"
);
assert_eq!(
(url.host(), url.port()),
(endpoint.host(), endpoint.port()),
"seed {seed}"
);
assert!(
url.query().is_none() && url.fragment().is_none(),
"seed {seed}: {url}"
);
assert!(
url.username().is_empty() && url.password().is_none(),
"seed {seed}: {url}"
);
assert_eq!(
url.path_segments().unwrap().next_back(),
Some(repo.as_str()),
"seed {seed}: {url}"
);
}
}
fn pkt(line: &[u8]) -> Vec<u8> {
let mut framed = format!("{:04x}", line.len() + 4).into_bytes();
framed.extend_from_slice(line);
framed
}
#[test]
fn receive_pack_fuzz_leaves_refs_alone_on_garbage() {
let root = tempfile::tempdir().unwrap();
let config = test_config(root.path());
let (hosted, work) = push_pair(root.path());
assert!(
push_through_ours(&config, &work, &hosted, "main")
.status
.success()
);
let main = git(&hosted, &["rev-parse", "main"]);
let refs = || {
git(
&hosted,
&["for-each-ref", "--format=%(refname) %(objectname)"],
)
};
let before = refs();
let zero = "0".repeat(40);
for seed in 0..300u64 {
let rng = &mut crate::test_rng::Rng(seed);
let mut stream = Vec::new();
for _ in 0..rng.range(0, 4) {
let name = *rng.pick(&[
"refs/heads/main",
"refs/heads/new",
"refs/cobs/x/y",
"refs/replace/abc",
"refs/tngl/keep/a",
"HEAD",
"refs/heads/../x",
"refs/heads/a\0b",
]);
let (old, new) = (
rng.pick(&[main.as_str(), zero.as_str()]).to_string(),
"1".repeat(40),
);
let caps = if stream.is_empty() {
"\0report-status side-band-64k"
} else {
""
};
match rng.below(4) {
0 => stream.extend((0..rng.below(80)).map(|_| rng.below(256) as u8)),
_ => stream.extend(pkt(format!("{old} {new} {name}{caps}\n").as_bytes())),
}
}
if rng.odds(70) {
stream.extend_from_slice(b"0000");
}
match rng.below(3) {
0 => stream.extend_from_slice(b"PACK\0\0\0\x02\0\0\0\x01garbage"),
1 => stream.extend((0..rng.below(200)).map(|_| rng.below(256) as u8)),
_ => {}
}
let mut ours = receive_pack(&config);
ours.arg("--stateless-rpc").arg(&hosted);
let mut child = ours
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
use std::io::Write as _;
let _ = child.stdin.take().unwrap().write_all(&stream);
let _ = child.wait().unwrap();
assert_eq!(
refs(),
before,
"seed {seed}: {:?}",
String::from_utf8_lossy(&stream)
);
}
}