Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
32 kB · 835 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836//! generated nginx maps and generation-specific config (packet §15).
#[cfg(test)]mod tests;
use std::collections::{BTreeMap, BTreeSet};use std::fmt::Write as _;use std::path::Path;
use anyhow::Context as _;
use crate::config::ValidatedConfig;use crate::manifest::GenerationManifest;use crate::project::errors::StaticXrpcError;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]pub struct LocationClass { pub status: u16, pub content_type: String, pub headers: Vec<(String, String)>,}
fn escape_nginx(s: &str) -> String { s.replace('\\', "\\\\") .replace('"', "\\\"") .replace('$', "\\$")}
/// invariant: nginx keeps query values encoded; cover literal and form-encoded spellings.fn repo_wire_aliases(aliases: &BTreeMap<String, String>) -> BTreeMap<String, String> { aliases .iter() .flat_map(|(param, token)| { let encoded: String = url::form_urlencoded::byte_serialize(param.as_bytes()).collect(); [(param.clone(), token.clone()), (encoded, token.clone())] }) .collect()}
/// invariant: Tangled clients encode query values; live knot-xrpc treats absent cursor as offset 0.fn insert_page_wired( map: &mut BTreeMap<String, String>, token: &str, limit: &str, cursor: &str, resp_path: &str,) { insert_wired(map, &format!("{token}|{limit}|{cursor}"), resp_path); if cursor == "0" { insert_wired(map, &format!("{token}|{limit}|"), resp_path); }}
// invariant: composite map keys join on '|'; escape each segment and rejoin on literal '|'.fn wire_keys(key: &str) -> Vec<String> { if !key.contains('%') && key.contains('|') { let encoded: Vec<String> = key .split('|') .map(|segment| { let mut out = String::with_capacity(segment.len()); for byte in segment.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(byte as char); } _ => { let _ = write!(out, "%{byte:02X}"); } } } out }) .collect(); let joined = encoded.join("|"); if joined != key { return vec![key.to_string(), joined]; } } vec![key.to_string()]}
fn insert_wired(map: &mut BTreeMap<String, String>, key: &str, value: &str) { for spelling in wire_keys(key) { map.insert(spelling, value.to_string()); }}
fn compute_response_path(generation_dir: &Path, body_hash: &str) -> String { let flat = format!("/_responses/{body_hash}"); if body_hash.len() >= 4 && !generation_dir.join("www").join(&flat[1..]).exists() { format!("/_responses/{}/{}", &body_hash[..2], &body_hash[2..]) } else { flat }}
fn emit_map_file( cas: &crate::cas::Cas, maps_dir: &Path, filename: &str, source_var: &str, target_var: &str, entries: &BTreeMap<String, String>,) -> anyhow::Result<()> { let mut out = format!("map {source_var} {target_var} {{\n default \"\";\n"); for (k, v) in entries { writeln!(out, " \"{}\" \"{}\";", escape_nginx(k), escape_nginx(v))?; } out.push_str("}\n"); let dest = maps_dir.join(filename); cas.put_and_link(out.as_bytes(), &dest) .with_context(|| format!("write map file {}", dest.display()))?; Ok(())}
/// emit generation-specific nginx configuration and maps.////// map values and error bodies are generation-independent; only the conf and activation/// wiring carry the generation path.pub fn emit( config: &ValidatedConfig, manifest: &GenerationManifest, generation_dir: &Path,) -> anyhow::Result<()> { emit_at(config, manifest, generation_dir, generation_dir)}
/// build privately while embedding only the final, published serving path.pub fn emit_at( config: &ValidatedConfig, manifest: &GenerationManifest, generation_dir: &Path, served_dir: &Path,) -> anyhow::Result<()> { let served_dir = if served_dir.exists() { std::fs::canonicalize(served_dir)? } else { let parent = served_dir.parent().context("generation parent")?; std::fs::canonicalize(parent)?.join(served_dir.file_name().context("generation name")?) }; let nginx_dir = generation_dir.join("nginx"); let maps_dir = nginx_dir.join("maps"); let www_dir = generation_dir.join("www"); let errors_dir = www_dir.join("_errors"); // invariant: maps and error bodies are content-stable across generations; writing through CAS dedupes at file granularity. let cas = crate::cas::Cas::open(config.output_root.join("cas"));
std::fs::create_dir_all(&maps_dir) .with_context(|| format!("create maps dir: {}", maps_dir.display()))?; std::fs::create_dir_all(&errors_dir) .with_context(|| format!("create errors dir: {}", errors_dir.display()))?; // invariant: runtime writes must not alter the immutable generation inventory. let runtime_dir = config .output_root .join("runtime") .join(served_dir.file_name().context("generation runtime name")?); for name in [ "client_body_temp", "proxy_temp", "fastcgi_temp", "uwsgi_temp", "scgi_temp", ] { std::fs::create_dir_all(runtime_dir.join(name))?; } let runtime_path = std::fs::canonicalize(&runtime_dir)?.display().to_string();
let error_defs = [ ("repo_not_found", StaticXrpcError::repo_not_found()), ("ref_not_found", StaticXrpcError::ref_not_found()), ("path_not_found", StaticXrpcError::path_not_found()), ("file_not_found", StaticXrpcError::file_not_found()), ("branch_not_found", StaticXrpcError::branch_not_found()), ("tag_not_found", StaticXrpcError::tag_not_found()), ( "revision_not_found", StaticXrpcError::revision_not_found("unknown"), ), ( "invalid_request", StaticXrpcError::invalid_request("invalid request"), ), ("disallowed_raw", StaticXrpcError::disallowed_raw_mime()), ( "compare_error", StaticXrpcError::compare_error("comparison is not available from this static knot"), ), ("blob_too_large", StaticXrpcError::blob_too_large()), ( "request_too_large", StaticXrpcError::request_too_large( "request or response exceeds configured maximum size", ), ), ( "method_not_allowed", StaticXrpcError::new(405, "MethodNotAllowed", "method not allowed"), ), ( "internal_error", StaticXrpcError::internal("internal server error"), ), ];
for (name, err) in &error_defs { let err_path = errors_dir.join(format!("{name}.json")); let bytes = serde_json::to_vec_pretty(&err.body())?; cas.put_and_link(&bytes, &err_path) .with_context(|| format!("write error body {}", err_path.display()))?; }
let mut classes: Vec<LocationClass> = Vec::new(); let mut class_to_id: BTreeMap<LocationClass, usize> = BTreeMap::new(); let mut response_class_map: BTreeMap<String, String> = BTreeMap::new();
let mut describe_repo_map: BTreeMap<String, String> = BTreeMap::new(); let mut get_default_branch_map: BTreeMap<String, String> = BTreeMap::new(); let mut branch_map: BTreeMap<String, String> = BTreeMap::new(); let mut branches_map: BTreeMap<String, String> = BTreeMap::new(); let mut tag_map: BTreeMap<String, String> = BTreeMap::new(); let mut tags_map: BTreeMap<String, String> = BTreeMap::new(); let mut list_refs_map: BTreeMap<String, String> = BTreeMap::new(); let mut list_repos_map: BTreeMap<String, String> = BTreeMap::new(); let mut tree_map: BTreeMap<String, String> = BTreeMap::new(); let mut blob_map: BTreeMap<String, String> = BTreeMap::new(); let mut languages_map: BTreeMap<String, String> = BTreeMap::new(); let mut diff_map: BTreeMap<String, String> = BTreeMap::new(); let mut compare_map: BTreeMap<String, String> = BTreeMap::new(); let mut log_map: BTreeMap<String, String> = BTreeMap::new(); let mut archive_map: BTreeMap<String, String> = BTreeMap::new(); let mut blame_map: BTreeMap<String, String> = BTreeMap::new();
let mut did_route: Option<(usize, String)> = None; let mut version_route: Option<(usize, String)> = None; let mut owner_route: Option<(usize, String)> = None; let mut health_route: Option<(usize, String)> = None;
for (route_key, response) in &manifest.routes { let resp_path = compute_response_path(generation_dir, &response.body_hash);
let mut sorted_headers: Vec<(String, String)> = response .headers .iter() .filter(|(k, _)| !k.eq_ignore_ascii_case("content-type")) .cloned() .collect(); sorted_headers.sort();
let loc_class = LocationClass { status: response.status, content_type: response.content_type.clone(), headers: sorted_headers, };
let class_id = *class_to_id.entry(loc_class.clone()).or_insert_with(|| { let id = classes.len(); classes.push(loc_class); id });
response_class_map.insert(resp_path.clone(), class_id.to_string());
let (endpoint, query_str) = route_key .split_once('?') .unwrap_or((route_key.as_str(), "")); let norm_endpoint = endpoint.trim_start_matches('/');
let params: BTreeMap<&str, &str> = query_str .split('&') .filter_map(|pair| pair.split_once('=')) .collect(); let param = |k| params.get(k).copied().unwrap_or("");
// invariant: describeRepo wire param is repoDid; everything else uses repo. let repo_param = params .get("repo") .or_else(|| params.get("repoDid")) .copied() .unwrap_or(""); let repo_token = manifest .repo_aliases .get(repo_param) .map_or(repo_param, String::as_str);
let ref_param = param("ref"); // invariant: knot2 wire bodies echo the requested ref; never collapse spellings through ref_aliases. // invariant: nginx map matching is case-insensitive; repo token must prefix key to prevent collisions. let commit_token = format!("{repo_token}|c_{ref_param}");
match norm_endpoint { ".well-known/did.json" | "did.json" => { did_route = Some((class_id, resp_path)); } "xrpc/sh.tangled.knot.version" | "sh.tangled.knot.version" | "version" => { version_route = Some((class_id, resp_path)); } "xrpc/sh.tangled.owner" | "sh.tangled.owner" | "owner" => { owner_route = Some((class_id, resp_path)); } "xrpc/_health" | "_health" | "health" => { health_route = Some((class_id, resp_path)); } "xrpc/sh.tangled.repo.describeRepo" | "sh.tangled.repo.describeRepo" => { describe_repo_map.insert(repo_token.to_string(), resp_path); } "xrpc/sh.tangled.repo.getDefaultBranch" | "sh.tangled.repo.getDefaultBranch" => { get_default_branch_map.insert(repo_token.to_string(), resp_path); } "xrpc/sh.tangled.repo.branch" | "sh.tangled.repo.branch" => { branch_map.insert(format!("{repo_token}|{}", param("name")), resp_path); } "xrpc/sh.tangled.repo.branches" | "sh.tangled.repo.branches" => { insert_page_wired( &mut branches_map, repo_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.repo.tag" | "sh.tangled.repo.tag" => { // Invariant: wire param is tag= (not name=) per knot-xrpc TagParams. insert_wired( &mut tag_map, &format!("{repo_token}|{}", param("tag")), &resp_path, ); } "xrpc/sh.tangled.repo.tags" | "sh.tangled.repo.tags" => { insert_page_wired( &mut tags_map, repo_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.git.listRefs" | "sh.tangled.git.listRefs" => { insert_page_wired( &mut list_refs_map, repo_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.sync.listRepos" | "sh.tangled.sync.listRepos" => { let limit = param("limit"); let cursor = param("cursor"); let order = param("order"); insert_wired( &mut list_repos_map, &format!("{limit}|{cursor}|{order}"), &resp_path, ); if cursor == "0" { insert_wired(&mut list_repos_map, &format!("{limit}|{order}"), &resp_path); } } "xrpc/sh.tangled.repo.tree" | "sh.tangled.repo.tree" => { insert_wired( &mut tree_map, &format!("{commit_token}|{}", param("path")), &resp_path, ); } "xrpc/sh.tangled.repo.blob" | "sh.tangled.repo.blob" => { insert_wired( &mut blob_map, &format!("{commit_token}|{}|{}", param("path"), param("raw")), &resp_path, ); } "xrpc/sh.tangled.repo.languages" | "sh.tangled.repo.languages" => { languages_map.insert(commit_token, resp_path); } "xrpc/sh.tangled.repo.diff" | "sh.tangled.repo.diff" => { diff_map.insert(commit_token, resp_path); } "xrpc/sh.tangled.repo.compare" | "sh.tangled.repo.compare" => { insert_wired( &mut compare_map, &format!("{repo_token}|{}|{}", param("rev1"), param("rev2")), &resp_path, ); } "xrpc/sh.tangled.repo.log" | "sh.tangled.repo.log" => { insert_page_wired( &mut log_map, &commit_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.repo.archive" | "sh.tangled.repo.archive" => { archive_map.insert(format!("{commit_token}|{}", param("format")), resp_path); } "xrpc/sh.tangled.repo.blame" | "sh.tangled.repo.blame" => { insert_wired( &mut blame_map, &format!("{commit_token}|{}", param("path")), &resp_path, ); } _ => {} } }
emit_map_file( &cas, &maps_dir, "00_repo.conf", "$arg_repo", "$ks_repo", &repo_wire_aliases(&manifest.repo_aliases), )?;
// invariant: repoverify/Go clients spell describeRepo with repoDid=; Bobbin spells repo=<slug>. let repo_did_entries: BTreeMap<String, String> = manifest .repo_aliases .iter() .filter(|(param, _)| param.starts_with("did:") && !param.contains('/')) .map(|(param, token)| (param.clone(), token.clone())) .collect(); emit_map_file( &cas, &maps_dir, "00_repo_did.conf", "$arg_repoDid", "$ks_repo_did", &repo_wire_aliases(&repo_did_entries), )?;
let mut ref_map_entries: BTreeMap<String, String> = BTreeMap::new(); for (key, commit_oid) in &manifest.ref_aliases { insert_wired(&mut ref_map_entries, key, &format!("c_{commit_oid}")); } emit_map_file( &cas, &maps_dir, "01_ref.conf", "\"$ks_repo|$arg_ref\"", "$ks_commit", &ref_map_entries, )?;
emit_map_file( &cas, &maps_dir, "02_response_class.conf", "$ks_response_path", "$ks_location_class", &response_class_map, )?;
// invariant: describe matches either spelling: "$ks_repo|$ks_repo_did" composite. let describe_entries: BTreeMap<String, String> = describe_repo_map .iter() .flat_map(|(token, resp_path)| { [ (format!("{token}|"), resp_path.clone()), (format!("|{token}"), resp_path.clone()), ] }) .collect();
let endpoint_maps = [ ( "10_describe_repo.conf", "\"$ks_repo|$ks_repo_did\"", "$ks_res_describe_repo", &describe_entries, ), ( "11_get_default_branch.conf", "$ks_repo", "$ks_res_get_default_branch", &get_default_branch_map, ), ( "12_branch.conf", "\"$ks_repo|$arg_name\"", "$ks_res_branch", &branch_map, ), ( "13_branches.conf", "\"$ks_repo|$arg_limit|$arg_cursor\"", "$ks_res_branches", &branches_map, ), ( "14_tag.conf", "\"$ks_repo|$arg_tag\"", "$ks_res_tag", &tag_map, ), ( "15_tags.conf", "\"$ks_repo|$arg_limit|$arg_cursor\"", "$ks_res_tags", &tags_map, ), ( "16_list_refs.conf", "\"$ks_repo|$arg_limit|$arg_cursor\"", "$ks_res_list_refs", &list_refs_map, ), ( "17_list_repos.conf", "\"$arg_limit|$arg_cursor|$arg_order\"", "$ks_res_list_repos", &list_repos_map, ), ( "18_tree.conf", "\"$ks_repo|$ks_commit|$arg_path\"", "$ks_res_tree", &tree_map, ), ( "19_blob.conf", "\"$ks_repo|$ks_commit|$arg_path|$arg_raw\"", "$ks_res_blob", &blob_map, ), ( "20_languages.conf", "\"$ks_repo|$ks_commit\"", "$ks_res_languages", &languages_map, ), ( "21_diff.conf", "\"$ks_repo|$ks_commit\"", "$ks_res_diff", &diff_map, ), ( "22_compare.conf", "\"$ks_repo|$arg_rev1|$arg_rev2\"", "$ks_res_compare", &compare_map, ), ( "23_log.conf", "\"$ks_repo|$ks_commit|$arg_limit|$arg_cursor\"", "$ks_res_log", &log_map, ), ( "24_archive.conf", "\"$ks_repo|$ks_commit|$arg_format\"", "$ks_res_archive", &archive_map, ), ( "25_blame.conf", "\"$ks_repo|$ks_commit|$arg_path\"", "$ks_res_blame", &blame_map, ), ]; for (filename, source_var, target_var, map) in endpoint_maps { emit_map_file(&cas, &maps_dir, filename, source_var, target_var, map)?; }
let gen_path_str = served_dir.display().to_string(); let mut conf = String::new();
// invariant: pid file lives in active.conf at a generation-independent path so reloads signal the running master. write!( conf, "error_log {runtime_path}/error.log;\n\nevents {{\n worker_connections 1024;\n}}\n\nhttp {{\n access_log {runtime_path}/access.log;\n client_body_temp_path {runtime_path}/client_body_temp;\n proxy_temp_path {runtime_path}/proxy_temp;\n fastcgi_temp_path {runtime_path}/fastcgi_temp;\n uwsgi_temp_path {runtime_path}/uwsgi_temp;\n scgi_temp_path {runtime_path}/scgi_temp;\n\n" )?;
// invariant: large maps need larger hash tables; must precede the maps include because nginx builds hashes at parse time. write!( conf, " map_hash_bucket_size 512;\n map_hash_max_size 131072;\n\n include {gen_path_str}/nginx/maps/*.conf;\n\n server {{\n listen {};\n server_name {};\n\n", config.activate.listen, config.hostname )?;
conf.push_str(" # JSON Error dispatch locations (packet §22)\n"); for (name, err) in &error_defs { write!( conf, " location = /_ks_error/{name} {{\n internal;\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n error_page {status} ={status} /_ks_error_body/{name}.json;\n return {status};\n }}\n location = /_ks_error_body/{name}.json {{\n internal;\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n alias {gen_path_str}/www/_errors/{name}.json;\n }}\n\n", status = err.status )?; }
conf.push_str(" # Location classes selected by response-path map\n"); if classes.is_empty() { write!( conf, " location /_ks_class/0/ {{\n internal;\n default_type application/json;\n alias {gen_path_str}/www/;\n }}\n\n" )?; } else { for (id, loc_class) in classes.iter().enumerate() { write!( conf, " location /_ks_class/{id}/ {{\n internal;\n default_type \"{}\";\n", escape_nginx(&loc_class.content_type) )?; for (header_name, header_val) in &loc_class.headers { write!( conf, " add_header {header_name} \"{}\" always;\n", escape_nginx(header_val) )?; } write!( conf, " alias {gen_path_str}/www/;\n }}\n\n" )?; } }
conf.push_str(" # Service / identity endpoints\n"); if let Some((did_class, did_path)) = did_route { write!( conf, " location = /.well-known/did.json {{\n rewrite ^ /_ks_class/{did_class}{did_path} last;\n }}\n\n" )?; } else if generation_dir.join("www/.well-known/did.json").exists() { write!( conf, " location = /.well-known/did.json {{\n default_type application/did+ld+json;\n add_header Cache-Control \"public, max-age=300\" always;\n alias {gen_path_str}/www/.well-known/did.json;\n }}\n\n" )?; } else { conf.push_str( " location = /.well-known/did.json {\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{\"error\":\"InvalidRequest\",\"message\":\"did document not found\"}';\n }\n\n" ); } if generation_dir.join("www/oauth/client-metadata.json").exists() { write!( conf, " location = /oauth/client-metadata.json {{\n default_type application/json;\n add_header Cache-Control \"public, max-age=300\" always;\n alias {gen_path_str}/www/oauth/client-metadata.json;\n }}\n\n" )?; } conf.push_str( " location = /oauth/callback {\n proxy_pass http://127.0.0.1:8918;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n\n", );
for (location, route, not_found_msg) in [ ( "/xrpc/sh.tangled.knot.version", version_route, "version not found", ), ("/xrpc/sh.tangled.owner", owner_route, "owner not found"), ("/xrpc/_health", health_route, "health check not found"), ] { if let Some((class_id, resp_path)) = route { write!( conf, " location = {location} {{\n rewrite ^ /_ks_class/{class_id}{resp_path} last;\n }}\n\n" )?; } else { write!( conf, " location = {location} {{\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{{\"error\":\"InvalidRequest\",\"message\":\"{not_found_msg}\"}}';\n }}\n\n" )?; } }
conf.push_str(" # XRPC Endpoints (staged maps dispatch)\n"); // invariant: describeRepo accepts repoDid= or repo=; staged maps resolve either to repo token; both-empty falls to repo_not_found. let endpoint_dispatch = [ ( "sh.tangled.repo.describeRepo", "$ks_res_describe_repo", false, false, "repo_not_found", ), ( "sh.tangled.repo.getDefaultBranch", "$ks_res_get_default_branch", true, false, "ref_not_found", ), ( "sh.tangled.repo.branch", "$ks_res_branch", true, false, "branch_not_found", ), ( "sh.tangled.repo.branches", "$ks_res_branches", true, false, "invalid_request", ), ( "sh.tangled.repo.tag", "$ks_res_tag", true, false, "tag_not_found", ), ( "sh.tangled.repo.tags", "$ks_res_tags", true, false, "invalid_request", ), ( "sh.tangled.git.listRefs", "$ks_res_list_refs", true, false, "invalid_request", ), ( "sh.tangled.sync.listRepos", "$ks_res_list_repos", false, false, "invalid_request", ), ( "sh.tangled.repo.tree", "$ks_res_tree", true, true, "path_not_found", ), ( "sh.tangled.repo.blob", "$ks_res_blob", true, true, "file_not_found", ), ( "sh.tangled.repo.languages", "$ks_res_languages", true, true, "ref_not_found", ), ( "sh.tangled.repo.diff", "$ks_res_diff", true, true, "ref_not_found", ), ( "sh.tangled.repo.compare", "$ks_res_compare", true, false, "compare_error", ), ( "sh.tangled.repo.log", "$ks_res_log", true, true, "invalid_request", ), ( "sh.tangled.repo.archive", "$ks_res_archive", true, true, "invalid_request", ), ( "sh.tangled.repo.blame", "$ks_res_blame", true, true, "file_not_found", ), ];
for (ep, res_var, req_repo, req_commit, err) in endpoint_dispatch { write!( conf, " location = /xrpc/{ep} {{\n set $ks_response_path {res_var};\n" )?; if req_repo { conf.push_str(" if ($ks_repo = \"\") {\n rewrite ^ /_ks_error/repo_not_found last;\n }\n"); } if req_commit { conf.push_str(" if ($ks_commit = \"\") {\n rewrite ^ /_ks_error/ref_not_found last;\n }\n"); } write!( conf, " if ($ks_response_path = \"\") {{\n rewrite ^ /_ks_error/{err} last;\n }}\n if ($ks_location_class = \"\") {{\n rewrite ^ /_ks_error/internal_error last;\n }}\n rewrite ^ /_ks_class/$ks_location_class$ks_response_path last;\n }}\n\n" )?; }
conf.push_str( " # Unknown NSID fallback (packet §22): JSON error, never nginx HTML\n location /xrpc/ {\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{\"error\":\"InvalidRequest\",\"message\":\"unknown endpoint\"}';\n }\n\n" );
conf.push_str(" # Dumb-HTTP Git exports (packet §17)\n"); let mut seen_aliases: BTreeSet<&str> = BTreeSet::new(); for (alias_param, repo_token) in &manifest.repo_aliases { let norm_alias = alias_param.trim_start_matches('/'); if !seen_aliases.insert(norm_alias) { continue; }
// invariant: smart upload-pack negotiation; advertisement and canned want-all pack are static artifacts. // invariant: nginx static handler 405s POST bodies; remap to 200 re-serve. error_page re-entering as GET must reach static file. write!( conf, " location = /{norm_alias}/info/refs {{\n limit_except GET HEAD {{ deny all; }}\n if ($arg_service !~ ^git-upload-pack$) {{ return 403 \"service unsupported\\n\"; }}\n types {{ }} default_type application/x-git-upload-pack-advertisement;\n add_header Cache-Control \"no-store\" always;\n alias {gen_path_str}/www/_git/{repo_token}/smart/advertisement;\n }}\n\n location = /{norm_alias}/git-upload-pack {{\n error_page 405 =200 $uri;\n types {{ }} default_type application/x-git-upload-pack-result;\n add_header Cache-Control \"no-store\" always;\n alias {gen_path_str}/www/_git/{repo_token}/smart/upload-pack;\n }}\n\n location /{norm_alias}/HEAD {{\n limit_except GET HEAD {{ deny all; }}\n default_type text/plain;\n add_header Cache-Control \"public, max-age=60, stale-while-revalidate=300\" always;\n alias {gen_path_str}/www/_git/{repo_token}/HEAD;\n }}\n\n location /{norm_alias}/objects/ {{\n limit_except GET HEAD {{ deny all; }}\n add_header Cache-Control \"public, max-age=31536000, immutable\" always;\n alias {gen_path_str}/www/_git/{repo_token}/objects/;\n }}\n\n" )?; }
conf.push_str( " location / {\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{\"error\":\"InvalidRequest\",\"message\":\"not found\"}';\n }\n }\n}\n" );
let conf_path = nginx_dir.join("knot-static.conf"); std::fs::write(&conf_path, conf.as_bytes()) .with_context(|| format!("write {}", conf_path.display()))?;
Ok(())}