a73x

15a970d1

Serve nested repositories over HTTP

a73x   2026-08-11 16:06

Commit message
Serve nested repositories over HTTP

Governance hands an agent a wild repo at `agents/<name>` and it works over
SSH, but `repos::discover` read exactly one level of `repos_dir`, so the
repository existed, accepted pushes, and appeared nowhere: not in the
listing, not at any URL, and — the part that matters more than the UI —
with no way for an HTTP clone to fetch its `refs/collab/*`.

Discovery now recurses, and a repository is named by its full path under
`repos_dir` rather than by its last component.

The naming rule is the one governance already writes its rules against:
the path relative to `repos_dir`, `/`-separated, with a trailing `.git`
stripped from the last segment — unless a sibling with the stripped name
is itself a repository, in which case the suffix stays. That is what
finally separates `tools.git` from `tools`; stripping both collapsed them
onto one name and left whichever `discover` reached second unreachable.

The walk stops at each repository rather than descending into its object
store, skips anything dot-prefixed (which is what keeps `.server/` out),
and is bounded at eight segments — it is reached from an anonymous page,
and without a bound whatever an operator leaves under `repos_dir` sets
the cost of rendering the repository list.

`resolve` no longer scans. A name determines a path, so it probes: the
name is validated first and the walk down refuses to pass through a
symlink, so a name cannot address anything outside `repos_dir`. That
removes the full-scan-per-request this carried, and with it the reason to
consider a cache — which is just as well, because a wild repo is created
by a push over SSH and has to be visible on the very next HTTP request.
Nothing here is cached, and a test holds that line.

Routing disambiguates by taking the longest leading run of segments that
names a repository as the repository and the rest as the sub-route,
folding the name into one percent-encoded segment ahead of the router so
every existing route and template goes on working unchanged.

Fixes d0d1bf5c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 }