//! Control socket for local pushers (sorcery-ssh, post-receive hooks). //! //! Unauthenticated: the socket is only reachable by its owner, which is the //! same user that owns the repositories. One request per connection: //! //! ```text //! client: \n //! server: ok[ ]\n | error \n //! ``` //! //! Paths outside the published root are `ok` with no URL, so private //! repositories can be pushed without noise. use std::io; use std::path::Path; use anyhow::{Context, Result}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use super::cache::RepoEntry; use super::{AppState, Inner}; pub(super) async fn serve(state: AppState, listener: UnixListener) { loop { match listener.accept().await { Ok((stream, _)) => { let state = state.clone(); tokio::spawn(async move { if let Err(error) = handle(&state, stream).await { eprintln!("sorceryd: control connection: {error}"); } }); } Err(error) => eprintln!("sorceryd: control accept: {error}"), } } } async fn handle(state: &Inner, mut stream: UnixStream) -> io::Result<()> { let mut line = String::new(); BufReader::new(&mut stream).read_line(&mut line).await?; let reply = match refresh(state, Path::new(line.trim_end_matches('\n'))).await { Ok(Some(url)) => format!("ok {url}\n"), Ok(None) => "ok\n".to_owned(), Err(error) => format!("error {error:#}\n"), }; let result = stream.write_all(reply.as_bytes()).await; state.rescan_requested.notify_one(); result } /// Rebuild the published repository at `path`, if there is one. async fn refresh(state: &Inner, path: &Path) -> Result> { let path = path .canonicalize() .with_context(|| format!("resolving {}", path.display()))?; let entry = match entry_at(state, &path) { Some(entry) => Some(entry), None => { let root = state.config.repositories.canonicalize()?; let Ok(relative) = path.strip_prefix(&root) else { return Ok(None); }; if relative.iter().count() != 2 || relative .iter() .any(|part| part.to_str().is_none_or(|name| name.starts_with('.'))) { return Ok(None); } // First pushes need discovery before returning a working site URL. state.rescan().await?; entry_at(state, &path) } }; let Some(entry) = entry else { return Ok(None); }; state.build(&entry, true).await?; Ok(state.config.site_url(&entry.repository)) } fn entry_at(state: &Inner, path: &Path) -> Option { state .snapshot .load() .repos .values() .find(|entry| entry.repository.path == path) .cloned() } #[cfg(test)] mod tests { use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::Result; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use crate::daemon::{Config, Inner}; use crate::generate::STATE_FILE; use crate::testutil::{TempDir, commit, grammar_cache, init_repo}; async fn request(socket: &Path, path: &Path) -> Result { let mut stream = UnixStream::connect(socket).await?; stream .write_all(format!("{}\n", path.display()).as_bytes()) .await?; let mut reply = String::new(); BufReader::new(stream).read_line(&mut reply).await?; Ok(reply) } #[tokio::test] async fn refreshes_published_repositories_and_reports_their_urls() -> Result<()> { let root = TempDir::new("control"); let repo = root.join("repos/alice/thing"); init_repo(&repo)?; fs::write(repo.join("README"), "hello")?; commit(&repo, "initial")?; let socket = root.join("control"); let listener = UnixListener::bind(&socket)?; let state = Inner::new( Config { repositories: root.join("repos"), cache: root.join("cache"), socket: PathBuf::from("/unused"), instance_name: "test".into(), internal_prefix: "/_sorcery".into(), git_internal_prefix: "/_sorcery-git".into(), check_interval: Duration::from_secs(3600), max_git_processes: 1, control_socket: Some(socket.clone()), clone_url_base: Some("https://git.example/".into()), }, grammar_cache(), ); tokio::spawn(super::serve(state, listener)); assert_eq!( request(&socket, &repo).await?, "ok https://git.example/alice/thing\n" ); assert!(root.join("cache/alice/thing").join(STATE_FILE).is_file()); assert_eq!(request(&socket, &root.join("repos/alice")).await?, "ok\n"); assert!( request(&socket, &root.join("missing")) .await? .starts_with("error ") ); Ok(()) } }