@@ -47,7 +47,7 @@ impl RepoStore {
4747 /// spawned to lazily migrate it (on-demand migration for pre-Tigris repos).
4848 /// Returns the local path to the bare repo.
4949 pub async fn acquire ( & self , owner_did : & str , repo_name : & str ) -> Result < PathBuf > {
50- let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ;
50+ let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ? ;
5151
5252 // Fast path: repo exists locally
5353 if local_path. exists ( ) {
@@ -115,7 +115,7 @@ impl RepoStore {
115115 /// `git-receive-pack`) so the client sees the same refs that `acquire_write()`
116116 /// will operate on.
117117 pub async fn acquire_fresh ( & self , owner_did : & str , repo_name : & str ) -> Result < PathBuf > {
118- let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ;
118+ let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ? ;
119119
120120 if let Some ( ref tigris) = self . tigris {
121121 if tigris. exists ( & owner_slug, repo_name) . await . unwrap_or ( false ) {
@@ -135,7 +135,7 @@ impl RepoStore {
135135 /// Take a write lock (Postgres advisory lock), ensure repo is local, return guard.
136136 /// The lock prevents concurrent writes to the same repo across machines.
137137 pub async fn acquire_write ( & self , owner_did : & str , repo_name : & str ) -> Result < RepoWriteGuard > {
138- let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ;
138+ let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ? ;
139139 let lock_key = advisory_lock_key ( & owner_slug, repo_name) ;
140140
141141 // Acquire Postgres advisory lock with retry using pg_try_advisory_lock
@@ -183,7 +183,7 @@ impl RepoStore {
183183
184184 /// Initialize a new bare repo on local disk and upload to Tigris.
185185 pub async fn init ( & self , owner_did : & str , repo_name : & str ) -> Result < PathBuf > {
186- let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ;
186+ let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ? ;
187187
188188 store:: init_bare ( & local_path) . context ( "initializing bare repo" ) ?;
189189
@@ -207,22 +207,98 @@ impl RepoStore {
207207 /// Call this after any operation that modifies the git repo on disk.
208208 pub async fn release_after_write ( & self , owner_did : & str , repo_name : & str ) {
209209 if let Some ( ref tigris) = self . tigris {
210- let ( owner_slug, local_path) = self . local_path ( owner_did, repo_name) ;
210+ let ( owner_slug, local_path) = match self . local_path ( owner_did, repo_name) {
211+ Ok ( p) => p,
212+ Err ( e) => {
213+ warn ! ( repo = %repo_name, err = %e, "rejected unsafe path in release_after_write" ) ;
214+ return ;
215+ }
216+ } ;
211217 if let Err ( e) = tigris. upload ( & owner_slug, repo_name, & local_path) . await {
212218 warn ! ( repo = %repo_name, err = %e, "failed to upload repo to tigris after write" ) ;
213219 }
214220 }
215221 }
216222
217223 /// Compute the local disk path and owner slug for a repo.
218- fn local_path ( & self , owner_did : & str , repo_name : & str ) -> ( String , PathBuf ) {
224+ ///
225+ /// Rejects any input that could be used for path traversal — strict
226+ /// allowlist on both `owner_did` and `repo_name`, length-bounded, and
227+ /// the resulting path must remain inside `repos_dir`.
228+ fn local_path ( & self , owner_did : & str , repo_name : & str ) -> Result < ( String , PathBuf ) > {
229+ validate_path_components ( owner_did, repo_name) ?;
230+
219231 let owner_slug = owner_did. replace ( ':' , "_" ) . replace ( '/' , "_" ) ;
220232 let local_path = self
221233 . repos_dir
222234 . join ( & owner_slug)
223235 . join ( format ! ( "{repo_name}.git" ) ) ;
224- ( owner_slug, local_path)
236+
237+ // Defence in depth: even though both inputs are allowlisted, verify
238+ // the joined path is still rooted at repos_dir.
239+ if !local_path. starts_with ( & self . repos_dir ) {
240+ anyhow:: bail!(
241+ "computed repo path escaped repos_dir: {}" ,
242+ local_path. display( )
243+ ) ;
244+ }
245+
246+ Ok ( ( owner_slug, local_path) )
247+ }
248+ }
249+
250+ /// Strict allowlist validator for `owner_did` and `repo_name`.
251+ ///
252+ /// Rejects any character that isn't explicitly safe, plus length and
253+ /// special-sequence checks (`..`, leading `.`, leading `-`).
254+ fn validate_path_components ( owner_did : & str , repo_name : & str ) -> Result < ( ) > {
255+ validate_owner_did ( owner_did) ?;
256+ validate_repo_name ( repo_name) ?;
257+ Ok ( ( ) )
258+ }
259+
260+ fn validate_owner_did ( owner_did : & str ) -> Result < ( ) > {
261+ if owner_did. is_empty ( ) {
262+ anyhow:: bail!( "owner_did is empty" ) ;
263+ }
264+ if owner_did. len ( ) > 256 {
265+ anyhow:: bail!( "owner_did exceeds 256 chars" ) ;
266+ }
267+ // DIDs are `did:method:identifier` — `did:key:z6Mk...`, `did:web:host:user`, etc.
268+ // Allow alnum + `:`, `.`, `_`, `-`. Reject `..` substring and any `/` or `\`.
269+ if owner_did. contains ( ".." ) {
270+ anyhow:: bail!( "owner_did contains '..' sequence" ) ;
271+ }
272+ for ch in owner_did. chars ( ) {
273+ let ok = ch. is_ascii_alphanumeric ( ) || matches ! ( ch, ':' | '.' | '_' | '-' ) ;
274+ if !ok {
275+ anyhow:: bail!( "owner_did contains disallowed character: {ch:?}" ) ;
276+ }
277+ }
278+ Ok ( ( ) )
279+ }
280+
281+ fn validate_repo_name ( repo_name : & str ) -> Result < ( ) > {
282+ if repo_name. is_empty ( ) {
283+ anyhow:: bail!( "repo_name is empty" ) ;
284+ }
285+ if repo_name. len ( ) > 100 {
286+ anyhow:: bail!( "repo_name exceeds 100 chars" ) ;
225287 }
288+ // Repo names are `[A-Za-z0-9._-]+` minus path-traversal traps.
289+ if repo_name. contains ( ".." ) {
290+ anyhow:: bail!( "repo_name contains '..' sequence" ) ;
291+ }
292+ if repo_name. starts_with ( '.' ) || repo_name. starts_with ( '-' ) {
293+ anyhow:: bail!( "repo_name must not start with '.' or '-'" ) ;
294+ }
295+ for ch in repo_name. chars ( ) {
296+ let ok = ch. is_ascii_alphanumeric ( ) || matches ! ( ch, '.' | '_' | '-' ) ;
297+ if !ok {
298+ anyhow:: bail!( "repo_name contains disallowed character: {ch:?}" ) ;
299+ }
300+ }
301+ Ok ( ( ) )
226302}
227303
228304/// Guard returned by `acquire_write()`. Holds the Postgres advisory lock and
@@ -270,3 +346,166 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
270346 repo_name. hash ( & mut hasher) ;
271347 hasher. finish ( ) as i64
272348}
349+
350+ #[ cfg( test) ]
351+ mod tests {
352+ use super :: * ;
353+
354+ // ── repo_name validation ───────────────────────────────────────────────
355+
356+ #[ test]
357+ fn repo_name_accepts_normal_names ( ) {
358+ for name in [
359+ "hello" ,
360+ "hello-world" ,
361+ "hello_world" ,
362+ "hello.world" ,
363+ "Repo123" ,
364+ "a" ,
365+ ] {
366+ validate_repo_name ( name) . unwrap_or_else ( |e| panic ! ( "{name} should be valid: {e}" ) ) ;
367+ }
368+ }
369+
370+ #[ test]
371+ fn repo_name_rejects_empty ( ) {
372+ assert ! ( validate_repo_name( "" ) . is_err( ) ) ;
373+ }
374+
375+ #[ test]
376+ fn repo_name_rejects_path_traversal_dotdot ( ) {
377+ for name in [ ".." , "../etc" , "../../passwd" , "foo/../bar" , "a..b" ] {
378+ assert ! (
379+ validate_repo_name( name) . is_err( ) ,
380+ "{name:?} must be rejected"
381+ ) ;
382+ }
383+ }
384+
385+ #[ test]
386+ fn repo_name_rejects_slashes ( ) {
387+ for name in [ "foo/bar" , "foo\\ bar" , "/abs" , "a/b/c" ] {
388+ assert ! (
389+ validate_repo_name( name) . is_err( ) ,
390+ "{name:?} must be rejected"
391+ ) ;
392+ }
393+ }
394+
395+ #[ test]
396+ fn repo_name_rejects_leading_dot_or_dash ( ) {
397+ for name in [ ".hidden" , "." , "-foo" ] {
398+ assert ! (
399+ validate_repo_name( name) . is_err( ) ,
400+ "{name:?} must be rejected"
401+ ) ;
402+ }
403+ }
404+
405+ #[ test]
406+ fn repo_name_rejects_null_byte ( ) {
407+ assert ! ( validate_repo_name( "foo\0 bar" ) . is_err( ) ) ;
408+ }
409+
410+ #[ test]
411+ fn repo_name_rejects_overlong ( ) {
412+ let long = "a" . repeat ( 101 ) ;
413+ assert ! ( validate_repo_name( & long) . is_err( ) ) ;
414+ }
415+
416+ // ── owner_did validation ───────────────────────────────────────────────
417+
418+ #[ test]
419+ fn owner_did_accepts_did_key ( ) {
420+ validate_owner_did ( "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr" ) . unwrap ( ) ;
421+ }
422+
423+ #[ test]
424+ fn owner_did_accepts_did_web_with_dots ( ) {
425+ validate_owner_did ( "did:web:example.com:user" ) . unwrap ( ) ;
426+ }
427+
428+ #[ test]
429+ fn owner_did_rejects_empty ( ) {
430+ assert ! ( validate_owner_did( "" ) . is_err( ) ) ;
431+ }
432+
433+ #[ test]
434+ fn owner_did_rejects_path_traversal ( ) {
435+ for did in [
436+ "did:key:.." ,
437+ "did:key:../../etc" ,
438+ ".." ,
439+ "did:key:foo/../bar" ,
440+ ] {
441+ assert ! ( validate_owner_did( did) . is_err( ) , "{did:?} must be rejected" ) ;
442+ }
443+ }
444+
445+ #[ test]
446+ fn owner_did_rejects_slashes_and_backslashes ( ) {
447+ for did in [ "did:key:foo/bar" , "did:key:foo\\ bar" , "did/key/foo" ] {
448+ assert ! ( validate_owner_did( did) . is_err( ) , "{did:?} must be rejected" ) ;
449+ }
450+ }
451+
452+ #[ test]
453+ fn owner_did_rejects_null_byte ( ) {
454+ assert ! ( validate_owner_did( "did:key:z6Mk\0 evil" ) . is_err( ) ) ;
455+ }
456+
457+ #[ test]
458+ fn owner_did_rejects_overlong ( ) {
459+ let long = format ! ( "did:key:{}" , "z" . repeat( 260 ) ) ;
460+ assert ! ( validate_owner_did( & long) . is_err( ) ) ;
461+ }
462+
463+ // ── end-to-end local_path ──────────────────────────────────────────────
464+
465+ fn make_store ( ) -> RepoStore {
466+ // We only exercise the path-construction code, which doesn't touch
467+ // the pool or the network. Fabricate a pool reference via PgPool::connect_lazy
468+ // so we don't need a live DB.
469+ let pool = sqlx:: PgPool :: connect_lazy ( "postgres://invalid" ) . unwrap ( ) ;
470+ RepoStore :: new ( PathBuf :: from ( "/var/lib/gitlawb/repos" ) , None , pool)
471+ }
472+
473+ #[ tokio:: test]
474+ async fn local_path_resolves_safe_inputs ( ) {
475+ let store = make_store ( ) ;
476+ let ( slug, path) = store
477+ . local_path (
478+ "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr" ,
479+ "hello" ,
480+ )
481+ . unwrap ( ) ;
482+ assert_eq ! (
483+ slug,
484+ "did_key_z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr"
485+ ) ;
486+ assert ! ( path. starts_with( "/var/lib/gitlawb/repos" ) ) ;
487+ assert ! ( path. ends_with( "hello.git" ) ) ;
488+ }
489+
490+ #[ tokio:: test]
491+ async fn local_path_rejects_traversal_in_repo_name ( ) {
492+ let store = make_store ( ) ;
493+ for bad in [ "../etc/passwd" , ".." , "../../shadow" ] {
494+ assert ! (
495+ store. local_path( "did:key:z6MkAlice" , bad) . is_err( ) ,
496+ "repo_name={bad:?} must be rejected"
497+ ) ;
498+ }
499+ }
500+
501+ #[ tokio:: test]
502+ async fn local_path_rejects_traversal_in_owner_did ( ) {
503+ let store = make_store ( ) ;
504+ for bad in [ "did:key:.." , ".." , "did/key/foo" ] {
505+ assert ! (
506+ store. local_path( bad, "hello" ) . is_err( ) ,
507+ "owner_did={bad:?} must be rejected"
508+ ) ;
509+ }
510+ }
511+ }
0 commit comments