use std::path::{Path, PathBuf};
use knot_types::RepoDid;
use super::{http, start_nginx, test_fixture};
use crate::git_xrpc::{RepoInput, SealedRepo};
use crate::plan::repo_token;
use crate::store::RepoStore;
const DID: &str = "did:plc:gitpathsfixture000000000";
fn git(dir: &Path, args: &[&str]) -> String {
let output = crate::test_git()
.current_dir(dir)
.args(args)
.env("GIT_AUTHOR_DATE", "1700000000 +0000")
.env("GIT_COMMITTER_DATE", "1700000000 +0000")
.output()
.unwrap();
assert!(output.status.success(), "git {args:?}: {output:?}");
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
pub(super) fn deep(levels: usize) -> String {
(0..levels)
.map(|level| format!("d{level}"))
.collect::<Vec<_>>()
.join("/")
+ "/leaf.txt"
}
struct Knot {
port: u16,
_nginx: super::Nginx,
sealed: SealedRepo,
generation: PathBuf,
_temp: tempfile::TempDir,
}
fn knot(files: &[(&str, &str)], links: &[(&str, &str)]) -> Knot {
let nginx_binary = PathBuf::from(
std::env::var_os("NGINX_BINARY").expect("NGINX_BINARY must name the real nginx binary"),
);
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
for (path, body) in files {
let file = repo.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, body).unwrap();
}
for (path, target) in links {
std::os::unix::fs::symlink(target, repo.join(path)).unwrap();
}
git(&repo, &["add", "-A"]);
git(
&repo,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@example.test",
"commit",
"-q",
"-m",
"files",
],
);
let main = git(&repo, &["rev-parse", "main"]);
let (config, mut manifest, generation) = test_fixture(temp.path());
manifest.repos = vec![super::repo_entry(DID)];
let sealed = crate::git_xrpc::seal(&RepoInput {
did: RepoDid::new(DID).unwrap(),
name: "fixture".into(),
export_dir: repo.clone(),
served: RepoStore::served(&temp.path().join("repos/key")),
shared_blobs: temp.path().join("git-xrpc/blobs"),
patches: Some(Vec::new()),
refs_digest: main,
verify: false,
})
.unwrap();
crate::git_xrpc::emit(&generation, std::slice::from_ref(&sealed)).unwrap();
crate::atproto::emit(&generation, &[]).unwrap();
let maps = generation.join("nginx/maps").join(crate::xrpc::MAPS_FILE);
let rows: String = crate::xrpc::wire_spellings(DID, repo_token(DID))
.iter()
.map(|(key, token)| format!(" {key} {token};\n"))
.collect();
let text = std::fs::read_to_string(&maps).unwrap().replacen(
"map $arg_repo $ks_atp_repo {\n default \"\";\n",
&format!("map $arg_repo $ks_atp_repo {{\n default \"\";\n{rows}"),
1,
);
std::fs::write(&maps, text).unwrap();
std::fs::write(temp.path().join("secret"), "SECRET").unwrap();
crate::blobs::emit(temp.path(), &generation, &[], &Default::default()).unwrap();
super::super::emit_at(&config, &manifest, &generation, &generation).unwrap();
let (port, nginx) = start_nginx(&nginx_binary, &generation);
Knot {
port,
_nginx: nginx,
sealed,
generation,
_temp: temp,
}
}
impl Knot {
fn get(&self, nsid: &str, path: &str) -> (u16, Vec<u8>) {
let target = format!("/xrpc/org.tangled.git.{nsid}?repo={DID}&ref=main&path={path}");
let (status, _, body) = http(self.port, "GET", &target, &[], b"");
(status, body)
}
fn oracle(&self, nsid: &str, path: &str) -> (u16, Vec<u8>) {
let rows = &self.sealed.rows();
let (map, missing) = match nsid {
"getEntry" => (&rows.entry, "git_entry_not_found"),
"getBlob" => (&rows.blob, "git_file_not_found"),
"getTree" => (&rows.tree, "git_path_not_found"),
_ => (&rows.blame, "git_not_projected"),
};
let commit = &rows.commit["main"];
match map.get(&format!("{commit}|{path}")) {
Some(file) => (200, std::fs::read(self.sealed.pool.join(file)).unwrap()),
None => {
let body = self.generation.join(format!("www/_errors/{missing}.json"));
(404, std::fs::read(body).unwrap())
}
}
}
fn agrees(&self, nsid: &str, path: &str) -> (u16, Vec<u8>) {
let got = self.get(nsid, path);
let want = self.oracle(nsid, path);
let text =
|answer: &(u16, Vec<u8>)| (answer.0, String::from_utf8_lossy(&answer.1).into_owned());
assert_eq!(text(&got), text(&want), "{nsid} {path}");
got
}
}
fn spellings(path: &str) -> Vec<String> {
crate::xrpc::spellings(path)
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn every_spelling_of_a_laid_out_path_answers_like_the_maps() {
let deep = deep(40);
let files = [
("README.md", "readme\n"),
("src/lib.rs", "fn main() {}\n"),
("pkg/@scope/a+b.ts", "scoped\n"),
("routes/+page.svelte", "plus\n"),
("has space.txt", "space\n"),
("a,b.txt", "comma\n"),
("100%.txt", "percent\n"),
("x%2Fy", "literal\n"),
("%2B", "literal plus\n"),
("%40/inner", "literal at\n"),
("...", "dots\n"),
(deep.as_str(), "deep\n"),
];
let knot = knot(&files, &[("link", "../../secret"), ("srclink", "src")]);
for (path, _) in files {
for spelling in spellings(path)
.into_iter()
.filter(|spelling| !spelling.contains(' '))
{
for nsid in ["getEntry", "getBlob", "getBlame"] {
knot.agrees(nsid, &spelling);
}
}
}
for dir in ["", "src", "pkg/@scope", "d0/d1"] {
for spelling in spellings(dir) {
assert_eq!(knot.agrees("getTree", &spelling).0, 200, "{dir}");
}
}
for path in ["+", "@/inner", "%40%2Finner", "x/y", "x%2Fy"] {
for nsid in ["getEntry", "getBlob", "getTree"] {
knot.agrees(nsid, path);
}
}
assert_eq!(
knot.agrees("getBlob", "%2B"),
(200, b"literal plus\n".to_vec())
);
assert_eq!(
knot.get("getBlob", "src%2flib.rs"),
knot.get("getBlob", "src%2Flib.rs")
);
for spelling in spellings(&deep) {
assert_eq!(knot.agrees("getBlob", &spelling), (200, b"deep\n".to_vec()));
}
let (status, body) = knot.agrees("getBlob", "link");
assert_eq!(
(status, body),
(200, b"../../secret".to_vec()),
"a git symlink is its target"
);
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn hostile_paths_never_leave_the_tree() {
let knot = knot(
&[("README.md", "readme\n"), ("src/lib.rs", "lib\n")],
&[("link", "../../secret")],
);
for path in [
"..",
"../secret",
"..%2Fsecret",
"..%2F..%2F..%2Fsecret",
"%2E%2E%2Fsecret",
"%2e%2e/secret",
"src/../README.md",
"src%2F..%2FREADME.md",
"./README.md",
"src/./lib.rs",
"src//lib.rs",
"src%2F%2Flib.rs",
"/README.md",
"%2FREADME.md",
"src/",
"src%2F",
"src/lib.rs%00",
"src%00lib.rs",
"%C0%AE%C0%AE/secret",
"%C0%AF",
"src%252Flib.rs",
"src/lib.rs,entry.json",
"src/lib.rs%2Centry.json",
"src%5Clib.rs",
"link/secret",
"link%2F..%2F..%2Fsecret",
] {
for nsid in ["getEntry", "getBlob", "getTree", "getBlame"] {
let (status, body) = knot.agrees(nsid, path);
assert!(
!String::from_utf8_lossy(&body).contains("SECRET"),
"{nsid} {path}"
);
assert_ne!(status, 200, "{nsid} {path}");
}
}
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn merge_bases_answer_one_repo_and_refuse_two() {
let knot = knot(&[("README.md", "readme\n")], &[]);
let main = knot.sealed.rows().commit["main"].clone();
let get = |query: &str| {
let target = format!("/xrpc/org.tangled.temp.git.getMergeBase?{query}");
let (status, _, body) = http(knot.port, "GET", &target, &[], b"");
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
(status, body)
};
let error = |query: &str| {
let (status, body) = get(query);
(
status,
body["error"].as_str().unwrap_or_default().to_string(),
)
};
let encoded = crate::xrpc::form_encoded(DID);
let lower = encoded.to_lowercase();
for repos in [
format!("repos={DID}"),
format!("repos={encoded}"),
format!("repos={lower}"),
format!("repos={DID}&repos={DID}"),
format!("repos={encoded}&repos={encoded}"),
format!("repos={DID}&repo=did:plc:other"),
] {
for query in [
format!("{repos}&base=main&head=main"),
format!("base=main&{repos}&head=main"),
] {
let (status, body) = get(&query);
assert_eq!(
(status, body),
(200, serde_json::json!({ "commit": main })),
"{query}"
);
}
}
for query in [
format!("repos={DID}&repos=did:plc:other&base=main&head=main"),
format!("repos=did:plc:other&repos={DID}&base=main&head=main"),
format!("base=main&repos={DID}&head=main&repos=did%3Aplc%3Aother"),
format!("repos={DID}&repos={DID}x&base=main&head=main"),
// nginx compares the raw spelling, so the encoded did counts as a second repo.
format!("repos={DID}&repos={encoded}&base=main&head=main"),
] {
assert_eq!(error(&query), (404, "NotFound".into()), "{query}");
}
for query in [
"base=main&head=main".to_string(),
format!("repo={DID}&base=main&head=main"),
format!("repos=&repos={DID}&base=main&head=main"),
format!("repos={DID}&repos={DID}&repos={DID}&base=main&head=main"),
format!("repos={DID}&head=main"),
] {
assert_eq!(error(&query), (400, "InvalidRequest".into()), "{query}");
}
assert_eq!(
error("repos=did:plc:nope&base=main&head=main"),
(404, "RepoNotFound".into())
);
assert_eq!(
error(&format!("repos={DID}&base=nope&head=main")),
(404, "RefNotFound".into())
);
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn a_diff_limited_to_paths_is_refused() {
let knot = knot(&[("README.md", "readme\n"), ("src/lib.rs", "lib\n")], &[]);
let (pair, _) = knot
.sealed
.rows()
.diff
.iter()
.next()
.expect("a projected diff");
let (base, head) = pair.split_once('|').unwrap();
let get = |extra: &str| {
let target =
format!("/xrpc/org.tangled.git.getDiff?repo={DID}&base={base}&head={head}{extra}");
let (status, _, body) = http(knot.port, "GET", &target, &[], b"");
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
(status, body["error"].as_str().map(str::to_string))
};
for extra in ["", "&paths=", "&paths=&paths="] {
assert_eq!(get(extra), (200, None), "{extra}");
}
for extra in [
"&paths=src",
"&paths=src%2Flib.rs,README.md",
"&paths=&paths=src",
"&PATHS=src",
] {
assert_eq!(get(extra), (404, Some("NotFound".into())), "{extra}");
}
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn the_check_conf_tests_everything_but_the_git_read_fragments() {
let knot = knot(&[("README.md", "readme\n")], &[]);
let nginx_binary = PathBuf::from(std::env::var_os("NGINX_BINARY").unwrap());
let nginx = knot.generation.join("nginx");
// start_nginx gave the full conf its pid and log, the check conf gets them here
let check = nginx.join("tested.conf");
std::fs::write(
&check,
format!(
"pid {};\nerror_log {};\ninclude {};\n",
knot.generation.join("tested.pid").display(),
knot.generation.join("tested.log").display(),
nginx.join(super::super::CHECK_FILE).display()
),
)
.unwrap();
let full = nginx.join("knot-static.conf");
let tested = |path: &Path| {
let output = std::process::Command::new(&nginx_binary)
.arg("-e")
.arg(knot.generation.join("tested.log"))
.args(["-t", "-c"])
.arg(&path)
.output()
.unwrap();
output.status.success()
};
assert!(tested(&full));
assert!(tested(&check));
let broken = |file: &Path| {
let mut permissions = std::fs::metadata(file).unwrap().permissions();
permissions.set_readonly(false);
std::fs::set_permissions(file, permissions).unwrap();
let text = std::fs::read_to_string(file).unwrap();
std::fs::write(file, text.clone() + " \"half a row\n").unwrap();
text
};
let fragment = std::fs::read_dir(&knot.sealed.maps)
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| path.extension().is_some_and(|ext| ext == "conf"))
.unwrap();
let kept = broken(&fragment);
assert!(
!tested(&full),
"a broken fragment fails the full test"
);
assert!(tested(&check), "and isn't read by the check");
std::fs::write(&fragment, kept).unwrap();
broken(&nginx.join("maps").join(crate::xrpc::social::MAPS_FILE));
assert!(!tested(&check), "every other map is still checked");
}