irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

204 lines7.8 KBRust
1//! The pre-receive hook. git runs `irongit hook pre-receive` (through the
2//! script `install` writes) after receiving a pack and before updating refs.
3//! The new objects sit in a quarantine directory at that point, so this is
4//! the one place a push can be inspected and refused as a whole.
5//!
6//! Limits arrive in the environment from whoever spawned receive-pack:
7//! IRONGIT_MAX_FILE_BYTES largest blob allowed (bigger belongs in LFS)
8//! IRONGIT_QUOTA_REMAINING bytes the owner may still add
9
10use std::{
11 collections::HashMap,
12 io::{BufRead, Write},
13 path::Path,
14 process::{Command, Stdio},
15};
16
17use crate::{config::Config, git::ZERO_SHA};
18
19pub const ENV_MAX_FILE: &str = "IRONGIT_MAX_FILE_BYTES";
20pub const ENV_QUOTA_REMAINING: &str = "IRONGIT_QUOTA_REMAINING";
21pub const ENV_REPO: &str = "IRONGIT_REPO";
22pub const ENV_PUSHER: &str = "IRONGIT_PUSHER";
23
24/// Writes the hook scripts git is pointed at with `core.hooksPath`. They
25/// re-exec this binary, so the hook always matches the running server.
26pub async fn install(config: &Config) -> anyhow::Result<()> {
27 let exe = std::env::current_exe()?;
28 let dir = config.hooks_dir();
29 tokio::fs::create_dir_all(&dir).await?;
30 let log_dir = config.log_dir.display().to_string();
31 let script = format!(
32 "#!/bin/sh\n# Written by irongit at startup; edits are overwritten.\nIRONGIT_LOG_DIR='{}' exec '{}' hook pre-receive\n",
33 log_dir.replace('\'', "'\\''"),
34 exe.display().to_string().replace('\'', "'\\''")
35 );
36 let path = dir.join("pre-receive");
37 tokio::fs::write(&path, script).await?;
38 #[cfg(unix)]
39 {
40 use std::os::unix::fs::PermissionsExt;
41 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).await?;
42 }
43 tracing::info!(hook = %path.display(), "installed pre-receive hook");
44 Ok(())
45}
46
47/// Entry point for `irongit hook <name>`. Exit status is the verdict.
48pub fn run(name: &str) -> anyhow::Result<()> {
49 let _guard = std::env::var("IRONGIT_LOG_DIR").ok().and_then(|dir| crate::logging::init_hook(Path::new(&dir)));
50 match name {
51 "pre-receive" => match pre_receive() {
52 Ok(()) => Ok(()),
53 Err(Rejection(message)) => {
54 let mut stderr = std::io::stderr();
55 for line in message.lines() {
56 let _ = writeln!(stderr, "irongit: {line}");
57 }
58 tracing::info!(repo = %std::env::var(ENV_REPO).unwrap_or_default(), pusher = %std::env::var(ENV_PUSHER).unwrap_or_default(), %message, "push rejected");
59 std::process::exit(1);
60 }
61 },
62 other => anyhow::bail!("unknown hook {other}"),
63 }
64}
65
66struct Rejection(String);
67
68impl<E: std::fmt::Display> From<E> for Rejection {
69 fn from(error: E) -> Self {
70 tracing::error!(%error, "pre-receive hook failed");
71 Rejection(format!("internal error while checking the push: {error}"))
72 }
73}
74
75fn pre_receive() -> Result<(), Rejection> {
76 let max_file: u64 = std::env::var(ENV_MAX_FILE).ok().and_then(|v| v.parse().ok()).unwrap_or(u64::MAX);
77 let quota_remaining: u64 = std::env::var(ENV_QUOTA_REMAINING).ok().and_then(|v| v.parse().ok()).unwrap_or(u64::MAX);
78
79 let mut new_tips = Vec::new();
80 for line in std::io::stdin().lock().lines() {
81 let line = line?;
82 let mut parts = line.split_whitespace();
83 let (Some(_old), Some(new), Some(_name)) = (parts.next(), parts.next(), parts.next()) else { continue };
84 if new != ZERO_SHA {
85 new_tips.push(new.to_string());
86 }
87 }
88
89 // Quarantined objects are what this push adds to disk.
90 if let Ok(quarantine) = std::env::var("GIT_QUARANTINE_PATH") {
91 let added = dir_size(Path::new(&quarantine));
92 if added > quota_remaining {
93 return Err(Rejection(format!(
94 "this push adds {} but the account has {} of storage left.\nDelete unused repositories or ask an administrator for more space.",
95 human(added),
96 human(quota_remaining)
97 )));
98 }
99 }
100 if new_tips.is_empty() || max_file == u64::MAX {
101 return Ok(());
102 }
103
104 // Objects reachable from the new tips but from no existing ref: exactly
105 // what this push introduces. rev-list prints "sha path" for blobs.
106 let mut rev_list = Command::new("git")
107 .args(["rev-list", "--objects", "--no-object-names"])
108 .args(["--stdin"])
109 .stdin(Stdio::piped())
110 .stdout(Stdio::piped())
111 .spawn()?;
112 // --no-object-names keeps output parseable; names are recovered below.
113 {
114 let mut stdin = rev_list.stdin.take().expect("piped");
115 for tip in &new_tips {
116 writeln!(stdin, "{tip}")?;
117 }
118 writeln!(stdin, "--not")?;
119 writeln!(stdin, "--all")?;
120 }
121 let listed = rev_list.wait_with_output()?;
122 if !listed.status.success() {
123 return Err(Rejection("could not list the pushed objects".into()));
124 }
125
126 let mut check = Command::new("git")
127 .args(["cat-file", "--batch-check=%(objecttype) %(objectname) %(objectsize)"])
128 .stdin(Stdio::piped())
129 .stdout(Stdio::piped())
130 .spawn()?;
131 let objects = listed.stdout;
132 let mut stdin = check.stdin.take().expect("piped");
133 let writer = std::thread::spawn(move || stdin.write_all(&objects));
134 let output = check.wait_with_output()?;
135 let _ = writer.join();
136
137 let oversized: Vec<(String, u64)> = String::from_utf8_lossy(&output.stdout)
138 .lines()
139 .filter_map(|line| {
140 let mut f = line.split_whitespace();
141 let (kind, sha, size) = (f.next()?, f.next()?, f.next()?.parse::<u64>().ok()?);
142 (kind == "blob" && size > max_file).then(|| (sha.to_string(), size))
143 })
144 .collect();
145 if oversized.is_empty() {
146 return Ok(());
147 }
148
149 let names = blob_paths(&new_tips, &oversized.iter().map(|(sha, _)| sha.clone()).collect::<Vec<_>>());
150 let mut message = format!("push rejected: files larger than {} must use Git LFS.\n", human(max_file));
151 for (sha, size) in oversized.iter().take(20) {
152 let name = names.get(sha).map(String::as_str).unwrap_or(&sha[..12]);
153 message.push_str(&format!(" {name} ({})\n", human(*size)));
154 }
155 message.push_str("Track them with `git lfs track <pattern>`, then rewrite the commits that added them\n");
156 message.push_str("(for example `git lfs migrate import --include=<pattern>`) and push again.");
157 Err(Rejection(message))
158}
159
160/// Recovers file paths for blob shas, for a readable rejection message.
161fn blob_paths(tips: &[String], wanted: &[String]) -> HashMap<String, String> {
162 let mut out = HashMap::new();
163 let Ok(mut child) = Command::new("git")
164 .args(["rev-list", "--objects", "--stdin"])
165 .stdin(Stdio::piped())
166 .stdout(Stdio::piped())
167 .spawn()
168 else {
169 return out;
170 };
171 if let Some(mut stdin) = child.stdin.take() {
172 for tip in tips {
173 let _ = writeln!(stdin, "{tip}");
174 }
175 let _ = writeln!(stdin, "--not");
176 let _ = writeln!(stdin, "--all");
177 }
178 if let Ok(output) = child.wait_with_output() {
179 for line in String::from_utf8_lossy(&output.stdout).lines() {
180 if let Some((sha, path)) = line.split_once(' ') {
181 if wanted.iter().any(|w| w == sha) {
182 out.entry(sha.to_string()).or_insert_with(|| path.to_string());
183 }
184 }
185 }
186 }
187 out
188}
189
190fn dir_size(path: &Path) -> u64 {
191 let Ok(entries) = std::fs::read_dir(path) else { return 0 };
192 entries
193 .flatten()
194 .map(|entry| match entry.metadata() {
195 Ok(meta) if meta.is_dir() => dir_size(&entry.path()),
196 Ok(meta) => meta.len(),
197 Err(_) => 0,
198 })
199 .sum()
200}
201
202fn human(bytes: u64) -> String {
203 crate::web::ui::bytes(bytes)
204}