char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
2688dec
main
1//! Control socket for local pushers (sorcery-ssh, post-receive hooks). 2//! 3//! Unauthenticated: the socket is only reachable by its owner, which is the 4//! same user that owns the repositories. One request per connection: 5//! 6//! ```text 7//! client: <absolute repository path>\n 8//! server: ok[ <site url>]\n | error <message>\n 9//! ``` 10//! 11//! Paths outside the published root are `ok` with no URL, so private 12//! repositories can be pushed without noise. 13 14use std:: io; 15use std:: path:: Path ; 16 17use anyhow::{ Context , Result }; 18use tokio:: io::{ AsyncBufReadExt , AsyncWriteExt , BufReader }; 19use tokio:: net::{ UnixListener , UnixStream }; 20 21use super :: cache:: RepoEntry ; 22use super ::{ AppState , Inner }; 23 24pub ( super ) async fn serve ( state : AppState , listener : UnixListener ) { 25loop { 26match listener. accept (). await { 27Ok (( stream, _)) =>{ 28let state = state. clone (); 29 tokio:: spawn ( async move { 30if let Err ( error) =handle ( & state, stream). await { 31eprintln! ( "sorceryd: control connection: {error}" ); 32} 33}); 34} 35Err ( error) =>eprintln! ( "sorceryd: control accept: {error}" ), 36} 37} 38} 39 40async fn handle ( state : & Inner , mut stream : UnixStream ) -> io:: Result <()> { 41let mut line =String :: new (); 42BufReader :: new ( & mut stream). read_line ( & mut line). await ?; 43let reply =match refresh ( state, Path :: new ( line. trim_end_matches ( '\n' ))). await { 44Ok ( Some ( url)) =>format! ( "ok {url}\n" ), 45Ok ( None ) =>"ok\n" . to_owned (), 46Err ( error) =>format! ( "error {error:#}\n" ), 47}; 48let result = stream. write_all ( reply. as_bytes ()). await ; 49 state. rescan_requested . notify_one (); 50 result 51} 52 53/// Rebuild the published repository at `path`, if there is one. 54async fn refresh ( state : & Inner , path : & Path ) ->Result < Option < String >> { 55let path = path 56. canonicalize () 57. with_context ( ||format! ( "resolving {}" , path. display ())) ?; 58let entry =match entry_at ( state, & path) { 59Some ( entry) =>Some ( entry), 60None =>{ 61let root = state. config . repositories . canonicalize () ?; 62let Ok ( relative) = path. strip_prefix ( & root) else { 63return Ok ( None ); 64}; 65if relative. iter (). count () !=2 66 || relative 67. iter () 68. any ( |part| part. to_str (). is_none_or ( |name| name. starts_with ( '.' ))) 69{ 70return Ok ( None ); 71} 72// First pushes need discovery before returning a working site URL. 73 state. rescan (). await ?; 74entry_at ( state, & path) 75} 76}; 77let Some ( entry) = entryelse { 78return Ok ( None ); 79}; 80 state. build ( & entry, true ). await ?; 81Ok ( state. config . site_url ( & entry. repository )) 82} 83 84fn entry_at ( state : & Inner , path : & Path ) ->Option < RepoEntry > { 85 state 86. snapshot 87. load () 88. repos 89. values () 90. find ( |entry| entry. repository . path == path) 91. cloned () 92} 93 94# [ cfg ( test )] 95mod tests{ 96use std:: fs; 97use std:: path::{ Path , PathBuf }; 98use std:: time:: Duration ; 99 100use anyhow:: Result ; 101use tokio:: io::{ AsyncBufReadExt , AsyncWriteExt , BufReader }; 102use tokio:: net::{ UnixListener , UnixStream }; 103 104use crate :: daemon::{ Config , Inner }; 105use crate :: generate:: STATE_FILE ; 106use crate :: testutil::{ TempDir , commit, grammar_cache, init_repo}; 107 108async fn request ( socket : & Path , path : & Path ) ->Result < String > { 109let mut stream =UnixStream :: connect ( socket). await ?; 110 stream 111. write_all ( format! ( "{}\n" , path. display ()). as_bytes ()) 112. await ?; 113let mut reply =String :: new (); 114BufReader :: new ( stream). read_line ( & mut reply). await ?; 115Ok ( reply) 116} 117 118# [ tokio :: test ] 119async fn refreshes_published_repositories_and_reports_their_urls () ->Result <()> { 120let root =TempDir :: new ( "control" ); 121let repo = root. join ( "repos/alice/thing" ); 122init_repo ( & repo) ?; 123 fs:: write ( repo. join ( "README" ), "hello" ) ?; 124commit ( & repo, "initial" ) ?; 125 126let socket = root. join ( "control" ); 127let listener =UnixListener :: bind ( & socket) ?; 128let state =Inner :: new ( 129Config { 130repositories : root. join ( "repos" ), 131cache : root. join ( "cache" ), 132socket : PathBuf :: from ( "/unused" ), 133instance_name : "test" . into (), 134internal_prefix : "/_sorcery" . into (), 135git_internal_prefix : "/_sorcery-git" . into (), 136check_interval : Duration :: from_secs ( 3600 ), 137max_git_processes : 1 , 138control_socket : Some ( socket. clone ()), 139clone_url_base : Some ( "https://git.example/" . into ()), 140}, 141grammar_cache (), 142); 143 tokio:: spawn ( super :: serve ( state, listener)); 144 145assert_eq! ( 146request ( & socket, & repo). await ?, 147"ok https://git.example/alice/thing\n" 148); 149assert! ( root. join ( "cache/alice/thing" ). join ( STATE_FILE ). is_file ()); 150 151assert_eq! ( request ( & socket, & root. join ( "repos/alice" )). await ?, "ok\n" ); 152assert! ( 153request ( & socket, & root. join ( "missing" )) 154. await ? 155. starts_with ( "error " ) 156); 157Ok (()) 158} 159}