Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! `dogfood-hook` — the pre-receive hook (spec §4).
2//!
3//! > Hooks are the compiled `dogfood-hook` binary, installed into each repo's
4//! > `hooks/` directory at creation and connecting to the database over a local
5//! > socket. Do not use shell scripts — argument handling and error propagation
6//! > are both worse.
7//!
8//! Runs synchronously on the push path with a <200ms budget, so it does the
9//! cheap checks first and only touches the database when it must.
10//!
11//! stdin is one line per ref update: `<old-oid> <new-oid> <refname>`.
12//! A non-zero exit rejects the entire push.
13
14use std::io::{BufRead, Write};
15use std::process::ExitCode;
16
17use df_store::refname;
18
19/// Hard cap on the whole hook, so a database stall cannot hang a push
20/// indefinitely. On timeout we *allow* the push: the alternative is that a
21/// database blip blocks all pushes, and the indexer re-derives state anyway.
22/// Ref-name validation is local and has already run by then.
23const DB_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500);
24
25fn main() -> ExitCode {
26 let mut stderr = std::io::stderr();
27
28 let updates = match read_updates() {
29 Ok(u) => u,
30 Err(e) => {
31 let _ = writeln!(stderr, "dogfood: could not read ref updates: {e}");
32 return ExitCode::FAILURE;
33 }
34 };
35
36 if updates.is_empty() {
37 return ExitCode::SUCCESS;
38 }
39
40 // ── 1. ref-name validation (local, no I/O) ───────────────────────────────
41 for u in &updates {
42 if let Err(e) = refname::validate_pushed_ref(&u.refname) {
43 // The message is printed to the pusher's terminal. `RefError`'s
44 // Display never echoes the offending byte, which is the point.
45 let _ = writeln!(stderr, "dogfood: rejected `{}`: {e}", sanitise(&u.refname));
46 return ExitCode::FAILURE;
47 }
48 }
49
50 // ── 2. protected bookmarks (needs the database) ──────────────────────────
51 let repo_id = std::env::var("DOGFOOD_REPO_ID").ok();
52 let database_url = std::env::var("DATABASE_URL").ok();
53
54 // Set by whichever transport spawned git, to say "this check is mandatory".
55 // Its absence means an old hook script or a transport that forgot to pass
56 // the environment — the exact failure that made bookmark protection a
57 // no-op over HTTPS without anything reporting it.
58 let enforced = std::env::var("DOGFOOD_ENFORCE").is_ok();
59
60 match (repo_id, database_url) {
61 (Some(repo_id), Some(url)) => match check_protected(&url, &repo_id, &updates) {
62 Ok(Some(violation)) => {
63 let _ = writeln!(stderr, "dogfood: {violation}");
64 return ExitCode::FAILURE;
65 }
66 Ok(None) => {}
67 Err(e) => {
68 // A *transient* failure fails open, loudly: a database blip
69 // blocking every push is worse than one missed check, and ref
70 // names were already validated locally above.
71 let _ = writeln!(
72 stderr,
73 "dogfood: warning: could not verify bookmark protection ({e}); allowing push"
74 );
75 }
76 },
77 // Misconfiguration, not a blip. Failing open here is how a protected
78 // bookmark stays unprotected forever with nobody noticing, so when the
79 // caller said the check was mandatory, refuse.
80 _ if enforced => {
81 let _ = writeln!(
82 stderr,
83 "dogfood: push validation is not configured on this server \
84 (missing DOGFOOD_REPO_ID or DATABASE_URL); refusing the push"
85 );
86 return ExitCode::FAILURE;
87 }
88 _ => {}
89 }
90
91 ExitCode::SUCCESS
92}
93
94struct Update {
95 old: String,
96 new: String,
97 refname: String,
98}
99
100impl Update {
101 /// Whether this update deletes the ref.
102 fn is_delete(&self) -> bool {
103 self.new.chars().all(|c| c == '0')
104 }
105
106 /// Whether this update is a non-fast-forward (a force push).
107 ///
108 /// The hook cannot cheaply prove ancestry without object access, so this is
109 /// the conservative signal: any update to an existing ref that is not a
110 /// creation. Protected bookmarks reject both.
111 fn is_update_of_existing(&self) -> bool {
112 !self.old.chars().all(|c| c == '0')
113 }
114}
115
116fn read_updates() -> std::io::Result<Vec<Update>> {
117 let stdin = std::io::stdin();
118 let mut out = Vec::new();
119
120 for line in stdin.lock().lines() {
121 let line = line?;
122 let line = line.trim_end_matches(['\r', '\n']);
123 if line.is_empty() {
124 continue;
125 }
126 let mut parts = line.split(' ');
127 let (Some(old), Some(new), Some(refname)) = (parts.next(), parts.next(), parts.next())
128 else {
129 // A malformed line means we do not understand the push; refuse
130 // rather than let it through unvalidated.
131 return Err(std::io::Error::new(
132 std::io::ErrorKind::InvalidData,
133 "malformed ref update line",
134 ));
135 };
136 out.push(Update {
137 old: old.to_string(),
138 new: new.to_string(),
139 refname: refname.to_string(),
140 });
141 }
142 Ok(out)
143}
144
145/// Whether `old` is an ancestor of `new` — i.e. whether this update is a
146/// fast-forward.
147///
148/// Asked of git rather than reasoned about: the hook already runs inside the
149/// repository (git sets `GIT_DIR`), and re-implementing ancestry against a
150/// pack we do not otherwise read would be a lot of code to get subtly wrong.
151///
152/// On any doubt this returns `false`, so an update we cannot classify is
153/// treated as a force-push and refused. For a *protected* bookmark that is the
154/// safe direction.
155fn is_fast_forward(old: &str, new: &str) -> bool {
156 std::process::Command::new("git")
157 .args(["merge-base", "--is-ancestor", old, new])
158 .stdin(std::process::Stdio::null())
159 .stdout(std::process::Stdio::null())
160 .stderr(std::process::Stdio::null())
161 .status()
162 .map(|s| s.success())
163 .unwrap_or(false)
164}
165
166/// Reject pushes to an archived repository, and deletes or force-pushes of
167/// protected bookmarks.
168///
169/// "Protected" means what the settings page says it means: the bookmark cannot
170/// be **deleted** or **force-updated**. An ordinary fast-forward is allowed —
171/// refusing those too would block the normal `jj git push -b main` workflow
172/// while the UI promised it was fine.
173fn check_protected(
174 database_url: &str,
175 repo_id: &str,
176 updates: &[Update],
177) -> Result<Option<String>, String> {
178 let runtime = tokio::runtime::Builder::new_current_thread()
179 .enable_all()
180 .build()
181 .map_err(|e| e.to_string())?;
182
183 runtime.block_on(async {
184 let fut = async {
185 let pool = df_db::connect(database_url, 1)
186 .await
187 .map_err(|e| e.to_string())?;
188
189 let repo: uuid::Uuid = repo_id.parse().map_err(|_| "bad repo id".to_string())?;
190
191 // An archived repository accepts nothing. Checked here as well as on
192 // each transport, because the hook is the one place both of them
193 // pass through.
194 let archived: Option<(bool,)> =
195 sqlx::query_as("SELECT archived FROM repos WHERE id = $1")
196 .bind(repo)
197 .fetch_optional(&pool)
198 .await
199 .map_err(|e| e.to_string())?;
200
201 match archived {
202 Some((true,)) => {
203 return Ok::<_, String>(Some(
204 "this repository is archived and does not accept pushes".to_string(),
205 ))
206 }
207 // No row: the repository was deleted between the push starting
208 // and this check. Refusing is the only safe reading.
209 None => return Ok(Some("this repository no longer exists".to_string())),
210 Some((false,)) => {}
211 }
212
213 // The default bookmark is protected whether or not it carries the
214 // flag. The settings page says "always" for it, and a `bookmarks`
215 // row created by the indexer defaults to unprotected — so reading
216 // the flag alone would quietly make that promise false.
217 let protected: Vec<(String,)> = sqlx::query_as(
218 "SELECT name FROM bookmarks WHERE repo_id = $1 AND protected
219 UNION
220 SELECT default_bookmark FROM repos WHERE id = $1",
221 )
222 .bind(repo)
223 .fetch_all(&pool)
224 .await
225 .map_err(|e| e.to_string())?;
226
227 for u in updates {
228 let Some(short) = u.refname.strip_prefix("refs/heads/") else {
229 continue;
230 };
231 if !protected.iter().any(|(n,)| n == short) {
232 continue;
233 }
234 if u.is_delete() {
235 return Ok(Some(format!(
236 "`{short}` is a protected bookmark and cannot be deleted"
237 )));
238 }
239 // Creating it is fine — that is the first push to a new
240 // repository. Updating it is fine too, as long as it only moves
241 // forward.
242 if u.is_update_of_existing() && !is_fast_forward(&u.old, &u.new) {
243 return Ok(Some(format!(
244 "`{short}` is a protected bookmark and cannot be force-updated; \
245 rebase onto it, or open a change"
246 )));
247 }
248 }
249 Ok(None)
250 };
251
252 match tokio::time::timeout(DB_TIMEOUT, fut).await {
253 Ok(r) => r,
254 Err(_) => Err("timed out".to_string()),
255 }
256 })
257}
258
259/// Render a ref name safely for terminal output.
260///
261/// Validation has usually already rejected anything dangerous, but this runs on
262/// the error path where the name is by definition untrusted.
263fn sanitise(s: &str) -> String {
264 s.chars()
265 .map(|c| {
266 if c.is_control() || c == '\u{7f}' {
267 '?'
268 } else {
269 c
270 }
271 })
272 .take(200)
273 .collect()
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 fn u(old: &str, new: &str, name: &str) -> Update {
281 Update {
282 old: old.into(),
283 new: new.into(),
284 refname: name.into(),
285 }
286 }
287
288 const ZERO: &str = "0000000000000000000000000000000000000000";
289 const OID: &str = "1111111111111111111111111111111111111111";
290
291 #[test]
292 fn detects_deletes() {
293 assert!(u(OID, ZERO, "refs/heads/main").is_delete());
294 assert!(!u(OID, OID, "refs/heads/main").is_delete());
295 }
296
297 #[test]
298 fn detects_creation_versus_update() {
299 assert!(!u(ZERO, OID, "refs/heads/new").is_update_of_existing());
300 assert!(u(OID, OID, "refs/heads/main").is_update_of_existing());
301 }
302
303 #[test]
304 fn sanitise_strips_control_bytes() {
305 assert_eq!(sanitise("main\x1b[31m"), "main?[31m");
306 assert_eq!(sanitise("main\n"), "main?");
307 assert_eq!(sanitise("ok"), "ok");
308 }
309
310 #[test]
311 fn sanitise_bounds_its_output() {
312 assert_eq!(sanitise(&"a".repeat(500)).len(), 200);
313 }
314}

314 lines · Rust