15a970d1
Serve nested repositories over HTTP
a73x 2026-08-11 16:06
Commit message
src/server/http/git_http.rs
| Old | New | ||
|---|---|---|---|
| @@ -33,7 +33,7 @@ pub async fn info_refs( | |||
| 33 | 33 | ||
| 34 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); | 34 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); |
| 35 | 35 | ||
| 36 | let entry = match crate::repos::resolve(&state.repos_dir, repo_name) { | 36 | let entry = match crate::repos::resolve_clone_target(&state.repos_dir, &repo_dot_git) { |
| 37 | Some(e) => e, | 37 | Some(e) => e, |
| 38 | None => { | 38 | None => { |
| 39 | return ( | 39 | return ( |
| @@ -117,7 +117,7 @@ pub async fn upload_pack( | |||
| 117 | ) -> Response { | 117 | ) -> Response { |
| 118 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); | 118 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); |
| 119 | 119 | ||
| 120 | let entry = match crate::repos::resolve(&state.repos_dir, repo_name) { | 120 | let entry = match crate::repos::resolve_clone_target(&state.repos_dir, &repo_dot_git) { |
| 121 | Some(e) => e, | 121 | Some(e) => e, |
| 122 | None => { | 122 | None => { |
| 123 | return ( | 123 | return ( |
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -2,12 +2,13 @@ pub mod git_http; | |||
| 2 | pub mod repo; | 2 | pub mod repo; |
| 3 | pub mod repo_list; | 3 | pub mod repo_list; |
| 4 | 4 | ||
| 5 | use axum::extract::{DefaultBodyLimit, Request}; | 5 | use axum::extract::{DefaultBodyLimit, Request, State}; |
| 6 | use axum::http::uri::{PathAndQuery, Uri}; | ||
| 6 | use axum::http::{HeaderName, HeaderValue}; | 7 | use axum::http::{HeaderName, HeaderValue}; |
| 7 | use axum::middleware::{self, Next}; | 8 | use axum::middleware::{self, Next}; |
| 8 | use axum::response::Response; | 9 | use axum::response::Response; |
| 9 | use axum::Router; | 10 | use axum::Router; |
| 10 | use std::path::PathBuf; | 11 | use std::path::{Path, PathBuf}; |
| 11 | use std::sync::Arc; | 12 | use std::sync::Arc; |
| 12 | 13 | ||
| 13 | #[derive(Debug, Clone)] | 14 | #[derive(Debug, Clone)] |
| @@ -18,7 +19,7 @@ pub struct AppState { | |||
| 18 | 19 | ||
| 19 | pub fn router(state: AppState) -> Router { | 20 | pub fn router(state: AppState) -> Router { |
| 20 | let shared = Arc::new(state); | 21 | let shared = Arc::new(state); |
| 21 | Router::new() | 22 | let routes = Router::new() |
| 22 | .route("/", axum::routing::get(repo_list::handler)) | 23 | .route("/", axum::routing::get(repo_list::handler)) |
| 23 | .route("/{repo_name}", axum::routing::get(repo::overview)) | 24 | .route("/{repo_name}", axum::routing::get(repo::overview)) |
| 24 | .route("/{repo_name}/commits", axum::routing::get(repo::commits)) | 25 | .route("/{repo_name}/commits", axum::routing::get(repo::commits)) |
| @@ -55,7 +56,111 @@ pub fn router(state: AppState) -> Router { | |||
| 55 | .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT)), | 56 | .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT)), |
| 56 | ) | 57 | ) |
| 57 | .layer(middleware::from_fn(add_nosniff)) | 58 | .layer(middleware::from_fn(add_nosniff)) |
| 58 | .with_state(shared) | 59 | .with_state(shared.clone()); |
| 60 | |||
| 61 | // Wrapped around the whole router rather than layered into it: | ||
| 62 | // `Router::layer` runs *after* routing, and a repository name has to be | ||
| 63 | // folded into one segment before the router tries to match it. | ||
| 64 | Router::new() | ||
| 65 | .fallback_service(routes) | ||
| 66 | .layer(middleware::from_fn_with_state(shared, fold_repo_name)) | ||
| 67 | } | ||
| 68 | |||
| 69 | /// Fold a multi-segment repository name into one path segment, so that every | ||
| 70 | /// route below can go on matching `/{repo_name}/...`. | ||
| 71 | /// | ||
| 72 | /// A repository name may contain `/` (`agents/claude-a`) and so may the path | ||
| 73 | /// hanging off it (`/agents/claude-a/tree/src/main.rs`). Where one ends and | ||
| 74 | /// the other begins is not decidable from the URL, so it is decided against | ||
| 75 | /// what is on disk: **the longest leading run of segments that names a | ||
| 76 | /// repository is the repository, and the rest is the sub-route.** | ||
| 77 | /// | ||
| 78 | /// Longest wins because the repository is the concrete fact — a directory | ||
| 79 | /// that exists — while the sub-route vocabulary (`issues`, `tree`, `diff`) is | ||
| 80 | /// ours to define. Preferring it means every repository on disk is reachable | ||
| 81 | /// at its own address, which is exactly the property that was missing. The | ||
| 82 | /// price is that a repository named `agents/issues` would shadow the issue | ||
| 83 | /// list of a repository named `agents`; that is a collision an operator | ||
| 84 | /// chose, and the alternative — preferring the shorter name — leaves the | ||
| 85 | /// nested repository with no address at all, which is the bug, not the fix. | ||
| 86 | /// | ||
| 87 | /// The fold re-emits the name with its separators percent-encoded. The router | ||
| 88 | /// matches on the raw path, where `%2F` is not a separator, and the `Path` | ||
| 89 | /// extractor decodes it again, so handlers see `agents/claude-a` and no route | ||
| 90 | /// or template had to learn about nesting. | ||
| 91 | async fn fold_repo_name( | ||
| 92 | State(state): State<Arc<AppState>>, | ||
| 93 | mut request: Request, | ||
| 94 | next: Next, | ||
| 95 | ) -> Response { | ||
| 96 | if let Some(uri) = folded_uri(&state.repos_dir, request.uri()) { | ||
| 97 | *request.uri_mut() = uri; | ||
| 98 | } | ||
| 99 | next.run(request).await | ||
| 100 | } | ||
| 101 | |||
| 102 | fn folded_uri(repos_dir: &Path, uri: &Uri) -> Option<Uri> { | ||
| 103 | let path = uri.path().strip_prefix('/')?; | ||
| 104 | let raw: Vec<&str> = path.split('/').collect(); | ||
| 105 | // A single segment already routes, and an empty segment anywhere means | ||
| 106 | // this is not a path we have anything to say about. | ||
| 107 | if raw.len() < 2 || raw.iter().any(|segment| segment.is_empty()) { | ||
| 108 | return None; | ||
| 109 | } | ||
| 110 | |||
| 111 | let decoded: Vec<String> = raw.iter().map(|segment| percent_decode(segment)).collect(); | ||
| 112 | let longest = raw.len().min(crate::repos::MAX_DEPTH); | ||
| 113 | |||
| 114 | for take in (2..=longest).rev() { | ||
| 115 | let candidate = decoded[..take].join("/"); | ||
| 116 | if crate::repos::resolve_clone_target(repos_dir, &candidate).is_none() { | ||
| 117 | continue; | ||
| 118 | } | ||
| 119 | |||
| 120 | let mut folded = String::from("/"); | ||
| 121 | folded.push_str(&raw[..take].join("%2F")); | ||
| 122 | for segment in &raw[take..] { | ||
| 123 | folded.push('/'); | ||
| 124 | folded.push_str(segment); | ||
| 125 | } | ||
| 126 | if let Some(query) = uri.query() { | ||
| 127 | folded.push('?'); | ||
| 128 | folded.push_str(query); | ||
| 129 | } | ||
| 130 | |||
| 131 | let mut parts = uri.clone().into_parts(); | ||
| 132 | parts.path_and_query = Some(folded.parse::<PathAndQuery>().ok()?); | ||
| 133 | return Uri::from_parts(parts).ok(); | ||
| 134 | } | ||
| 135 | |||
| 136 | None | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Decode one percent-encoded path segment. Invalid escapes and bytes that do | ||
| 140 | /// not form UTF-8 are left as they are: the result is only ever compared | ||
| 141 | /// against a repository name, and something undecodable is simply not one. | ||
| 142 | fn percent_decode(segment: &str) -> String { | ||
| 143 | if !segment.contains('%') { | ||
| 144 | return segment.to_string(); | ||
| 145 | } | ||
| 146 | |||
| 147 | let bytes = segment.as_bytes(); | ||
| 148 | let mut out = Vec::with_capacity(bytes.len()); | ||
| 149 | let mut index = 0; | ||
| 150 | while index < bytes.len() { | ||
| 151 | if bytes[index] == b'%' && index + 2 < bytes.len() { | ||
| 152 | let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok(); | ||
| 153 | if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { | ||
| 154 | out.push(byte); | ||
| 155 | index += 3; | ||
| 156 | continue; | ||
| 157 | } | ||
| 158 | } | ||
| 159 | out.push(bytes[index]); | ||
| 160 | index += 1; | ||
| 161 | } | ||
| 162 | |||
| 163 | String::from_utf8(out).unwrap_or_else(|_| segment.to_string()) | ||
| 59 | } | 164 | } |
| 60 | 165 | ||
| 61 | /// `X-Content-Type-Options: nosniff` on every response. This is additive — | 166 | /// `X-Content-Type-Options: nosniff` on every response. This is additive — |
| @@ -70,3 +175,111 @@ async fn add_nosniff(request: Request, next: Next) -> Response { | |||
| 70 | ); | 175 | ); |
| 71 | response | 176 | response |
| 72 | } | 177 | } |
| 178 | |||
| 179 | #[cfg(test)] | ||
| 180 | mod tests { | ||
| 181 | use super::*; | ||
| 182 | use std::process::Command; | ||
| 183 | use tempfile::TempDir; | ||
| 184 | |||
| 185 | fn init_bare(repos_dir: &Path, relative: &str) { | ||
| 186 | let path = repos_dir.join(relative); | ||
| 187 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 188 | Command::new("git") | ||
| 189 | .args(["init", "--bare", path.to_str().unwrap()]) | ||
| 190 | .output() | ||
| 191 | .expect("git init --bare failed"); | ||
| 192 | } | ||
| 193 | |||
| 194 | fn fold(repos_dir: &Path, path: &str) -> Option<String> { | ||
| 195 | let uri: Uri = path.parse().unwrap(); | ||
| 196 | folded_uri(repos_dir, &uri).map(|uri| uri.to_string()) | ||
| 197 | } | ||
| 198 | |||
| 199 | #[test] | ||
| 200 | fn a_single_segment_path_is_left_alone() { | ||
| 201 | let tmp = TempDir::new().unwrap(); | ||
| 202 | init_bare(tmp.path(), "alpha.git"); | ||
| 203 | assert_eq!(fold(tmp.path(), "/alpha"), None); | ||
| 204 | assert_eq!(fold(tmp.path(), "/"), None); | ||
| 205 | } | ||
| 206 | |||
| 207 | #[test] | ||
| 208 | fn a_nested_name_is_folded_and_its_sub_route_kept() { | ||
| 209 | let tmp = TempDir::new().unwrap(); | ||
| 210 | init_bare(tmp.path(), "agents/claude-a.git"); | ||
| 211 | |||
| 212 | assert_eq!( | ||
| 213 | fold(tmp.path(), "/agents/claude-a").as_deref(), | ||
| 214 | Some("/agents%2Fclaude-a") | ||
| 215 | ); | ||
| 216 | assert_eq!( | ||
| 217 | fold(tmp.path(), "/agents/claude-a/issues").as_deref(), | ||
| 218 | Some("/agents%2Fclaude-a/issues") | ||
| 219 | ); | ||
| 220 | assert_eq!( | ||
| 221 | fold(tmp.path(), "/agents/claude-a/tree/main/src/lib.rs").as_deref(), | ||
| 222 | Some("/agents%2Fclaude-a/tree/main/src/lib.rs") | ||
| 223 | ); | ||
| 224 | // The clone-URL spelling folds too, suffix intact for git_http. | ||
| 225 | assert_eq!( | ||
| 226 | fold( | ||
| 227 | tmp.path(), | ||
| 228 | "/agents/claude-a.git/info/refs?service=git-upload-pack" | ||
| 229 | ) | ||
| 230 | .as_deref(), | ||
| 231 | Some("/agents%2Fclaude-a.git/info/refs?service=git-upload-pack") | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | |||
| 235 | /// Nothing on disk to fold onto: the path goes through untouched and | ||
| 236 | /// routing 404s it exactly as before. | ||
| 237 | #[test] | ||
| 238 | fn a_path_that_names_no_repository_is_left_alone() { | ||
| 239 | let tmp = TempDir::new().unwrap(); | ||
| 240 | init_bare(tmp.path(), "alpha.git"); | ||
| 241 | assert_eq!(fold(tmp.path(), "/agents/claude-a"), None); | ||
| 242 | assert_eq!(fold(tmp.path(), "/alpha/issues"), None); | ||
| 243 | assert_eq!(fold(tmp.path(), "/alpha/tree/src/main.rs"), None); | ||
| 244 | } | ||
| 245 | |||
| 246 | /// The disambiguation rule, stated as a test: the longest leading run of | ||
| 247 | /// segments that names a repository is the repository. | ||
| 248 | #[test] | ||
| 249 | fn the_longest_repository_prefix_wins() { | ||
| 250 | let tmp = TempDir::new().unwrap(); | ||
| 251 | init_bare(tmp.path(), "agents.git"); | ||
| 252 | init_bare(tmp.path(), "agents/claude-a.git"); | ||
| 253 | |||
| 254 | // `agents` is a repository and `agents/claude-a` is a longer one. | ||
| 255 | assert_eq!( | ||
| 256 | fold(tmp.path(), "/agents/claude-a/issues").as_deref(), | ||
| 257 | Some("/agents%2Fclaude-a/issues") | ||
| 258 | ); | ||
| 259 | // With nothing longer to match, `agents` keeps its own sub-routes. | ||
| 260 | assert_eq!(fold(tmp.path(), "/agents/issues"), None); | ||
| 261 | } | ||
| 262 | |||
| 263 | #[test] | ||
| 264 | fn a_dot_prefixed_or_traversing_path_folds_onto_nothing() { | ||
| 265 | let tmp = TempDir::new().unwrap(); | ||
| 266 | init_bare(tmp.path(), "alpha.git"); | ||
| 267 | std::fs::create_dir_all(tmp.path().join(".server")).unwrap(); | ||
| 268 | init_bare(tmp.path(), ".server/sneaky.git"); | ||
| 269 | |||
| 270 | assert_eq!(fold(tmp.path(), "/.server/sneaky"), None); | ||
| 271 | assert_eq!(fold(tmp.path(), "/../alpha"), None); | ||
| 272 | assert_eq!(fold(tmp.path(), "/%2E%2E/alpha"), None); | ||
| 273 | assert_eq!(fold(tmp.path(), "/alpha//issues"), None); | ||
| 274 | } | ||
| 275 | |||
| 276 | #[test] | ||
| 277 | fn percent_escapes_decode_and_bad_ones_survive() { | ||
| 278 | assert_eq!(percent_decode("plain"), "plain"); | ||
| 279 | assert_eq!(percent_decode("agents%2Fclaude-a"), "agents/claude-a"); | ||
| 280 | assert_eq!(percent_decode("a%2"), "a%2"); | ||
| 281 | assert_eq!(percent_decode("a%zz"), "a%zz"); | ||
| 282 | // Not valid UTF-8 once decoded: returned unchanged rather than lossy. | ||
| 283 | assert_eq!(percent_decode("a%FFb"), "a%FFb"); | ||
| 284 | } | ||
| 285 | } | ||
src/server/repos.rs
| Old | New | ||
|---|---|---|---|
| @@ -158,11 +158,110 @@ pub fn repo_server_config_path(entry_path: &Path, bare: bool) -> PathBuf { | |||
| 158 | } | 158 | } |
| 159 | } | 159 | } |
| 160 | 160 | ||
| 161 | fn repo_name(dir_name: &str) -> String { | 161 | /// How deep under `repos_dir` a repository may be nested, in path segments. |
| 162 | dir_name | 162 | /// `agents/claude-a` is two. |
| 163 | .strip_suffix(".git") | 163 | /// |
| 164 | .unwrap_or(dir_name) | 164 | /// Bounded rather than unlimited, even though gitolite allows arbitrary |
| 165 | .to_string() | 165 | /// nesting, because `discover` walks every directory that is not a repository |
| 166 | /// and is reached from an anonymous, unauthenticated page. Without a bound, | ||
| 167 | /// whatever an operator happens to leave under `repos_dir` sets the cost of | ||
| 168 | /// rendering the repository list. Eight is far past any layout anyone has | ||
| 169 | /// asked for and keeps the worst case finite. Resolution applies the same | ||
| 170 | /// bound, so a repository is never resolvable but unlisted. | ||
| 171 | pub const MAX_DEPTH: usize = 8; | ||
| 172 | |||
| 173 | /// Whether this directory is a git repository, by the same test used to build | ||
| 174 | /// an entry from it. | ||
| 175 | fn is_repo_dir(path: &Path) -> bool { | ||
| 176 | path.join("HEAD").is_file() || path.join(".git").is_dir() | ||
| 177 | } | ||
| 178 | |||
| 179 | /// The name a repository at `path` is served under. | ||
| 180 | /// | ||
| 181 | /// The rule: its path relative to `repos_dir`, `/`-separated, with a trailing | ||
| 182 | /// `.git` stripped from the last segment — unless a sibling directory with | ||
| 183 | /// the stripped name is itself a repository, in which case the suffix stays | ||
| 184 | /// and the two remain distinct. `tools.git` and `tools` are two directories | ||
| 185 | /// and therefore two repositories; collapsing them onto one name left | ||
| 186 | /// whichever `discover` reached second permanently unreachable. | ||
| 187 | /// | ||
| 188 | /// This is the same key governance writes its rules against | ||
| 189 | /// (`governance::repo_key`), so a rule and a URL name the same repository. | ||
| 190 | fn repo_name_for(repos_dir: &Path, path: &Path) -> Option<String> { | ||
| 191 | let relative = path.strip_prefix(repos_dir).ok()?; | ||
| 192 | |||
| 193 | let mut segments: Vec<String> = Vec::new(); | ||
| 194 | for component in relative.components() { | ||
| 195 | match component { | ||
| 196 | std::path::Component::Normal(segment) => { | ||
| 197 | segments.push(segment.to_str()?.to_string()); | ||
| 198 | } | ||
| 199 | // `..`, a root, or a drive prefix: not a name under repos_dir. | ||
| 200 | _ => return None, | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | if segments.is_empty() || segments.len() > MAX_DEPTH { | ||
| 205 | return None; | ||
| 206 | } | ||
| 207 | if segments.iter().any(|s| s.starts_with('.')) { | ||
| 208 | return None; | ||
| 209 | } | ||
| 210 | |||
| 211 | let last = segments.last()?.clone(); | ||
| 212 | if let Some(stem) = last.strip_suffix(".git") { | ||
| 213 | if !stem.is_empty() && !stem.starts_with('.') { | ||
| 214 | let mut sibling = path.to_path_buf(); | ||
| 215 | sibling.set_file_name(stem); | ||
| 216 | if !is_repo_dir(&sibling) { | ||
| 217 | *segments.last_mut()? = stem.to_string(); | ||
| 218 | } | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | Some(segments.join("/")) | ||
| 223 | } | ||
| 224 | |||
| 225 | /// Split a requested name into path segments, or `None` if it is not a name | ||
| 226 | /// this server will serve: empty, too deep, or containing a segment that is | ||
| 227 | /// empty, dot-prefixed (`.`, `..`, `.server`), or otherwise not a plain | ||
| 228 | /// directory name. This is the gate against traversal — `resolve` builds a | ||
| 229 | /// path from the name rather than searching for it. | ||
| 230 | fn name_segments(name: &str) -> Option<Vec<&str>> { | ||
| 231 | if name.is_empty() { | ||
| 232 | return None; | ||
| 233 | } | ||
| 234 | let segments: Vec<&str> = name.split('/').collect(); | ||
| 235 | if segments.len() > MAX_DEPTH { | ||
| 236 | return None; | ||
| 237 | } | ||
| 238 | if segments | ||
| 239 | .iter() | ||
| 240 | .any(|s| s.is_empty() || s.starts_with('.') || s.contains('\0')) | ||
| 241 | { | ||
| 242 | return None; | ||
| 243 | } | ||
| 244 | Some(segments) | ||
| 245 | } | ||
| 246 | |||
| 247 | /// Walk `segments` down from `repos_dir`, refusing to pass *through* a | ||
| 248 | /// symbolic link. | ||
| 249 | /// | ||
| 250 | /// The final segment may be a link — a repository symlinked into place is a | ||
| 251 | /// legitimate layout, and one that has always been discovered — but an | ||
| 252 | /// intermediate link could point anywhere, so neither a URL nor the directory | ||
| 253 | /// walk is allowed to follow one. Keeping both to the same rule is what stops | ||
| 254 | /// a repository being resolvable but unlisted, or the reverse. | ||
| 255 | fn descend(repos_dir: &Path, segments: &[&str]) -> Option<PathBuf> { | ||
| 256 | let mut path = repos_dir.to_path_buf(); | ||
| 257 | for (index, segment) in segments.iter().enumerate() { | ||
| 258 | path.push(segment); | ||
| 259 | let is_last = index + 1 == segments.len(); | ||
| 260 | if !is_last && std::fs::symlink_metadata(&path).ok()?.is_symlink() { | ||
| 261 | return None; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | Some(path) | ||
| 166 | } | 265 | } |
| 167 | 266 | ||
| 168 | fn load_policy(path: &Path, bare: bool) -> RepoPolicy { | 267 | fn load_policy(path: &Path, bare: bool) -> RepoPolicy { |
| @@ -196,17 +295,17 @@ fn load_policy(path: &Path, bare: bool) -> RepoPolicy { | |||
| 196 | } | 295 | } |
| 197 | } | 296 | } |
| 198 | 297 | ||
| 199 | fn repo_entry_from_path(path: &Path, dir_name: &str) -> Option<RepoEntry> { | 298 | fn repo_entry_from_path(path: &Path, name: String) -> Option<RepoEntry> { |
| 200 | if path.join("HEAD").is_file() { | 299 | if path.join("HEAD").is_file() { |
| 201 | Some(RepoEntry { | 300 | Some(RepoEntry { |
| 202 | name: repo_name(dir_name), | 301 | name, |
| 203 | path: path.to_path_buf(), | 302 | path: path.to_path_buf(), |
| 204 | bare: true, | 303 | bare: true, |
| 205 | policy: load_policy(path, true), | 304 | policy: load_policy(path, true), |
| 206 | }) | 305 | }) |
| 207 | } else if path.join(".git").is_dir() { | 306 | } else if path.join(".git").is_dir() { |
| 208 | Some(RepoEntry { | 307 | Some(RepoEntry { |
| 209 | name: dir_name.to_string(), | 308 | name, |
| 210 | path: path.to_path_buf(), | 309 | path: path.to_path_buf(), |
| 211 | bare: false, | 310 | bare: false, |
| 212 | policy: load_policy(path, false), | 311 | policy: load_policy(path, false), |
| @@ -216,14 +315,40 @@ fn repo_entry_from_path(path: &Path, dir_name: &str) -> Option<RepoEntry> { | |||
| 216 | } | 315 | } |
| 217 | } | 316 | } |
| 218 | 317 | ||
| 219 | /// Build a repo entry directly from an on-disk path. | 318 | /// Build a repo entry directly from an on-disk path under `repos_dir`. |
| 220 | pub fn entry_for_path(path: &Path) -> Option<RepoEntry> { | 319 | /// |
| 320 | /// Takes `repos_dir` because the name is a property of where the repository | ||
| 321 | /// sits in the tree, not of its own directory name: SSH addresses a | ||
| 322 | /// repository by path and HTTP by name, and the two have to be the same | ||
| 323 | /// repository or a repository reachable over one is invisible over the other. | ||
| 324 | pub fn entry_for_path(repos_dir: &Path, path: &Path) -> Option<RepoEntry> { | ||
| 221 | if !path.is_dir() { | 325 | if !path.is_dir() { |
| 222 | return None; | 326 | return None; |
| 223 | } | 327 | } |
| 224 | 328 | ||
| 225 | let dir_name = path.file_name()?.to_string_lossy().to_string(); | 329 | let name = repo_name_for(repos_dir, path)?; |
| 226 | repo_entry_from_path(path, &dir_name) | 330 | let mut entry = repo_entry_from_path(path, name)?; |
| 331 | apply_governance_default(repos_dir, &mut entry); | ||
| 332 | Some(entry) | ||
| 333 | } | ||
| 334 | |||
| 335 | /// Apply the governance repository's closed-by-default posture. | ||
| 336 | /// | ||
| 337 | /// Creating `settings.git` must not silently publish the key roster and the | ||
| 338 | /// access rules to the internet. An operator who wants them browsable can say | ||
| 339 | /// so in `settings.git/.collab/server.toml`, and then this steps out of the | ||
| 340 | /// way. Authenticated access over SSH is unaffected — it never comes through | ||
| 341 | /// here — so a contributor can still read the rules it is subject to. | ||
| 342 | /// | ||
| 343 | /// Applied wherever an entry is built, not only in `discover`: the anonymous | ||
| 344 | /// HTTP surface reaches a repository by resolving a name, and a default that | ||
| 345 | /// only the listing page honoured would not be a default at all. | ||
| 346 | fn apply_governance_default(repos_dir: &Path, entry: &mut RepoEntry) { | ||
| 347 | if is_governance_repo(repos_dir, entry) | ||
| 348 | && !repo_server_config_path(&entry.path, entry.bare).exists() | ||
| 349 | { | ||
| 350 | entry.policy = std::mem::take(&mut entry.policy).without_anonymous_access(); | ||
| 351 | } | ||
| 227 | } | 352 | } |
| 228 | 353 | ||
| 229 | /// Whether this entry is the repository that governs the server. | 354 | /// Whether this entry is the repository that governs the server. |
| @@ -231,47 +356,123 @@ fn is_governance_repo(repos_dir: &Path, entry: &RepoEntry) -> bool { | |||
| 231 | entry.path == repos_dir.join(format!("{}.git", crate::governance::SETTINGS_REPO)) | 356 | entry.path == repos_dir.join(format!("{}.git", crate::governance::SETTINGS_REPO)) |
| 232 | } | 357 | } |
| 233 | 358 | ||
| 234 | /// Scan a directory for git repositories. | 359 | /// Scan `repos_dir` for git repositories, recursively. |
| 235 | /// | 360 | /// |
| 236 | /// This is the only route by which the anonymous HTTP surface reaches a | 361 | /// Nesting is the point: governance hands an agent a wild repo at |
| 237 | /// repository, which is where the governance repository's default is applied: | 362 | /// `agents/<name>`, and a single `read_dir` never saw past the plain `agents` |
| 238 | /// creating `settings.git` must not silently publish the key roster and the | 363 | /// directory, so the repository existed, accepted pushes, and appeared |
| 239 | /// access rules to the internet. An operator who wants them browsable can say | 364 | /// nowhere. |
| 240 | /// so in `settings.git/.collab/server.toml`, and then this steps out of the | 365 | /// |
| 241 | /// way. Authenticated access over SSH is unaffected — it never comes through | 366 | /// The walk stops at each repository. A bare repo is full of directories and |
| 242 | /// here — so a contributor can still read the rules it is subject to. | 367 | /// a non-bare one has a whole working tree; descending into either would be |
| 368 | /// slow and would invent entries out of object directories. It also skips | ||
| 369 | /// anything dot-prefixed, which keeps `.server/` — the SSH host key — out | ||
| 370 | /// along with anything else an operator has tucked away. | ||
| 371 | /// | ||
| 372 | /// Results are uncached, deliberately. A wild repo is created by a push over | ||
| 373 | /// SSH and has to be visible on the next HTTP request; that is the entire | ||
| 374 | /// workflow it exists for. `resolve` no longer comes through here, so the | ||
| 375 | /// per-request cost this once carried is gone, and what remains is one walk | ||
| 376 | /// per rendering of the repository list. | ||
| 243 | pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { | 377 | pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { |
| 244 | let mut entries = Vec::new(); | 378 | let mut entries = Vec::new(); |
| 379 | // The top level is the caller's business — a missing or unreadable | ||
| 380 | // repos_dir is a real error. Failures further down are not worth | ||
| 381 | // blanking the whole listing for. | ||
| 245 | let read_dir = std::fs::read_dir(repos_dir)?; | 382 | let read_dir = std::fs::read_dir(repos_dir)?; |
| 383 | collect(repos_dir, read_dir, 1, &mut entries); | ||
| 384 | entries.sort_by(|a, b| a.name.cmp(&b.name)); | ||
| 385 | Ok(entries) | ||
| 386 | } | ||
| 246 | 387 | ||
| 388 | fn collect( | ||
| 389 | repos_dir: &Path, | ||
| 390 | read_dir: std::fs::ReadDir, | ||
| 391 | depth: usize, | ||
| 392 | entries: &mut Vec<RepoEntry>, | ||
| 393 | ) { | ||
| 247 | for entry in read_dir { | 394 | for entry in read_dir { |
| 248 | let entry = entry?; | 395 | let entry = match entry { |
| 396 | Ok(entry) => entry, | ||
| 397 | Err(error) => { | ||
| 398 | tracing::warn!("skipping unreadable directory entry: {}", error); | ||
| 399 | continue; | ||
| 400 | } | ||
| 401 | }; | ||
| 402 | |||
| 403 | if entry.file_name().to_string_lossy().starts_with('.') { | ||
| 404 | continue; | ||
| 405 | } | ||
| 406 | |||
| 249 | let path = entry.path(); | 407 | let path = entry.path(); |
| 250 | if !path.is_dir() { | 408 | if !path.is_dir() { |
| 251 | continue; | 409 | continue; |
| 252 | } | 410 | } |
| 253 | 411 | ||
| 254 | let dir_name = entry.file_name().to_string_lossy().to_string(); | 412 | if let Some(repo) = entry_for_path(repos_dir, &path) { |
| 255 | if let Some(mut repo) = repo_entry_from_path(&path, &dir_name) { | ||
| 256 | if is_governance_repo(repos_dir, &repo) | ||
| 257 | && !repo_server_config_path(&repo.path, repo.bare).exists() | ||
| 258 | { | ||
| 259 | repo.policy = repo.policy.without_anonymous_access(); | ||
| 260 | } | ||
| 261 | entries.push(repo); | 413 | entries.push(repo); |
| 414 | continue; | ||
| 262 | } | 415 | } |
| 263 | } | ||
| 264 | 416 | ||
| 265 | entries.sort_by(|a, b| a.name.cmp(&b.name)); | 417 | // Not a repository, so look inside it — unless it is a symlink, which |
| 266 | Ok(entries) | 418 | // could point out of the tree or back into it. |
| 419 | let is_symlink = entry.file_type().map(|t| t.is_symlink()).unwrap_or(true); | ||
| 420 | if is_symlink || depth >= MAX_DEPTH { | ||
| 421 | continue; | ||
| 422 | } | ||
| 423 | |||
| 424 | match std::fs::read_dir(&path) { | ||
| 425 | Ok(read_dir) => collect(repos_dir, read_dir, depth + 1, entries), | ||
| 426 | Err(error) => { | ||
| 427 | tracing::warn!("skipping unreadable directory {:?}: {}", path, error) | ||
| 428 | } | ||
| 429 | } | ||
| 430 | } | ||
| 267 | } | 431 | } |
| 268 | 432 | ||
| 269 | /// Resolve a repo name to its on-disk entry. | 433 | /// Resolve a repo name to its on-disk entry. |
| 270 | /// Note: this performs a full directory scan on each call. Fine for a small | 434 | /// |
| 271 | /// number of repos; consider caching with a short TTL if this becomes a bottleneck. | 435 | /// A name determines a path, so this probes rather than searches: no |
| 436 | /// directory scan, no cache to go stale, and a repository created moments ago | ||
| 437 | /// over SSH answers on the next request. The name is validated first — | ||
| 438 | /// nothing empty, dot-prefixed, or past the depth limit — and the walk down | ||
| 439 | /// refuses to pass through a symlink, so a name cannot address anything | ||
| 440 | /// outside `repos_dir`. | ||
| 441 | /// | ||
| 442 | /// The entry is only returned if its canonical name is the one asked for. | ||
| 443 | /// That is what keeps `tools` and `tools.git` apart: each answers to its own | ||
| 444 | /// name and neither answers to the other's. | ||
| 272 | pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> { | 445 | pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> { |
| 273 | let entries = discover(repos_dir).ok()?; | 446 | let segments = name_segments(name)?; |
| 274 | entries.into_iter().find(|e| e.name == name) | 447 | |
| 448 | // The canonical bare layout first, then the literal directory. | ||
| 449 | let mut suffixed: Vec<&str> = segments.clone(); | ||
| 450 | let with_git = format!("{}.git", segments.last()?); | ||
| 451 | *suffixed.last_mut()? = &with_git; | ||
| 452 | |||
| 453 | for candidate in [suffixed, segments] { | ||
| 454 | let Some(path) = descend(repos_dir, &candidate) else { | ||
| 455 | continue; | ||
| 456 | }; | ||
| 457 | if let Some(entry) = entry_for_path(repos_dir, &path) { | ||
| 458 | if entry.name == name { | ||
| 459 | return Some(entry); | ||
| 460 | } | ||
| 461 | } | ||
| 462 | } | ||
| 463 | |||
| 464 | None | ||
| 465 | } | ||
| 466 | |||
| 467 | /// Resolve the repository a git smart-HTTP URL names. | ||
| 468 | /// | ||
| 469 | /// `foo.git` is the conventional clone-URL spelling of the repository `foo`, | ||
| 470 | /// but a directory literally named `foo.git` sitting next to a repository | ||
| 471 | /// `foo` is a repository in its own right. The literal name is tried first so | ||
| 472 | /// that each of the two is clonable, and the conventional spelling still | ||
| 473 | /// works wherever there is nothing to collide with. | ||
| 474 | pub fn resolve_clone_target(repos_dir: &Path, requested: &str) -> Option<RepoEntry> { | ||
| 475 | resolve(repos_dir, requested).or_else(|| resolve(repos_dir, requested.strip_suffix(".git")?)) | ||
| 275 | } | 476 | } |
| 276 | 477 | ||
| 277 | /// Open a git2::Repository from a RepoEntry. | 478 | /// Open a git2::Repository from a RepoEntry. |
| @@ -351,6 +552,203 @@ mod tests { | |||
| 351 | assert_eq!(entry.name, "myrepo"); | 552 | assert_eq!(entry.name, "myrepo"); |
| 352 | } | 553 | } |
| 353 | 554 | ||
| 555 | // --- nesting ------------------------------------------------------- | ||
| 556 | |||
| 557 | #[test] | ||
| 558 | fn discover_finds_nested_repos_under_their_full_path() { | ||
| 559 | let tmp = TempDir::new().unwrap(); | ||
| 560 | init_bare(tmp.path(), "alpha.git"); | ||
| 561 | let agents = tmp.path().join("agents"); | ||
| 562 | std::fs::create_dir_all(&agents).unwrap(); | ||
| 563 | init_bare(&agents, "claude-a.git"); | ||
| 564 | |||
| 565 | let names: Vec<String> = discover(tmp.path()) | ||
| 566 | .unwrap() | ||
| 567 | .into_iter() | ||
| 568 | .map(|r| r.name) | ||
| 569 | .collect(); | ||
| 570 | assert_eq!(names, vec!["agents/claude-a", "alpha"]); | ||
| 571 | } | ||
| 572 | |||
| 573 | #[test] | ||
| 574 | fn resolve_finds_a_nested_repo_by_its_full_path() { | ||
| 575 | let tmp = TempDir::new().unwrap(); | ||
| 576 | let agents = tmp.path().join("agents"); | ||
| 577 | std::fs::create_dir_all(&agents).unwrap(); | ||
| 578 | init_bare(&agents, "claude-a.git"); | ||
| 579 | |||
| 580 | let entry = resolve(tmp.path(), "agents/claude-a").unwrap(); | ||
| 581 | assert_eq!(entry.name, "agents/claude-a"); | ||
| 582 | assert_eq!(entry.path, agents.join("claude-a.git")); | ||
| 583 | // The last component alone is not a name for it. | ||
| 584 | assert!(resolve(tmp.path(), "claude-a").is_none()); | ||
| 585 | // Neither is the plain directory that contains it. | ||
| 586 | assert!(resolve(tmp.path(), "agents").is_none()); | ||
| 587 | } | ||
| 588 | |||
| 589 | /// A repository is a leaf. Descending into one would walk the object | ||
| 590 | /// store, which is both slow and a source of nonsense entries. | ||
| 591 | #[test] | ||
| 592 | fn discover_does_not_descend_into_a_repository() { | ||
| 593 | let tmp = TempDir::new().unwrap(); | ||
| 594 | init_bare(tmp.path(), "alpha.git"); | ||
| 595 | init_non_bare(tmp.path(), "work"); | ||
| 596 | // A bare repo has directories inside it (objects/, refs/), and a | ||
| 597 | // non-bare one has a whole working tree. | ||
| 598 | std::fs::create_dir_all(tmp.path().join("work").join("sub")).unwrap(); | ||
| 599 | |||
| 600 | let names: Vec<String> = discover(tmp.path()) | ||
| 601 | .unwrap() | ||
| 602 | .into_iter() | ||
| 603 | .map(|r| r.name) | ||
| 604 | .collect(); | ||
| 605 | assert_eq!(names, vec!["alpha", "work"]); | ||
| 606 | } | ||
| 607 | |||
| 608 | #[test] | ||
| 609 | fn nesting_is_bounded() { | ||
| 610 | let tmp = TempDir::new().unwrap(); | ||
| 611 | let mut deep = tmp.path().to_path_buf(); | ||
| 612 | for _ in 0..(MAX_DEPTH + 2) { | ||
| 613 | deep = deep.join("d"); | ||
| 614 | } | ||
| 615 | std::fs::create_dir_all(&deep).unwrap(); | ||
| 616 | init_bare(&deep, "buried.git"); | ||
| 617 | |||
| 618 | assert!( | ||
| 619 | discover(tmp.path()).unwrap().is_empty(), | ||
| 620 | "a repo nested past the depth limit must not be discovered" | ||
| 621 | ); | ||
| 622 | let name = format!("{}/buried", ["d"; MAX_DEPTH + 2].join("/")); | ||
| 623 | assert!( | ||
| 624 | resolve(tmp.path(), &name).is_none(), | ||
| 625 | "and must not be resolvable either — discovery and resolution \ | ||
| 626 | have to agree about what exists" | ||
| 627 | ); | ||
| 628 | } | ||
| 629 | |||
| 630 | // --- reserved names ------------------------------------------------ | ||
| 631 | |||
| 632 | /// `.server/` holds the SSH host key. Nothing under a dot-prefixed | ||
| 633 | /// directory is a repository. | ||
| 634 | #[test] | ||
| 635 | fn the_server_state_directory_is_never_a_repository() { | ||
| 636 | let tmp = TempDir::new().unwrap(); | ||
| 637 | let server_dir = tmp.path().join(".server"); | ||
| 638 | std::fs::create_dir_all(&server_dir).unwrap(); | ||
| 639 | std::fs::write(server_dir.join("host_key"), "secret").unwrap(); | ||
| 640 | // Even if something repo-shaped is planted underneath it. | ||
| 641 | init_bare(&server_dir, "sneaky.git"); | ||
| 642 | init_bare(tmp.path(), "real.git"); | ||
| 643 | |||
| 644 | let names: Vec<String> = discover(tmp.path()) | ||
| 645 | .unwrap() | ||
| 646 | .into_iter() | ||
| 647 | .map(|r| r.name) | ||
| 648 | .collect(); | ||
| 649 | assert_eq!(names, vec!["real"]); | ||
| 650 | assert!(resolve(tmp.path(), ".server").is_none()); | ||
| 651 | assert!(resolve(tmp.path(), ".server/sneaky").is_none()); | ||
| 652 | } | ||
| 653 | |||
| 654 | #[test] | ||
| 655 | fn resolve_refuses_to_escape_the_repos_directory() { | ||
| 656 | let tmp = TempDir::new().unwrap(); | ||
| 657 | let root = tmp.path().join("repos"); | ||
| 658 | std::fs::create_dir_all(&root).unwrap(); | ||
| 659 | init_bare(tmp.path(), "outside.git"); | ||
| 660 | init_bare(&root, "inside.git"); | ||
| 661 | |||
| 662 | assert!(resolve(&root, "inside").is_some()); | ||
| 663 | for name in [ | ||
| 664 | "../outside", | ||
| 665 | "a/../../outside", | ||
| 666 | "/outside", | ||
| 667 | "..", | ||
| 668 | "", | ||
| 669 | "a//b", | ||
| 670 | "./inside", | ||
| 671 | ] { | ||
| 672 | assert!( | ||
| 673 | resolve(&root, name).is_none(), | ||
| 674 | "{name:?} must not resolve to anything" | ||
| 675 | ); | ||
| 676 | } | ||
| 677 | } | ||
| 678 | |||
| 679 | // --- the `.git` suffix collision ----------------------------------- | ||
| 680 | |||
| 681 | /// `tools.git` and `tools` are two directories, so they are two | ||
| 682 | /// repositories. Stripping `.git` from both would collapse them into one | ||
| 683 | /// name and leave whichever lost the race unreachable. | ||
| 684 | #[test] | ||
| 685 | fn tools_and_tools_dot_git_are_distinct_repositories() { | ||
| 686 | let tmp = TempDir::new().unwrap(); | ||
| 687 | init_bare(tmp.path(), "tools.git"); | ||
| 688 | init_bare(tmp.path(), "tools"); | ||
| 689 | |||
| 690 | let entries = discover(tmp.path()).unwrap(); | ||
| 691 | let names: Vec<&str> = entries.iter().map(|r| r.name.as_str()).collect(); | ||
| 692 | assert_eq!(names, vec!["tools", "tools.git"]); | ||
| 693 | |||
| 694 | let plain = resolve(tmp.path(), "tools").unwrap(); | ||
| 695 | assert_eq!(plain.path, tmp.path().join("tools")); | ||
| 696 | let suffixed = resolve(tmp.path(), "tools.git").unwrap(); | ||
| 697 | assert_eq!(suffixed.path, tmp.path().join("tools.git")); | ||
| 698 | } | ||
| 699 | |||
| 700 | /// With no collision, the suffix is stripped as before — the ordinary | ||
| 701 | /// case, and the one every existing URL depends on. | ||
| 702 | #[test] | ||
| 703 | fn a_lone_dot_git_directory_is_named_without_the_suffix() { | ||
| 704 | let tmp = TempDir::new().unwrap(); | ||
| 705 | init_bare(tmp.path(), "tools.git"); | ||
| 706 | |||
| 707 | let entries = discover(tmp.path()).unwrap(); | ||
| 708 | assert_eq!(entries.len(), 1); | ||
| 709 | assert_eq!(entries[0].name, "tools"); | ||
| 710 | assert!(resolve(tmp.path(), "tools").is_some()); | ||
| 711 | // And the name it does not have. | ||
| 712 | assert!(resolve(tmp.path(), "tools.git").is_none()); | ||
| 713 | } | ||
| 714 | |||
| 715 | /// The clone-URL spelling stays a way in, whichever repository it names. | ||
| 716 | #[test] | ||
| 717 | fn clone_urls_may_spell_the_repository_with_a_dot_git_suffix() { | ||
| 718 | let tmp = TempDir::new().unwrap(); | ||
| 719 | init_bare(tmp.path(), "solo.git"); | ||
| 720 | let agents = tmp.path().join("agents"); | ||
| 721 | std::fs::create_dir_all(&agents).unwrap(); | ||
| 722 | init_bare(&agents, "claude-a.git"); | ||
| 723 | |||
| 724 | assert_eq!( | ||
| 725 | resolve_clone_target(tmp.path(), "solo.git").unwrap().name, | ||
| 726 | "solo" | ||
| 727 | ); | ||
| 728 | assert_eq!( | ||
| 729 | resolve_clone_target(tmp.path(), "solo").unwrap().name, | ||
| 730 | "solo" | ||
| 731 | ); | ||
| 732 | assert_eq!( | ||
| 733 | resolve_clone_target(tmp.path(), "agents/claude-a.git") | ||
| 734 | .unwrap() | ||
| 735 | .name, | ||
| 736 | "agents/claude-a" | ||
| 737 | ); | ||
| 738 | |||
| 739 | // When both exist, the literal directory wins over the stripped one. | ||
| 740 | init_bare(tmp.path(), "tools.git"); | ||
| 741 | init_bare(tmp.path(), "tools"); | ||
| 742 | assert_eq!( | ||
| 743 | resolve_clone_target(tmp.path(), "tools.git").unwrap().path, | ||
| 744 | tmp.path().join("tools.git") | ||
| 745 | ); | ||
| 746 | assert_eq!( | ||
| 747 | resolve_clone_target(tmp.path(), "tools").unwrap().path, | ||
| 748 | tmp.path().join("tools") | ||
| 749 | ); | ||
| 750 | } | ||
| 751 | |||
| 354 | #[test] | 752 | #[test] |
| 355 | fn resolve_returns_none_for_unknown() { | 753 | fn resolve_returns_none_for_unknown() { |
| 356 | let tmp = TempDir::new().unwrap(); | 754 | let tmp = TempDir::new().unwrap(); |
| @@ -388,7 +786,7 @@ mod tests { | |||
| 388 | fn missing_policy_defaults_to_public_access() { | 786 | fn missing_policy_defaults_to_public_access() { |
| 389 | let tmp = TempDir::new().unwrap(); | 787 | let tmp = TempDir::new().unwrap(); |
| 390 | init_bare(tmp.path(), "public.git"); | 788 | init_bare(tmp.path(), "public.git"); |
| 391 | let entry = entry_for_path(&tmp.path().join("public.git")).unwrap(); | 789 | let entry = entry_for_path(tmp.path(), &tmp.path().join("public.git")).unwrap(); |
| 392 | assert_eq!(entry.policy.visibility, RepoVisibility::Public); | 790 | assert_eq!(entry.policy.visibility, RepoVisibility::Public); |
| 393 | assert!(entry.policy.allows_anonymous_ui()); | 791 | assert!(entry.policy.allows_anonymous_ui()); |
| 394 | assert!(entry.policy.allows_anonymous_http()); | 792 | assert!(entry.policy.allows_anonymous_http()); |
| @@ -407,7 +805,7 @@ mod tests { | |||
| 407 | "visibility = \"private\"\ndescription = \"Secret repo\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"key:SHA256:reader\"]\nwrite = [\"key:SHA256:writer\"]\n", | 805 | "visibility = \"private\"\ndescription = \"Secret repo\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"key:SHA256:reader\"]\nwrite = [\"key:SHA256:writer\"]\n", |
| 408 | ); | 806 | ); |
| 409 | 807 | ||
| 410 | let entry = entry_for_path(&repo_path).unwrap(); | 808 | let entry = entry_for_path(tmp.path(), &repo_path).unwrap(); |
| 411 | assert_eq!(entry.policy.visibility, RepoVisibility::Private); | 809 | assert_eq!(entry.policy.visibility, RepoVisibility::Private); |
| 412 | assert_eq!( | 810 | assert_eq!( |
| 413 | entry.policy.normalized_description().as_deref(), | 811 | entry.policy.normalized_description().as_deref(), |
| @@ -432,7 +830,7 @@ mod tests { | |||
| 432 | "description = \"Workspace repo\"\n[access]\nwrite = [\"key:SHA256:writer\"]\n", | 830 | "description = \"Workspace repo\"\n[access]\nwrite = [\"key:SHA256:writer\"]\n", |
| 433 | ); | 831 | ); |
| 434 | 832 | ||
| 435 | let entry = entry_for_path(&repo_path).unwrap(); | 833 | let entry = entry_for_path(tmp.path(), &repo_path).unwrap(); |
| 436 | assert_eq!( | 834 | assert_eq!( |
| 437 | entry.policy.normalized_description().as_deref(), | 835 | entry.policy.normalized_description().as_deref(), |
| 438 | Some("Workspace repo") | 836 | Some("Workspace repo") |
| @@ -473,16 +871,38 @@ mod tests { | |||
| 473 | } | 871 | } |
| 474 | 872 | ||
| 475 | /// A repository called `settings` that is not *the* settings repository — | 873 | /// A repository called `settings` that is not *the* settings repository — |
| 476 | /// nested under another directory — is an ordinary repository. | 874 | /// nested under another directory — is an ordinary repository. Now that |
| 875 | /// discovery recurses it is actually reachable, so this matters: a wild | ||
| 876 | /// repo an agent creates at `agents/settings` must not inherit the | ||
| 877 | /// governance repository's closed-by-default posture, and more to the | ||
| 878 | /// point must not be mistaken for the thing that governs the server. | ||
| 477 | #[test] | 879 | #[test] |
| 478 | fn only_the_top_level_settings_repo_gets_the_governance_default() { | 880 | fn only_the_top_level_settings_repo_gets_the_governance_default() { |
| 479 | let tmp = TempDir::new().unwrap(); | 881 | let tmp = TempDir::new().unwrap(); |
| 480 | let nested = tmp.path().join("team"); | 882 | init_bare(tmp.path(), "settings.git"); |
| 481 | std::fs::create_dir_all(&nested).unwrap(); | 883 | let agents = tmp.path().join("agents"); |
| 482 | init_bare(&nested, "settings.git"); | 884 | std::fs::create_dir_all(&agents).unwrap(); |
| 885 | init_bare(&agents, "settings.git"); | ||
| 483 | 886 | ||
| 484 | let entry = entry_for_path(&nested.join("settings.git")).unwrap(); | 887 | let repos = discover(tmp.path()).unwrap(); |
| 485 | assert!(!is_governance_repo(tmp.path(), &entry)); | 888 | let nested = repos.iter().find(|r| r.name == "agents/settings").unwrap(); |
| 889 | let governance = repos.iter().find(|r| r.name == "settings").unwrap(); | ||
| 890 | |||
| 891 | assert!(!is_governance_repo(tmp.path(), nested)); | ||
| 892 | assert!(is_governance_repo(tmp.path(), governance)); | ||
| 893 | assert!(nested.policy.allows_anonymous_ui()); | ||
| 894 | assert!(nested.policy.allows_anonymous_http()); | ||
| 895 | assert!(!governance.policy.allows_anonymous_ui()); | ||
| 896 | |||
| 897 | // And the same conclusion by the route a request actually takes. | ||
| 898 | assert!(resolve(tmp.path(), "agents/settings") | ||
| 899 | .unwrap() | ||
| 900 | .policy | ||
| 901 | .allows_anonymous_ui()); | ||
| 902 | assert!(!resolve(tmp.path(), "settings") | ||
| 903 | .unwrap() | ||
| 904 | .policy | ||
| 905 | .allows_anonymous_ui()); | ||
| 486 | } | 906 | } |
| 487 | 907 | ||
| 488 | #[test] | 908 | #[test] |
| @@ -492,7 +912,7 @@ mod tests { | |||
| 492 | init_bare(tmp.path(), "broken.git"); | 912 | init_bare(tmp.path(), "broken.git"); |
| 493 | write_policy(&repo_path, true, "visibility = [broken toml"); | 913 | write_policy(&repo_path, true, "visibility = [broken toml"); |
| 494 | 914 | ||
| 495 | let entry = entry_for_path(&repo_path).unwrap(); | 915 | let entry = entry_for_path(tmp.path(), &repo_path).unwrap(); |
| 496 | assert_eq!(entry.policy.visibility, RepoVisibility::Private); | 916 | assert_eq!(entry.policy.visibility, RepoVisibility::Private); |
| 497 | assert!(!entry.policy.allows_anonymous_ui()); | 917 | assert!(!entry.policy.allows_anonymous_ui()); |
| 498 | assert!(!entry.policy.allows_anonymous_http()); | 918 | assert!(!entry.policy.allows_anonymous_http()); |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -179,7 +179,7 @@ impl SshHandler { | |||
| 179 | const NOT_FOUND: &str = "error: repository not found\n"; | 179 | const NOT_FOUND: &str = "error: repository not found\n"; |
| 180 | 180 | ||
| 181 | // Releases never auto-create a repo (unlike git-receive-pack). | 181 | // Releases never auto-create a repo (unlike git-receive-pack). |
| 182 | let entry = match crate::repos::entry_for_path(resolved_path) { | 182 | let entry = match crate::repos::entry_for_path(&self.config.repos_dir, resolved_path) { |
| 183 | Some(entry) => entry, | 183 | Some(entry) => entry, |
| 184 | None => { | 184 | None => { |
| 185 | warn!("Rejected release command: unknown repo {:?}", resolved_path); | 185 | warn!("Rejected release command: unknown repo {:?}", resolved_path); |
| @@ -667,7 +667,7 @@ impl Handler for SshHandler { | |||
| 667 | let bare; | 667 | let bare; |
| 668 | 668 | ||
| 669 | if resolved_path.exists() { | 669 | if resolved_path.exists() { |
| 670 | let entry = match crate::repos::entry_for_path(&resolved_path) { | 670 | let entry = match crate::repos::entry_for_path(&self.config.repos_dir, &resolved_path) { |
| 671 | Some(entry) => entry, | 671 | Some(entry) => entry, |
| 672 | None => { | 672 | None => { |
| 673 | warn!( | 673 | warn!( |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1100,6 +1100,12 @@ impl ServerHarness { | |||
| 1100 | std::fs::write(policy_path, content).unwrap(); | 1100 | std::fs::write(policy_path, content).unwrap(); |
| 1101 | } | 1101 | } |
| 1102 | 1102 | ||
| 1103 | /// An absolute `http://` URL for `path` on this server, for handing to a | ||
| 1104 | /// real git client (`git clone`, `git ls-remote`) rather than to `get`. | ||
| 1105 | pub fn http_url(&self, path: &str) -> String { | ||
| 1106 | format!("http://{}{}", self.http_addr, path) | ||
| 1107 | } | ||
| 1108 | |||
| 1103 | pub fn get_ok(&self, path: &str) -> HttpResponse { | 1109 | pub fn get_ok(&self, path: &str) -> HttpResponse { |
| 1104 | let response = self.get(path); | 1110 | let response = self.get(path); |
| 1105 | assert!( | 1111 | assert!( |
tests/nested_repo_http_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,313 @@ | |||
| 1 | //! Nested repositories over HTTP, end to end through the real server. | ||
| 2 | //! | ||
| 3 | //! Governance grants wild repos of the shape `agents/<name>`, and they are | ||
| 4 | //! created by pushing over SSH. The HTTP surface has to reach them under the | ||
| 5 | //! same name, or the feature is half-delivered: an agent can create and push | ||
| 6 | //! to its own repository and then nobody, itself included, can see it. | ||
| 7 | //! | ||
| 8 | //! Everything here drives a live `git-collab-server` and talks real HTTP, | ||
| 9 | //! because the failure being guarded against was invisible to unit tests — | ||
| 10 | //! discovery, name resolution and URL routing each looked fine on their own. | ||
| 11 | |||
| 12 | mod common; | ||
| 13 | |||
| 14 | use common::{git_cmd, ServerHarness}; | ||
| 15 | use std::path::Path; | ||
| 16 | use std::process::{Command, Output}; | ||
| 17 | |||
| 18 | /// The access rules used by the wild-repo test: one prefix per agent, with | ||
| 19 | /// the creating key owning what it creates. | ||
| 20 | const ACCESS_CONF: &str = "\ | ||
| 21 | @admins = alex | ||
| 22 | @agents = claude-a | ||
| 23 | |||
| 24 | repo settings | ||
| 25 | RW+ = @admins | ||
| 26 | |||
| 27 | repo governed | ||
| 28 | RW+ = @admins | ||
| 29 | R = @all | ||
| 30 | |||
| 31 | repo agents/[a-z-]+ | ||
| 32 | C = @agents | ||
| 33 | RW+ = CREATOR | ||
| 34 | "; | ||
| 35 | |||
| 36 | /// Create a bare repository at `repos_dir/<relative>`, making parents as | ||
| 37 | /// needed, and give it one commit plus a collab ref so there is something to | ||
| 38 | /// fetch. | ||
| 39 | fn seed_bare_repo(repos_dir: &Path, relative: &str) { | ||
| 40 | let bare = repos_dir.join(relative); | ||
| 41 | std::fs::create_dir_all(bare.parent().unwrap()).unwrap(); | ||
| 42 | git_cmd( | ||
| 43 | repos_dir, | ||
| 44 | &["init", "--bare", "-b", "main", bare.to_str().unwrap()], | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Populate a bare repo from a scratch working tree: one commit on `main`, | ||
| 49 | /// and one ref under `refs/collab/` so a fetch has something to show. | ||
| 50 | fn seed_content(work_root: &Path, repos_dir: &Path, relative: &str, marker: &str) { | ||
| 51 | let bare = repos_dir.join(relative); | ||
| 52 | let work = work_root.join(marker); | ||
| 53 | std::fs::create_dir_all(&work).unwrap(); | ||
| 54 | git_cmd(&work, &["init", "-q", "-b", "main"]); | ||
| 55 | git_cmd(&work, &["config", "user.email", "seed@example.com"]); | ||
| 56 | git_cmd(&work, &["config", "user.name", "Seed"]); | ||
| 57 | std::fs::write(work.join("README.md"), format!("# {marker}\n")).unwrap(); | ||
| 58 | git_cmd(&work, &["add", "-A"]); | ||
| 59 | git_cmd(&work, &["commit", "-q", "-m", marker]); | ||
| 60 | git_cmd(&work, &["push", "-q", bare.to_str().unwrap(), "main:main"]); | ||
| 61 | // A collab-shaped ref, pushed by its full name. Its content does not | ||
| 62 | // matter here; that it is advertised over HTTP does. | ||
| 63 | git_cmd( | ||
| 64 | &work, | ||
| 65 | &[ | ||
| 66 | "push", | ||
| 67 | "-q", | ||
| 68 | bare.to_str().unwrap(), | ||
| 69 | &format!("main:refs/collab/issues/{marker}"), | ||
| 70 | ], | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 74 | /// `git ls-remote` against an http:// URL, unasserted. | ||
| 75 | fn ls_remote(dir: &Path, url: &str) -> Output { | ||
| 76 | Command::new("git") | ||
| 77 | .args(["ls-remote", url]) | ||
| 78 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 79 | .current_dir(dir) | ||
| 80 | .output() | ||
| 81 | .expect("failed to run git ls-remote") | ||
| 82 | } | ||
| 83 | |||
| 84 | /// The reproduction from the issue, as a table: with `alpha.git` and | ||
| 85 | /// `agents/claude-a.git` both on disk, the nested one must be listed, must | ||
| 86 | /// answer on its own path, and must advertise its collab refs. | ||
| 87 | #[test] | ||
| 88 | fn a_nested_repository_is_listed_browsable_and_serves_its_collab_refs() { | ||
| 89 | let harness = ServerHarness::new("alpha"); | ||
| 90 | let repos_dir = harness.repos_dir(); | ||
| 91 | let scratch = tempfile::TempDir::new().unwrap(); | ||
| 92 | |||
| 93 | seed_content(scratch.path(), &repos_dir, "alpha.git", "alpha"); | ||
| 94 | seed_bare_repo(&repos_dir, "agents/claude-a.git"); | ||
| 95 | seed_content( | ||
| 96 | scratch.path(), | ||
| 97 | &repos_dir, | ||
| 98 | "agents/claude-a.git", | ||
| 99 | "claude-a", | ||
| 100 | ); | ||
| 101 | |||
| 102 | // Listed, under its full path, not its last component. | ||
| 103 | let list = harness.get_ok("/"); | ||
| 104 | assert!( | ||
| 105 | list.body.contains("href=\"/agents/claude-a\""), | ||
| 106 | "the repo list must link the nested repo by its full path; got:\n{}", | ||
| 107 | list.body | ||
| 108 | ); | ||
| 109 | assert!( | ||
| 110 | !list.body.contains("href=\"/claude-a\""), | ||
| 111 | "the nested repo must not be listed under its last component alone; got:\n{}", | ||
| 112 | list.body | ||
| 113 | ); | ||
| 114 | |||
| 115 | // Reachable at that same path, and only that path. | ||
| 116 | let nested = harness.get("/agents/claude-a"); | ||
| 117 | assert!( | ||
| 118 | nested.status_line.contains("200"), | ||
| 119 | "GET /agents/claude-a: expected 200, got {}\n{}", | ||
| 120 | nested.status_line, | ||
| 121 | nested.body | ||
| 122 | ); | ||
| 123 | assert!( | ||
| 124 | harness.get("/claude-a").status_line.contains("404"), | ||
| 125 | "the last component alone must not resolve the nested repo" | ||
| 126 | ); | ||
| 127 | // The unnested repo is untouched. | ||
| 128 | harness.get_ok("/alpha"); | ||
| 129 | |||
| 130 | // Sub-routes hang off the multi-segment name. | ||
| 131 | for suffix in ["issues", "patches", "commits", "tree"] { | ||
| 132 | let path = format!("/agents/claude-a/{suffix}"); | ||
| 133 | let response = harness.get(&path); | ||
| 134 | assert!( | ||
| 135 | response.status_line.contains("200"), | ||
| 136 | "GET {path}: expected 200, got {}\n{}", | ||
| 137 | response.status_line, | ||
| 138 | response.body | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | // And the thing that matters more than the UI: a client can fetch the | ||
| 143 | // repo's collab refs over HTTP. Advertised first (GET info/refs), then | ||
| 144 | // actually transferred (POST git-upload-pack) — a clone over HTTP being | ||
| 145 | // unable to reach `refs/collab/*` is the substance of the bug. | ||
| 146 | let url = harness.http_url("/agents/claude-a.git"); | ||
| 147 | let output = ls_remote(scratch.path(), &url); | ||
| 148 | assert!( | ||
| 149 | output.status.success(), | ||
| 150 | "git ls-remote {url} failed:\n{}", | ||
| 151 | String::from_utf8_lossy(&output.stderr) | ||
| 152 | ); | ||
| 153 | let refs = String::from_utf8_lossy(&output.stdout).to_string(); | ||
| 154 | assert!( | ||
| 155 | refs.contains("refs/collab/issues/claude-a"), | ||
| 156 | "collab refs must be advertised over HTTP for a nested repo; got:\n{refs}" | ||
| 157 | ); | ||
| 158 | |||
| 159 | let clone = scratch.path().join("fetched"); | ||
| 160 | std::fs::create_dir_all(&clone).unwrap(); | ||
| 161 | git_cmd(&clone, &["init", "-q", "-b", "main"]); | ||
| 162 | let fetch = Command::new("git") | ||
| 163 | .args(["fetch", "-q", &url, "refs/collab/*:refs/collab/*"]) | ||
| 164 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 165 | .current_dir(&clone) | ||
| 166 | .output() | ||
| 167 | .expect("failed to run git fetch"); | ||
| 168 | assert!( | ||
| 169 | fetch.status.success(), | ||
| 170 | "fetching collab refs over HTTP failed:\n{}", | ||
| 171 | String::from_utf8_lossy(&fetch.stderr) | ||
| 172 | ); | ||
| 173 | let fetched = Command::new("git") | ||
| 174 | .args(["for-each-ref", "--format=%(refname)", "refs/collab/"]) | ||
| 175 | .current_dir(&clone) | ||
| 176 | .output() | ||
| 177 | .expect("failed to run git for-each-ref"); | ||
| 178 | assert!( | ||
| 179 | String::from_utf8_lossy(&fetched.stdout).contains("refs/collab/issues/claude-a"), | ||
| 180 | "the collab ref should have landed locally; got:\n{}", | ||
| 181 | String::from_utf8_lossy(&fetched.stdout) | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | |||
| 185 | /// `tools.git` and `tools` are two directories and so two repositories. The | ||
| 186 | /// old naming stripped `.git` from both, collapsed them onto one name, and | ||
| 187 | /// served whichever `discover` happened to reach first. | ||
| 188 | #[test] | ||
| 189 | fn tools_and_tools_dot_git_are_served_as_two_repositories() { | ||
| 190 | let harness = ServerHarness::new("alpha"); | ||
| 191 | let repos_dir = harness.repos_dir(); | ||
| 192 | let scratch = tempfile::TempDir::new().unwrap(); | ||
| 193 | |||
| 194 | seed_bare_repo(&repos_dir, "tools.git"); | ||
| 195 | seed_bare_repo(&repos_dir, "tools"); | ||
| 196 | seed_content(scratch.path(), &repos_dir, "tools.git", "suffixed"); | ||
| 197 | seed_content(scratch.path(), &repos_dir, "tools", "plain"); | ||
| 198 | |||
| 199 | let list = harness.get_ok("/"); | ||
| 200 | assert!( | ||
| 201 | list.body.contains("href=\"/tools\"") && list.body.contains("href=\"/tools.git\""), | ||
| 202 | "both must be listed, under distinct names; got:\n{}", | ||
| 203 | list.body | ||
| 204 | ); | ||
| 205 | |||
| 206 | // Each answers on its own address, with its own content. | ||
| 207 | assert!(harness.get_ok("/tools").body.contains("plain")); | ||
| 208 | assert!(harness.get_ok("/tools.git").body.contains("suffixed")); | ||
| 209 | |||
| 210 | // And each is clonable as itself. | ||
| 211 | for (url_path, marker) in [("/tools", "plain"), ("/tools.git", "suffixed")] { | ||
| 212 | let url = harness.http_url(url_path); | ||
| 213 | let output = ls_remote(scratch.path(), &url); | ||
| 214 | assert!( | ||
| 215 | output.status.success(), | ||
| 216 | "git ls-remote {url} failed:\n{}", | ||
| 217 | String::from_utf8_lossy(&output.stderr) | ||
| 218 | ); | ||
| 219 | let refs = String::from_utf8_lossy(&output.stdout).to_string(); | ||
| 220 | assert!( | ||
| 221 | refs.contains(&format!("refs/collab/issues/{marker}")), | ||
| 222 | "{url} should serve the {marker} repository; got:\n{refs}" | ||
| 223 | ); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | /// `repos_dir/.server/` holds the SSH host key. It is not a repository and | ||
| 228 | /// must never be presented as one, however discovery walks the tree. | ||
| 229 | #[test] | ||
| 230 | fn the_server_state_directory_is_never_a_repository() { | ||
| 231 | let harness = ServerHarness::new("alpha"); | ||
| 232 | let host_key = harness.repos_dir().join(".server").join("host_key"); | ||
| 233 | assert!( | ||
| 234 | host_key.exists(), | ||
| 235 | "expected the server to have written {}", | ||
| 236 | host_key.display() | ||
| 237 | ); | ||
| 238 | |||
| 239 | let list = harness.get_ok("/"); | ||
| 240 | assert!( | ||
| 241 | !list.body.contains(".server"), | ||
| 242 | "the server state directory must not appear in the repo list; got:\n{}", | ||
| 243 | list.body | ||
| 244 | ); | ||
| 245 | assert!(harness.get("/.server").status_line.contains("404")); | ||
| 246 | assert!(harness.get("/.server/host_key").status_line.contains("404")); | ||
| 247 | } | ||
| 248 | |||
| 249 | /// A repository whose name would escape `repos_dir` is not a repository. | ||
| 250 | #[test] | ||
| 251 | fn a_traversing_repo_path_is_not_served() { | ||
| 252 | let harness = ServerHarness::new("alpha"); | ||
| 253 | for path in [ | ||
| 254 | "/../../etc/passwd", | ||
| 255 | "/alpha/../../etc", | ||
| 256 | "/..%2F..%2Fetc%2Fpasswd", | ||
| 257 | ] { | ||
| 258 | let response = harness.get(path); | ||
| 259 | assert!( | ||
| 260 | !response.status_line.contains("200"), | ||
| 261 | "GET {path} must not succeed, got {}", | ||
| 262 | response.status_line | ||
| 263 | ); | ||
| 264 | } | ||
| 265 | } | ||
| 266 | |||
| 267 | /// The workflow wild repos exist for: an agent creates its repository by | ||
| 268 | /// pushing to it over SSH, and it is visible over HTTP on the very next | ||
| 269 | /// request. Nothing may cache discovery across that boundary. | ||
| 270 | #[test] | ||
| 271 | fn a_wild_repo_created_over_ssh_is_visible_over_http_immediately() { | ||
| 272 | let harness = ServerHarness::new("governed"); | ||
| 273 | harness.bootstrap_settings( | ||
| 274 | ACCESS_CONF, | ||
| 275 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 276 | ); | ||
| 277 | let agent = harness.named_key("claude-a"); | ||
| 278 | |||
| 279 | // Not there yet. | ||
| 280 | assert!( | ||
| 281 | harness.get("/agents/claude-a").status_line.contains("404"), | ||
| 282 | "the wild repo must not exist before it is created" | ||
| 283 | ); | ||
| 284 | |||
| 285 | harness.work_repo().commit_file("mine.txt", "1", "mine"); | ||
| 286 | let push = harness.ssh_push_from( | ||
| 287 | harness.work_repo().dir.path(), | ||
| 288 | &agent, | ||
| 289 | "agents/claude-a", | ||
| 290 | "main:main", | ||
| 291 | ); | ||
| 292 | assert!( | ||
| 293 | push.status.success(), | ||
| 294 | "the agent's push should create the wild repo:\n{}", | ||
| 295 | String::from_utf8_lossy(&push.stderr) | ||
| 296 | ); | ||
| 297 | |||
| 298 | // Visible on the next request, with no restart and no wait. | ||
| 299 | let response = harness.get("/agents/claude-a"); | ||
| 300 | assert!( | ||
| 301 | response.status_line.contains("200"), | ||
| 302 | "a wild repo must be browsable as soon as it exists, got {}\n{}", | ||
| 303 | response.status_line, | ||
| 304 | response.body | ||
| 305 | ); | ||
| 306 | assert!( | ||
| 307 | harness | ||
| 308 | .get_ok("/") | ||
| 309 | .body | ||
| 310 | .contains("href=\"/agents/claude-a\""), | ||
| 311 | "a wild repo must be listed as soon as it exists" | ||
| 312 | ); | ||
| 313 | } | ||