char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
3ee940a
main
1const std =@import ( "std" ); 2const shlex =@import ( "shlex" ); 3 4pub fn main ( init : std . process . Init ) ! void { 5const allocator =init . gpa ; 6const io =init . io ; 7 8const cmd =init . environ_map . get ( "SSH_ORIGINAL_COMMAND" ) orelse 9return launchTui ( allocator , io , init . environ_map , & .{}); 10const tokens =shlex . split ( allocator , cmd , false , true ) catch | err | { 11std . log . err ( "failed to parse SSH_ORIGINAL_COMMAND: {} " , .{ err }); 12std . process . exit ( 1 ); 13}; 14defer { 15for ( tokens ) |token |allocator . free ( token ); 16allocator . free ( tokens ); 17} 18 19if ( tokens . len == 0 ) return launchTui ( allocator , io , init . environ_map , tokens ); 20 21const is_receive_pack =std . mem . eql ( u8 , tokens [ 0 ], "git-receive-pack" ); 22const is_upload_pack =std . mem . eql ( u8 , tokens [ 0 ], "git-upload-pack" ); 23if ( ! is_receive_pack and ! is_upload_pack ) { 24return launchTui ( allocator , io , init . environ_map , tokens ); 25} 26if ( tokens . len != 2 ) { 27std . log . err ( "invalid git command: { s } " , .{ cmd }); 28std . process . exit ( 1 ); 29} 30 31const home =init . environ_map . get ( "HOME" ) orelse { 32std . log . err ( "HOME not set" , .{}); 33std . process . exit ( 1 ); 34}; 35const repo_path =try expandPath ( allocator , home , tokens [ 1 ]); 36defer allocator . free ( repo_path ); 37 38if ( ! isUnder ( repo_path , home )) { 39std . log . err ( "repo path must be inside HOME: { s } " , .{ tokens [ 1 ]}); 40std . process . exit ( 1 ); 41} 42 43if ( is_upload_pack ) { 44return std . process . replace ( io , .{ 45. argv =& .{ "git-upload-pack" , repo_path }, 46}); 47} 48 49var created_repo =false ; 50if ( is_receive_pack ) { 51std . Io . Dir . accessAbsolute ( io , repo_path , .{}) catch { 52std . log . info ( "auto-initializing bare repo at { s } " , .{ repo_path }); 53std . Io . Dir . createDirPath (. cwd (), io , repo_path ) catch | err | { 54std . log . err ( "failed to create repo directory: {} " , .{ err }); 55std . process . exit ( 1 ); 56}; 57const result =try std . process . run ( allocator , io , .{ 58. argv =& .{ "git" , "init" , "--bare" , repo_path }, 59}); 60defer allocator . free ( result . stdout ); 61defer allocator . free ( result . stderr ); 62if ( result . term != . exited or result . term . exited != 0 ) { 63std . log . err ( "git init --bare failed: { s } " , .{ result . stderr }); 64std . process . exit ( 1 ); 65} 66created_repo = true ; 67}; 68} 69 70var receive_pack =try std . process . spawn ( io , .{ 71. argv =& .{ "git-receive-pack" , repo_path }, 72}); 73const term =try receive_pack . wait ( io ); 74if ( term != . exited ) std . process . exit ( 1 ); 75if ( term . exited != 0 ) std . process . exit ( term . exited ); 76 77if ( created_repo ) try fixHead ( allocator , io , repo_path ); 78if ( is_receive_pack ) refreshAfterPush ( allocator , io , init . environ_map , repo_path ); 79} 80 81fn launchTui ( 82allocator : std . mem . Allocator , 83io : std . Io , 84environ : * const std . process . Environ . Map , 85tokens : [] const [] const u8 , 86) ! void { 87const tui =environ . get ( "SORCERY_SSH_TUI" ) orelse "sorcery-ssh-tui" ; 88const argv =try allocator . alloc ([] const u8 , tokens . len + 1 ); 89defer allocator . free ( argv ); 90argv [ 0 ] = tui ; 91for ( tokens , argv [ 1 ..]) |token , * arg |arg .* = token ; 92const err =std . process . replace ( io , .{ . argv =argv }); 93return switch ( err ) { 94error . FileNotFound =>printWelcome ( io , environ ), 95else =>err , 96}; 97} 98 99fn printWelcome ( io : std . Io , environ : * const std . process . Environ . Map ) ! void { 100var buffer : [ 1024 ] u8 =undefined ; 101var stdout =std . Io . File . stdout (). writer ( io , & buffer ); 102try writeWelcome ( & stdout . interface , environ . get ( "SORCERY_INSTANCE_NAME" ) orelse "this Sorcery instance" ); 103try stdout . interface . flush (); 104} 105 106fn writeWelcome ( writer : * std . Io . Writer , instance : [] const u8 ) ! void { 107try writer . ( 108\\welcome to sorcery-ssh on {s}! 109\\ 110\\usage: 111\\ ssh git@{s} describe USER/REPOSITORY 112\\ ssh git@{s} describe USER/REPOSITORY DESCRIPTION... 113\\ 114\\install sorcery-ssh-tui for interactive repository management. 115\\ 116, .{ instance , instance , instance }); 117} 118 119fn refreshAfterPush ( 120allocator : std . mem . Allocator , 121io : std . Io , 122environ : * const std . process . Environ . Map , 123repo_path : [] const u8 , 124) void { 125const repositories_raw =environ . get ( "SORCERY_REPOSITORIES" ) orelse { 126std . log . warn ( "SORCERY_REPOSITORIES not set; site refresh skipped" , .{}); 127return ; 128}; 129const repositories =std . fs . path . resolve ( allocator , & .{ repositories_raw }) catch | err | { 130std . log . warn ( "failed to resolve SORCERY_REPOSITORIES: {} " , .{ err }); 131return ; 132}; 133defer allocator . free ( repositories ); 134 135const coordinates =repositoryCoordinates ( repositories , repo_path ) orelse return ; 136const socket =environ . get ( "SORCERY_SOCKET" ) orelse { 137std . log . warn ( "SORCERY_SOCKET not set; site refresh skipped" , .{}); 138return ; 139}; 140const token_file =environ . get ( "SORCERY_REFRESH_TOKEN_FILE" ) orelse { 141std . log . warn ( "SORCERY_REFRESH_TOKEN_FILE not set; site refresh skipped" , .{}); 142return ; 143}; 144const token_alloc =std . Io . Dir . readFileAlloc ( 145. cwd (), 146io , 147token_file , 148allocator , 149. limited ( 4096 ), 150) catch | err | { 151std . log . warn ( "failed to read refresh token: {} " , .{ err }); 152return ; 153}; 154defer allocator . free ( token_alloc ); 155const token =std . mem . trimEnd ( u8 , token_alloc , "\r\n" ); 156if ( token . len == 0 ) { 157std . log . warn ( "refresh token must contain visible ASCII without whitespace" , .{}); 158return ; 159} 160for ( token ) |byte |{ 161if ( ! std . ascii . isPrint ( byte ) or std . ascii . isWhitespace ( byte )) { 162std . log . warn ( "refresh token must contain visible ASCII without whitespace" , .{}); 163return ; 164} 165} 166 167refresh ( io , socket , token , coordinates . user , coordinates . repo ) catch | err | { 168std . log . warn ( "site refresh failed: {} " , .{ err }); 169return ; 170}; 171 172if ( environ . get ( "SORCERY_CLONE_URL_BASE" )) | base | { 173std . debug . ( 174"sorcery: { s } / { s } / { s } /\n" , 175.{ std . mem . trimEnd ( u8 , base , "/" ), coordinates . user , coordinates . repo }, 176); 177} 178} 179 180const Coordinates =struct { 181user : [] const u8 , 182repo : [] const u8 , 183}; 184 185fn repositoryCoordinates ( root : [] const u8 , path : [] const u8 ) ? Coordinates { 186if ( ! isUnder ( path , root )) return null ; 187const offset =if ( std . mem . eql ( u8 , root , & .{ std . fs . path . sep })) root . len else root . len + 1 ; 188var parts =std . mem . splitScalar ( u8 , path [ offset ..], std . fs . path . sep ); 189const user =parts . next () orelse return null ; 190const repo_raw =parts . next () orelse return null ; 191if ( user . len == 0 or repo_raw . len == 0 or parts . next () != null ) return null ; 192const repo =if ( std . mem . endsWith ( u8 , repo_raw , ".git" )) 193repo_raw [ 0 .. repo_raw . len - ".git" . len ] 194else 195repo_raw ; 196if ( repo . len == 0 ) return null ; 197return .{ . user =user , . repo =repo }; 198} 199 200/// Whether `path` is strictly below `root`; `root` itself does not count. 201fn isUnder ( path : [] const u8 , root : [] const u8 ) bool { 202if ( std . mem . eql ( u8 , root , & .{ std . fs . path . sep })) { 203return path . len > 1 and path [ 0 ] == std . fs . path . sep ; 204} 205return std . mem . startsWith ( u8 , path , root ) and 206path . len > root . len and path [ root . len ] == std . fs . path . sep ; 207} 208 209fn refresh ( 210io : std . Io , 211socket_path : [] const u8 , 212token : [] const u8 , 213user : [] const u8 , 214repo : [] const u8 , 215) ! void { 216const address =try std . Io . net . UnixAddress . init ( socket_path ); 217const stream =try address . connect ( io ); 218defer stream . close ( io ); 219 220var write_buffer : [ 1024 ] u8 =undefined ; 221var stream_writer =stream . writer ( io , & write_buffer ); 222const writer =& stream_writer . interface ; 223try writer . writeAll ( "POST /-/refresh/" ); 224try writeUrlComponent ( writer , user ); 225try writer . writeByte ( '/' ); 226try writeUrlComponent ( writer , repo ); 227try writer . writeAll ( " HTTP/1.1\r\nHost: sorcery\r\nAuthorization: Bearer " ); 228try writer . writeAll ( token ); 229try writer . writeAll ( "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ); 230try writer . flush (); 231 232var read_buffer : [ 1024 ] u8 =undefined ; 233var stream_reader =stream . reader ( io , & read_buffer ); 234const status =try stream_reader . interface . takeDelimiter ( '\n' ) orelse return error . EmptyResponse ; 235if ( ! std . mem . startsWith ( u8 , status , "HTTP/1.1 200 " ) and 236! std . mem . startsWith ( u8 , status , "HTTP/1.0 200 " )) 237{ 238return error . RefreshRejected ; 239} 240} 241 242fn writeUrlComponent ( writer : * std . Io . Writer , component : [] const u8 ) ! void { 243const hex ="0123456789ABCDEF" ; 244for ( component ) |byte |{ 245if ( std . ascii . isAlphanumeric ( byte ) or std . mem . indexOfScalar ( u8 , "-._~" , byte ) != null ) { 246try writer . writeByte ( byte ); 247} else { 248try writer . writeAll ( & .{ '%' , hex [ byte >> 4 ], hex [ byte & 0xf ] }); 249} 250} 251} 252 253fn fixHead ( allocator : std . mem . Allocator , io : std . Io , repo_path : [] const u8 ) ! void { 254const head_result =try std . process . run ( allocator , io , .{ 255. argv =& .{ "git" , "--git-dir" , repo_path , "rev-parse" , "--verify" , "--quiet" , "HEAD" }, 256}); 257defer allocator . free ( head_result . stdout ); 258defer allocator . free ( head_result . stderr ); 259if ( head_result . term == . exited and head_result . term . exited == 0 ) return ; 260if ( head_result . term != . exited or head_result . term . exited != 1 ) { 261std . log . err ( "failed to resolve repository HEAD: { s } " , .{ head_result . stderr }); 262return error . GitCommandFailed ; 263} 264 265const refs_result =try std . process . run ( allocator , io , .{ 266. argv =& .{ "git" , "--git-dir" , repo_path , "for-each-ref" , "--format=%(refname)" , "refs/heads/" }, 267}); 268defer allocator . free ( refs_result . stdout ); 269defer allocator . free ( refs_result . stderr ); 270if ( refs_result . term != . exited or refs_result . term . exited != 0 ) { 271std . log . err ( "failed to list repository branches: { s } " , .{ refs_result . stderr }); 272return error . GitCommandFailed ; 273} 274 275var refs =std . mem . tokenizeScalar ( u8 , refs_result . stdout , '\n' ); 276var sole_ref : ? [] const u8 =null ; 277var main_ref : ? [] const u8 =null ; 278var ref_count : usize =0 ; 279while ( refs . next ()) | ref | { 280sole_ref = ref ; 281ref_count += 1 ; 282if ( std . mem . eql ( u8 , ref , "refs/heads/main" )) main_ref = ref ; 283} 284 285const head_ref =main_ref orelse if ( ref_count == 1 ) sole_ref else null ; 286if ( head_ref ) | ref | { 287const set_head_result =try std . process . run ( allocator , io , .{ 288. argv =& .{ "git" , "--git-dir" , repo_path , "symbolic-ref" , "HEAD" , ref }, 289}); 290defer allocator . free ( set_head_result . stdout ); 291defer allocator . free ( set_head_result . stderr ); 292if ( set_head_result . term != . exited or set_head_result . term . exited != 0 ) { 293std . log . err ( "failed to set repository HEAD: { s } " , .{ set_head_result . stderr }); 294return error . GitCommandFailed ; 295} 296} else if ( ref_count > 1 ) { 297std . log . warn ( "HEAD remains unresolved because the repository has multiple branches and no main branch" , .{}); 298} 299} 300 301// im pretty sure this is what git expand user path does. wordexp from posix is overkill 302fn expandPath ( allocator : std . mem . Allocator , home : [] const u8 , path : [] const u8 ) ! [] const u8 { 303if ( std . mem . startsWith ( u8 , path , "~/" )) { 304return std . fs . path . resolve ( allocator , & .{ home , path [ 2 ..] }); 305} else if ( std . mem . eql ( u8 , path , "~" )) { 306return allocator . dupe ( u8 , home ); 307} else { 308return std . fs . path . resolve ( allocator , & .{ home , path }); 309} 310} 311 312test "pushed paths normalise into HOME or are rejected" { 313const home ="/home/git" ; 314for ([ _ ][] const u8 { "x" , "~/x" , "/home/git/x" , "~/a/../x" , "/home/git/../git/x" }) |path |{ 315const expanded =try expandPath ( std . testing . allocator , home , path ); 316defer std . testing . allocator . free ( expanded ); 317try std . testing . expectEqualStrings ( "/home/git/x" , expanded ); 318try std . testing . expect ( isUnder ( expanded , home )); 319} 320for ([ _ ][] const u8 { "~" , "." , "../x" , "~/../x" , "/etc/passwd" , "/home/gitolite/x" , "/home" }) |path |{ 321const expanded =try expandPath ( std . testing . allocator , home , path ); 322defer std . testing . allocator . free ( expanded ); 323try std . testing . expect ( ! isUnder ( expanded , home )); 324} 325} 326 327test "refresh url components escape everything outside the unreserved set" { 328var output =std . Io . Writer . Allocating . init ( std . testing . allocator ); 329defer output . deinit (); 330try writeUrlComponent ( & output . writer , "a b/c?d#\u{e9}-._~" ); 331try std . testing . expectEqualStrings ( "a%20b%2Fc%3Fd%23%C3%A9-._~" , output . written ()); 332} 333 334test "repository coordinates require exactly user and repo under root" { 335const expectEqualStrings =std . testing . expectEqualStrings ; 336const root ="/home/git/public" ; 337 338const plain =repositoryCoordinates ( root , "/home/git/public/char/sorcery" ) .? ; 339try expectEqualStrings ( "char" , plain . user ); 340try expectEqualStrings ( "sorcery" , plain . repo ); 341 342const dotted =repositoryCoordinates ( root , "/home/git/public/char/sorcery.git" ) .? ; 343try expectEqualStrings ( "sorcery" , dotted . repo ); 344 345try std . testing . expect ( repositoryCoordinates ( root , "/home/git/private/char/sorcery" ) == null ); 346try std . testing . expect ( repositoryCoordinates ( root , "/home/git/public/char" ) == null ); 347try std . testing . expect ( repositoryCoordinates ( root , "/home/git/public/char/sorcery/extra" ) == null ); 348try std . testing . expect ( repositoryCoordinates ( root , "/home/git/publicity/char/sorcery" ) == null ); 349}