max / makenotwork
4 files changed,
+91 insertions,
-13 deletions
| @@ -168,6 +168,34 @@ | |||
| 168 | 168 | done | |
| 169 | 169 | "#; | |
| 170 | 170 | ||
| 171 | + | /// The `update` hook: refuse a push to a namespace MNW owns. | |
| 172 | + | /// | |
| 173 | + | /// `update` rather than `pre-receive` because it runs once per ref and rejects | |
| 174 | + | /// only that one. A push carrying a branch and a stray `refs/notes/mnw/*` should | |
| 175 | + | /// land the branch and refuse the note, not fail whole. | |
| 176 | + | /// | |
| 177 | + | /// This is the enforcement half of a policy the in-process write paths already | |
| 178 | + | /// hold (`validate_note_namespace`): the browser, the JSON API and the notes | |
| 179 | + | /// inbox all refuse the prefix. A direct push reached none of them, which left | |
| 180 | + | /// the one door with no lock on it. The server writes these refs through gix ref | |
| 181 | + | /// transactions, which run no hooks, so the door being locked from outside does | |
| 182 | + | /// not lock us out. | |
| 183 | + | /// | |
| 184 | + | /// Static, so it carries no HMAC and needs no per-repo generation. The `mnw` | |
| 185 | + | /// literal is `validation::RESERVED_NOTE_NAMESPACE`; a test pins the two | |
| 186 | + | /// together, since bash cannot read the constant. | |
| 187 | + | pub const UPDATE_HOOK: &str = r#"#!/bin/bash | |
| 188 | + | case "$1" in | |
| 189 | + | refs/notes/mnw|refs/notes/mnw/*) | |
| 190 | + | echo "refs/notes/mnw/* is written by makenot.work and cannot be pushed." | |
| 191 | + | echo "Annotate under a namespace of your own instead:" | |
| 192 | + | echo " git push origin refs/notes/<name>:refs/mnw/notes-inbox/<name>" | |
| 193 | + | exit 1 | |
| 194 | + | ;; | |
| 195 | + | esac | |
| 196 | + | exit 0 | |
| 197 | + | "#; | |
| 198 | + | ||
| 171 | 199 | /// Compute a per-repo HMAC so the global token never touches disk. | |
| 172 | 200 | pub fn repo_hmac(token: &str, owner: &str, repo: &str) -> String { | |
| 173 | 201 | use hmac::{Hmac, KeyInit, Mac}; | |
| @@ -1105,6 +1133,22 @@ | |||
| 1105 | 1133 | mod tests { | |
| 1106 | 1134 | use super::*; | |
| 1107 | 1135 | ||
| 1136 | + | #[test] | |
| 1137 | + | fn the_update_hook_guards_the_namespace_validation_reserves() { | |
| 1138 | + | // Bash cannot read a Rust constant, so the literal in the hook is a | |
| 1139 | + | // copy. Renaming the reserved prefix without editing the hook would | |
| 1140 | + | // leave the new one pushable and the old one locked, which is the | |
| 1141 | + | // failure this pins: two doors, one policy. | |
| 1142 | + | let reserved = crate::validation::RESERVED_NOTE_NAMESPACE; | |
| 1143 | + | assert!( | |
| 1144 | + | UPDATE_HOOK.contains(&format!("refs/notes/{reserved}|refs/notes/{reserved}/*")), | |
| 1145 | + | "the update hook does not guard refs/notes/{reserved}/*:\n{UPDATE_HOOK}" | |
| 1146 | + | ); | |
| 1147 | + | // The bare prefix and the subtree are separate patterns in a glob, and | |
| 1148 | + | // matching only the subtree would leave `refs/notes/mnw` itself open. | |
| 1149 | + | assert!(UPDATE_HOOK.contains("exit 1"), "{UPDATE_HOOK}"); | |
| 1150 | + | } | |
| 1151 | + | ||
| 1108 | 1152 | #[tokio::test] | |
| 1109 | 1153 | async fn read_capped_truncates_to_cap() { | |
| 1110 | 1154 | // 10k bytes through a 4k cap retains exactly 4k (the rest is drained and |
| @@ -236,11 +236,9 @@ | |||
| 236 | 236 | // Build triggers are optional, and a repo that pushes without firing one is | |
| 237 | 237 | // a working repo. Warn rather than fail: refusing the push over a missing | |
| 238 | 238 | // hook would trade an empty repo for an unpushable one. | |
| 239 | - | if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") { | |
| 240 | - | let hook = crate::build_runner::post_receive_hook(&token, owner, repo_name); | |
| 241 | - | if let Err(error) = install_hook_for_repo(&repo_dir, &hook) { | |
| 242 | - | tracing::warn!(error = ?error, path = %repo_dir.display(), "post-receive hook not installed"); | |
| 243 | - | } | |
| 239 | + | let token = std::env::var("BUILD_TRIGGER_TOKEN").ok(); | |
| 240 | + | if let Err(error) = install_hooks_for_repo(&repo_dir, token.as_deref(), owner, repo_name) { | |
| 241 | + | tracing::warn!(error = ?error, path = %repo_dir.display(), "hooks not installed"); | |
| 244 | 242 | } | |
| 245 | 243 | ||
| 246 | 244 | Ok(()) | |
| @@ -330,11 +328,44 @@ | |||
| 330 | 328 | } | |
| 331 | 329 | } | |
| 332 | 330 | ||
| 333 | - | /// Install a post-receive hook in a bare git repository. | |
| 334 | - | pub fn install_hook_for_repo(repo_dir: &std::path::Path, hook_content: &str) -> anyhow::Result<()> { | |
| 331 | + | /// Install every hook a bare repository needs. | |
| 332 | + | /// | |
| 333 | + | /// One call rather than one per hook, because the hooks are not independent: | |
| 334 | + | /// `post-receive` reindexes a notes push and `update` decides whether that push | |
| 335 | + | /// is allowed at all, and a repository with only the first enforces no policy | |
| 336 | + | /// while looking installed. A caller that has to remember the second is a | |
| 337 | + | /// caller that eventually does not. | |
| 338 | + | /// `token` is optional because `post-receive` carries a per-repo HMAC and is | |
| 339 | + | /// useless without one, while `update` carries nothing and enforces a policy | |
| 340 | + | /// that holds whether or not this deployment runs builds. Gating both on the | |
| 341 | + | /// token would leave a server with no `BUILD_TRIGGER_TOKEN` accepting pushes to | |
| 342 | + | /// namespaces MNW owns. | |
| 343 | + | pub fn install_hooks_for_repo( | |
| 344 | + | repo_dir: &std::path::Path, | |
| 345 | + | token: Option<&str>, | |
| 346 | + | owner: &str, | |
| 347 | + | repo_name: &str, | |
| 348 | + | ) -> anyhow::Result<()> { | |
| 349 | + | if let Some(token) = token { | |
| 350 | + | install_hook( | |
| 351 | + | repo_dir, | |
| 352 | + | "post-receive", | |
| 353 | + | &crate::build_runner::post_receive_hook(token, owner, repo_name), | |
| 354 | + | )?; | |
| 355 | + | } | |
| 356 | + | install_hook(repo_dir, "update", crate::build_runner::UPDATE_HOOK)?; | |
| 357 | + | Ok(()) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | /// Write one executable hook into a bare repository's `hooks/`. | |
| 361 | + | fn install_hook( | |
| 362 | + | repo_dir: &std::path::Path, | |
| 363 | + | hook_name: &str, | |
| 364 | + | hook_content: &str, | |
| 365 | + | ) -> anyhow::Result<()> { | |
| 335 | 366 | let hooks_dir = repo_dir.join("hooks"); | |
| 336 | 367 | std::fs::create_dir_all(&hooks_dir)?; | |
| 337 | - | let hook_path = hooks_dir.join("post-receive"); | |
| 368 | + | let hook_path = hooks_dir.join(hook_name); | |
| 338 | 369 | std::fs::write(&hook_path, hook_content)?; | |
| 339 | 370 | ||
| 340 | 371 | #[cfg(unix)] |
| @@ -728,14 +728,17 @@ | |||
| 728 | 728 | continue; | |
| 729 | 729 | } | |
| 730 | 730 | ||
| 731 | - | let hook_content = | |
| 732 | - | makenotwork::build_runner::post_receive_hook(&token, &owner_name, repo_name); | |
| 733 | - | makenotwork::git_ssh::install_hook_for_repo(&repo_path, &hook_content)?; | |
| 731 | + | makenotwork::git_ssh::install_hooks_for_repo( | |
| 732 | + | &repo_path, | |
| 733 | + | Some(&token), | |
| 734 | + | &owner_name, | |
| 735 | + | repo_name, | |
| 736 | + | )?; | |
| 734 | 737 | installed += 1; | |
| 735 | 738 | } | |
| 736 | 739 | } | |
| 737 | 740 | ||
| 738 | - | println!("Installed post-receive hooks on {installed} repo(s)."); | |
| 741 | + | println!("Installed hooks on {installed} repo(s)."); | |
| 739 | 742 | Ok(()) | |
| 740 | 743 | } | |
| 741 | 744 |
| @@ -154,7 +154,7 @@ | |||
| 154 | 154 | /// once P6 mirrors them out of Postgres. Reserving it before anyone can write | |
| 155 | 155 | /// there is the cheap order: taking a prefix back after repositories carry | |
| 156 | 156 | /// user notes under it means deciding what happens to those notes. | |
| 157 | - | const RESERVED_NOTE_NAMESPACE: &str = "mnw"; | |
| 157 | + | pub(crate) const RESERVED_NOTE_NAMESPACE: &str = "mnw"; | |
| 158 | 158 | ||
| 159 | 159 | /// Validate a git notes namespace: the part after `refs/notes/`. | |
| 160 | 160 | /// |