char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somperf: prioritize push-triggered builds over background scans2688dec

main
5.2 KiB159 linesraw
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) {
25    loop {
26        match listener.accept().await {
27            Ok((stream, _)) => {
28                let state = state.clone();
29                tokio::spawn(async move {
30                    if let Err(error) = handle(&state, stream).await {
31                        eprintln!("sorceryd: control connection: {error}");
32                    }
33                });
34            }
35            Err(error) => eprintln!("sorceryd: control accept: {error}"),
36        }
37    }
38}
39
40async fn handle(state: &Inner, mut stream: UnixStream) -> io::Result<()> {
41    let mut line = String::new();
42    BufReader::new(&mut stream).read_line(&mut line).await?;
43    let reply = match refresh(state, Path::new(line.trim_end_matches('\n'))).await {
44        Ok(Some(url)) => format!("ok {url}\n"),
45        Ok(None) => "ok\n".to_owned(),
46        Err(error) => format!("error {error:#}\n"),
47    };
48    let 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>> {
55    let path = path
56        .canonicalize()
57        .with_context(|| format!("resolving {}", path.display()))?;
58    let entry = match entry_at(state, &path) {
59        Some(entry) => Some(entry),
60        None => {
61            let root = state.config.repositories.canonicalize()?;
62            let Ok(relative) = path.strip_prefix(&root) else {
63                return Ok(None);
64            };
65            if relative.iter().count() != 2
66                || relative
67                    .iter()
68                    .any(|part| part.to_str().is_none_or(|name| name.starts_with('.')))
69            {
70                return Ok(None);
71            }
72            // First pushes need discovery before returning a working site URL.
73            state.rescan().await?;
74            entry_at(state, &path)
75        }
76    };
77    let Some(entry) = entry else {
78        return Ok(None);
79    };
80    state.build(&entry, true).await?;
81    Ok(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 {
96    use std::fs;
97    use std::path::{Path, PathBuf};
98    use std::time::Duration;
99
100    use anyhow::Result;
101    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
102    use tokio::net::{UnixListener, UnixStream};
103
104    use crate::daemon::{Config, Inner};
105    use crate::generate::STATE_FILE;
106    use crate::testutil::{TempDir, commit, grammar_cache, init_repo};
107
108    async fn request(socket: &Path, path: &Path) -> Result<String> {
109        let mut stream = UnixStream::connect(socket).await?;
110        stream
111            .write_all(format!("{}\n", path.display()).as_bytes())
112            .await?;
113        let mut reply = String::new();
114        BufReader::new(stream).read_line(&mut reply).await?;
115        Ok(reply)
116    }
117
118    #[tokio::test]
119    async fn refreshes_published_repositories_and_reports_their_urls() -> Result<()> {
120        let root = TempDir::new("control");
121        let repo = root.join("repos/alice/thing");
122        init_repo(&repo)?;
123        fs::write(repo.join("README"), "hello")?;
124        commit(&repo, "initial")?;
125
126        let socket = root.join("control");
127        let listener = UnixListener::bind(&socket)?;
128        let state = Inner::new(
129            Config {
130                repositories: root.join("repos"),
131                cache: root.join("cache"),
132                socket: PathBuf::from("/unused"),
133                instance_name: "test".into(),
134                internal_prefix: "/_sorcery".into(),
135                git_internal_prefix: "/_sorcery-git".into(),
136                check_interval: Duration::from_secs(3600),
137                max_git_processes: 1,
138                control_socket: Some(socket.clone()),
139                clone_url_base: Some("https://git.example/".into()),
140            },
141            grammar_cache(),
142        );
143        tokio::spawn(super::serve(state, listener));
144
145        assert_eq!(
146            request(&socket, &repo).await?,
147            "ok https://git.example/alice/thing\n"
148        );
149        assert!(root.join("cache/alice/thing").join(STATE_FILE).is_file());
150
151        assert_eq!(request(&socket, &root.join("repos/alice")).await?, "ok\n");
152        assert!(
153            request(&socket, &root.join("missing"))
154                .await?
155                .starts_with("error ")
156        );
157        Ok(())
158    }
159}