Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! `dogfood-hook` — the pre-receive hook (spec §4).
Matt W2//!
Matt W3//! > Hooks are the compiled `dogfood-hook` binary, installed into each repo's
Matt W4//! > `hooks/` directory at creation and connecting to the database over a local
Matt W5//! > socket. Do not use shell scripts — argument handling and error propagation
Matt W6//! > are both worse.
Matt W7//!
Matt W8//! Runs synchronously on the push path with a <200ms budget, so it does the
Matt W9//! cheap checks first and only touches the database when it must.
Matt W10//!
Matt W11//! stdin is one line per ref update: `<old-oid> <new-oid> <refname>`.
Matt W12//! A non-zero exit rejects the entire push.
Matt W13
Matt W14use std::io::{BufRead, Write};
Matt W15use std::process::ExitCode;
Matt W16
Matt W17use df_store::refname;
Matt W18
Matt W19/// Hard cap on the whole hook, so a database stall cannot hang a push
Matt W20/// indefinitely. On timeout we *allow* the push: the alternative is that a
Matt W21/// database blip blocks all pushes, and the indexer re-derives state anyway.
Matt W22/// Ref-name validation is local and has already run by then.
Matt W23const DB_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500);
Matt W24
Matt W25fn main() -> ExitCode {
Matt W26 let mut stderr = std::io::stderr();
Matt W27
Matt W28 let updates = match read_updates() {
Matt W29 Ok(u) => u,
Matt W30 Err(e) => {
Matt W31 let _ = writeln!(stderr, "dogfood: could not read ref updates: {e}");
Matt W32 return ExitCode::FAILURE;
Matt W33 }
Matt W34 };
Matt W35
Matt W36 if updates.is_empty() {
Matt W37 return ExitCode::SUCCESS;
Matt W38 }
Matt W39
Matt W40 // ── 1. ref-name validation (local, no I/O) ───────────────────────────────
Matt W41 for u in &updates {
Matt W42 if let Err(e) = refname::validate_pushed_ref(&u.refname) {
Matt W43 // The message is printed to the pusher's terminal. `RefError`'s
Matt W44 // Display never echoes the offending byte, which is the point.
Matt W45 let _ = writeln!(stderr, "dogfood: rejected `{}`: {e}", sanitise(&u.refname));
Matt W46 return ExitCode::FAILURE;
Matt W47 }
Matt W48 }
Matt W49
Matt W50 // ── 2. protected bookmarks (needs the database) ──────────────────────────
Matt W51 let repo_id = std::env::var("DOGFOOD_REPO_ID").ok();
Matt W52 let database_url = std::env::var("DATABASE_URL").ok();
Matt W53
Matt W54 // Set by whichever transport spawned git, to say "this check is mandatory".
Matt W55 // Its absence means an old hook script or a transport that forgot to pass
Matt W56 // the environment — the exact failure that made bookmark protection a
Matt W57 // no-op over HTTPS without anything reporting it.
Matt W58 let enforced = std::env::var("DOGFOOD_ENFORCE").is_ok();
Matt W59
Matt W60 match (repo_id, database_url) {
Matt W61 (Some(repo_id), Some(url)) => match check_protected(&url, &repo_id, &updates) {
Matt W62 Ok(Some(violation)) => {
Matt W63 let _ = writeln!(stderr, "dogfood: {violation}");
Matt W64 return ExitCode::FAILURE;
Matt W65 }
Matt W66 Ok(None) => {}
Matt W67 Err(e) => {
Matt W68 // A *transient* failure fails open, loudly: a database blip
Matt W69 // blocking every push is worse than one missed check, and ref
Matt W70 // names were already validated locally above.
Matt W71 let _ = writeln!(
Matt W72 stderr,
Matt W73 "dogfood: warning: could not verify bookmark protection ({e}); allowing push"
Matt W74 );
Matt W75 }
Matt W76 },
Matt W77 // Misconfiguration, not a blip. Failing open here is how a protected
Matt W78 // bookmark stays unprotected forever with nobody noticing, so when the
Matt W79 // caller said the check was mandatory, refuse.
Matt W80 _ if enforced => {
Matt W81 let _ = writeln!(
Matt W82 stderr,
Matt W83 "dogfood: push validation is not configured on this server \
Matt W84 (missing DOGFOOD_REPO_ID or DATABASE_URL); refusing the push"
Matt W85 );
Matt W86 return ExitCode::FAILURE;
Matt W87 }
Matt W88 _ => {}
Matt W89 }
Matt W90
Matt W91 ExitCode::SUCCESS
Matt W92}
Matt W93
Matt W94struct Update {
Matt W95 old: String,
Matt W96 new: String,
Matt W97 refname: String,
Matt W98}
Matt W99
Matt W100impl Update {
Matt W101 /// Whether this update deletes the ref.
Matt W102 fn is_delete(&self) -> bool {
Matt W103 self.new.chars().all(|c| c == '0')
Matt W104 }
Matt W105
Matt W106 /// Whether this update is a non-fast-forward (a force push).
Matt W107 ///
Matt W108 /// The hook cannot cheaply prove ancestry without object access, so this is
Matt W109 /// the conservative signal: any update to an existing ref that is not a
Matt W110 /// creation. Protected bookmarks reject both.
Matt W111 fn is_update_of_existing(&self) -> bool {
Matt W112 !self.old.chars().all(|c| c == '0')
Matt W113 }
Matt W114}
Matt W115
Matt W116fn read_updates() -> std::io::Result<Vec<Update>> {
Matt W117 let stdin = std::io::stdin();
Matt W118 let mut out = Vec::new();
Matt W119
Matt W120 for line in stdin.lock().lines() {
Matt W121 let line = line?;
Matt W122 let line = line.trim_end_matches(['\r', '\n']);
Matt W123 if line.is_empty() {
Matt W124 continue;
Matt W125 }
Matt W126 let mut parts = line.split(' ');
Matt W127 let (Some(old), Some(new), Some(refname)) = (parts.next(), parts.next(), parts.next())
Matt W128 else {
Matt W129 // A malformed line means we do not understand the push; refuse
Matt W130 // rather than let it through unvalidated.
Matt W131 return Err(std::io::Error::new(
Matt W132 std::io::ErrorKind::InvalidData,
Matt W133 "malformed ref update line",
Matt W134 ));
Matt W135 };
Matt W136 out.push(Update {
Matt W137 old: old.to_string(),
Matt W138 new: new.to_string(),
Matt W139 refname: refname.to_string(),
Matt W140 });
Matt W141 }
Matt W142 Ok(out)
Matt W143}
Matt W144
Matt W145/// Whether `old` is an ancestor of `new` — i.e. whether this update is a
Matt W146/// fast-forward.
Matt W147///
Matt W148/// Asked of git rather than reasoned about: the hook already runs inside the
Matt W149/// repository (git sets `GIT_DIR`), and re-implementing ancestry against a
Matt W150/// pack we do not otherwise read would be a lot of code to get subtly wrong.
Matt W151///
Matt W152/// On any doubt this returns `false`, so an update we cannot classify is
Matt W153/// treated as a force-push and refused. For a *protected* bookmark that is the
Matt W154/// safe direction.
Matt W155fn is_fast_forward(old: &str, new: &str) -> bool {
Matt W156 std::process::Command::new("git")
Matt W157 .args(["merge-base", "--is-ancestor", old, new])
Matt W158 .stdin(std::process::Stdio::null())
Matt W159 .stdout(std::process::Stdio::null())
Matt W160 .stderr(std::process::Stdio::null())
Matt W161 .status()
Matt W162 .map(|s| s.success())
Matt W163 .unwrap_or(false)
Matt W164}
Matt W165
Matt W166/// Reject pushes to an archived repository, and deletes or force-pushes of
Matt W167/// protected bookmarks.
Matt W168///
Matt W169/// "Protected" means what the settings page says it means: the bookmark cannot
Matt W170/// be **deleted** or **force-updated**. An ordinary fast-forward is allowed —
Matt W171/// refusing those too would block the normal `jj git push -b main` workflow
Matt W172/// while the UI promised it was fine.
Matt W173fn check_protected(
Matt W174 database_url: &str,
Matt W175 repo_id: &str,
Matt W176 updates: &[Update],
Matt W177) -> Result<Option<String>, String> {
Matt W178 let runtime = tokio::runtime::Builder::new_current_thread()
Matt W179 .enable_all()
Matt W180 .build()
Matt W181 .map_err(|e| e.to_string())?;
Matt W182
Matt W183 runtime.block_on(async {
Matt W184 let fut = async {
Matt W185 let pool = df_db::connect(database_url, 1)
Matt W186 .await
Matt W187 .map_err(|e| e.to_string())?;
Matt W188
Matt W189 let repo: uuid::Uuid = repo_id.parse().map_err(|_| "bad repo id".to_string())?;
Matt W190
Matt W191 // An archived repository accepts nothing. Checked here as well as on
Matt W192 // each transport, because the hook is the one place both of them
Matt W193 // pass through.
Matt W194 let archived: Option<(bool,)> =
Matt W195 sqlx::query_as("SELECT archived FROM repos WHERE id = $1")
Matt W196 .bind(repo)
Matt W197 .fetch_optional(&pool)
Matt W198 .await
Matt W199 .map_err(|e| e.to_string())?;
Matt W200
Matt W201 match archived {
Matt W202 Some((true,)) => {
Matt W203 return Ok::<_, String>(Some(
Matt W204 "this repository is archived and does not accept pushes".to_string(),
Matt W205 ))
Matt W206 }
Matt W207 // No row: the repository was deleted between the push starting
Matt W208 // and this check. Refusing is the only safe reading.
Matt W209 None => return Ok(Some("this repository no longer exists".to_string())),
Matt W210 Some((false,)) => {}
Matt W211 }
Matt W212
Matt W213 // The default bookmark is protected whether or not it carries the
Matt W214 // flag. The settings page says "always" for it, and a `bookmarks`
Matt W215 // row created by the indexer defaults to unprotected — so reading
Matt W216 // the flag alone would quietly make that promise false.
Matt W217 let protected: Vec<(String,)> = sqlx::query_as(
Matt W218 "SELECT name FROM bookmarks WHERE repo_id = $1 AND protected
Matt W219 UNION
Matt W220 SELECT default_bookmark FROM repos WHERE id = $1",
Matt W221 )
Matt W222 .bind(repo)
Matt W223 .fetch_all(&pool)
Matt W224 .await
Matt W225 .map_err(|e| e.to_string())?;
Matt W226
Matt W227 for u in updates {
Matt W228 let Some(short) = u.refname.strip_prefix("refs/heads/") else {
Matt W229 continue;
Matt W230 };
Matt W231 if !protected.iter().any(|(n,)| n == short) {
Matt W232 continue;
Matt W233 }
Matt W234 if u.is_delete() {
Matt W235 return Ok(Some(format!(
Matt W236 "`{short}` is a protected bookmark and cannot be deleted"
Matt W237 )));
Matt W238 }
Matt W239 // Creating it is fine — that is the first push to a new
Matt W240 // repository. Updating it is fine too, as long as it only moves
Matt W241 // forward.
Matt W242 if u.is_update_of_existing() && !is_fast_forward(&u.old, &u.new) {
Matt W243 return Ok(Some(format!(
Matt W244 "`{short}` is a protected bookmark and cannot be force-updated; \
Matt W245 rebase onto it, or open a change"
Matt W246 )));
Matt W247 }
Matt W248 }
Matt W249 Ok(None)
Matt W250 };
Matt W251
Matt W252 match tokio::time::timeout(DB_TIMEOUT, fut).await {
Matt W253 Ok(r) => r,
Matt W254 Err(_) => Err("timed out".to_string()),
Matt W255 }
Matt W256 })
Matt W257}
Matt W258
Matt W259/// Render a ref name safely for terminal output.
Matt W260///
Matt W261/// Validation has usually already rejected anything dangerous, but this runs on
Matt W262/// the error path where the name is by definition untrusted.
Matt W263fn sanitise(s: &str) -> String {
Matt W264 s.chars()
Matt W265 .map(|c| {
Matt W266 if c.is_control() || c == '\u{7f}' {
Matt W267 '?'
Matt W268 } else {
Matt W269 c
Matt W270 }
Matt W271 })
Matt W272 .take(200)
Matt W273 .collect()
Matt W274}
Matt W275
Matt W276#[cfg(test)]
Matt W277mod tests {
Matt W278 use super::*;
Matt W279
Matt W280 fn u(old: &str, new: &str, name: &str) -> Update {
Matt W281 Update {
Matt W282 old: old.into(),
Matt W283 new: new.into(),
Matt W284 refname: name.into(),
Matt W285 }
Matt W286 }
Matt W287
Matt W288 const ZERO: &str = "0000000000000000000000000000000000000000";
Matt W289 const OID: &str = "1111111111111111111111111111111111111111";
Matt W290
Matt W291 #[test]
Matt W292 fn detects_deletes() {
Matt W293 assert!(u(OID, ZERO, "refs/heads/main").is_delete());
Matt W294 assert!(!u(OID, OID, "refs/heads/main").is_delete());
Matt W295 }
Matt W296
Matt W297 #[test]
Matt W298 fn detects_creation_versus_update() {
Matt W299 assert!(!u(ZERO, OID, "refs/heads/new").is_update_of_existing());
Matt W300 assert!(u(OID, OID, "refs/heads/main").is_update_of_existing());
Matt W301 }
Matt W302
Matt W303 #[test]
Matt W304 fn sanitise_strips_control_bytes() {
Matt W305 assert_eq!(sanitise("main\x1b[31m"), "main?[31m");
Matt W306 assert_eq!(sanitise("main\n"), "main?");
Matt W307 assert_eq!(sanitise("ok"), "ok");
Matt W308 }
Matt W309
Matt W310 #[test]
Matt W311 fn sanitise_bounds_its_output() {
Matt W312 assert_eq!(sanitise(&"a".repeat(500)).len(), 200);
Matt W313 }
Matt W314}

314 lines · Rust