| 145 |
145 |
|
Ok(stdin)
|
| 146 |
146 |
|
}
|
| 147 |
147 |
|
|
| 148 |
|
- |
/// Post-receive hook template. __TOKEN__ is replaced with the actual token.
|
|
148 |
+ |
/// Post-receive hook template. `__HMAC__` is replaced with a per-repo HMAC
|
|
149 |
+ |
/// signature so the global `BUILD_TRIGGER_TOKEN` never lands on disk. Both
|
|
150 |
+ |
/// endpoints verify `HMAC(token, owner:repo)`, not the raw token, so this has
|
|
151 |
+ |
/// to match `server`'s `build_runner::POST_RECEIVE_HOOK_TEMPLATE`.
|
| 149 |
152 |
|
const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
|
| 150 |
153 |
|
while read oldrev newrev refname; do
|
| 151 |
154 |
|
case "$refname" in
|
| 155 |
158 |
|
REPO_NAME="$(basename "$REPO_PATH" .git)"
|
| 156 |
159 |
|
OWNER="$(basename "$(dirname "$REPO_PATH")")"
|
| 157 |
160 |
|
curl -sf -X POST \
|
| 158 |
|
- |
-H "Authorization: Bearer __TOKEN__" \
|
|
161 |
+ |
-H "Authorization: Bearer __HMAC__" \
|
| 159 |
162 |
|
-H "Content-Type: application/json" \
|
| 160 |
163 |
|
-d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
|
| 161 |
164 |
|
"http://localhost:3000/api/internal/builds/trigger" \
|
| 167 |
170 |
|
REPO_NAME="$(basename "$REPO_PATH" .git)"
|
| 168 |
171 |
|
OWNER="$(basename "$(dirname "$REPO_PATH")")"
|
| 169 |
172 |
|
curl -sf -X POST \
|
| 170 |
|
- |
-H "Authorization: Bearer __TOKEN__" \
|
|
173 |
+ |
-H "Authorization: Bearer __HMAC__" \
|
| 171 |
174 |
|
-H "Content-Type: application/json" \
|
| 172 |
175 |
|
-d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
|
| 173 |
176 |
|
"http://localhost:3000/api/internal/issues/process-push" \
|
| 177 |
180 |
|
done
|
| 178 |
181 |
|
"#;
|
| 179 |
182 |
|
|
|
183 |
+ |
/// Compute the per-repo HMAC the internal endpoints expect. Mirrors
|
|
184 |
+ |
/// `build_runner::repo_hmac` on the server side; the two must agree.
|
|
185 |
+ |
fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
|
|
186 |
+ |
use hmac::{Hmac, KeyInit, Mac};
|
|
187 |
+ |
use sha2::Sha256;
|
|
188 |
+ |
let mut mac =
|
|
189 |
+ |
<Hmac<Sha256>>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
|
|
190 |
+ |
mac.update(format!("{owner}:{repo}").as_bytes());
|
|
191 |
+ |
hex::encode(mac.finalize().into_bytes())
|
|
192 |
+ |
}
|
|
193 |
+ |
|
|
194 |
+ |
/// Split a bare-repo path into (owner, repo), matching what the hook derives
|
|
195 |
+ |
/// at run time from its own location: repo = basename minus `.git`, owner =
|
|
196 |
+ |
/// the containing directory's name.
|
|
197 |
+ |
fn owner_and_repo(repo_path: &str) -> Option<(String, String)> {
|
|
198 |
+ |
let path = std::path::Path::new(repo_path);
|
|
199 |
+ |
let repo = path.file_name()?.to_str()?;
|
|
200 |
+ |
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
|
201 |
+ |
let owner = path.parent()?.file_name()?.to_str()?;
|
|
202 |
+ |
Some((owner.to_string(), repo.to_string()))
|
|
203 |
+ |
}
|
|
204 |
+ |
|
| 180 |
205 |
|
/// Install the post-receive hook in a bare repository.
|
| 181 |
206 |
|
pub(crate) async fn install_post_receive_hook(
|
| 182 |
207 |
|
_git_user: &str,
|
| 183 |
208 |
|
repo_path: &str,
|
| 184 |
209 |
|
token: &str,
|
| 185 |
210 |
|
) -> anyhow::Result<()> {
|
| 186 |
|
- |
let hook_content = POST_RECEIVE_HOOK.replace("__TOKEN__", token);
|
|
211 |
+ |
let (owner, repo) = owner_and_repo(repo_path)
|
|
212 |
+ |
.ok_or_else(|| anyhow::anyhow!("cannot derive owner/repo from {repo_path}"))?;
|
|
213 |
+ |
let hook_content = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
|
| 187 |
214 |
|
let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive");
|
| 188 |
215 |
|
|
| 189 |
216 |
|
// Write directly — mnw-cli is in the git group, setgid dir gives correct ownership
|
| 230 |
257 |
|
assert!(parse_git_command("scp -t /tmp/file").is_none());
|
| 231 |
258 |
|
}
|
| 232 |
259 |
|
|
|
260 |
+ |
/// Locks the derivation to the same vector the server's `repo_hmac`
|
|
261 |
+ |
/// produces (HMAC-SHA256 over `owner:repo`, hex). If this drifts, every
|
|
262 |
+ |
/// hook mnw-cli installs starts failing auth silently.
|
|
263 |
+ |
#[test]
|
|
264 |
+ |
fn repo_hmac_matches_server_vector() {
|
|
265 |
+ |
assert_eq!(
|
|
266 |
+ |
repo_hmac("test-token", "max", "repo"),
|
|
267 |
+ |
"198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
|
|
268 |
+ |
);
|
|
269 |
+ |
}
|
|
270 |
+ |
|
|
271 |
+ |
#[test]
|
|
272 |
+ |
fn owner_and_repo_from_bare_path() {
|
|
273 |
+ |
let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
|
|
274 |
+ |
assert_eq!(owner, "max");
|
|
275 |
+ |
assert_eq!(repo, "repo");
|
|
276 |
+ |
|
|
277 |
+ |
// No .git suffix is still valid.
|
|
278 |
+ |
let (owner, repo) = owner_and_repo("/srv/git/max/repo").unwrap();
|
|
279 |
+ |
assert_eq!(owner, "max");
|
|
280 |
+ |
assert_eq!(repo, "repo");
|
|
281 |
+ |
|
|
282 |
+ |
// A bare name has no owner directory to read.
|
|
283 |
+ |
assert!(owner_and_repo("repo.git").is_none());
|
|
284 |
+ |
}
|
|
285 |
+ |
|
|
286 |
+ |
/// The hook that lands on disk must carry the per-repo HMAC and never the
|
|
287 |
+ |
/// global token itself.
|
|
288 |
+ |
#[test]
|
|
289 |
+ |
fn hook_body_carries_hmac_not_token() {
|
|
290 |
+ |
let token = "super-secret-token";
|
|
291 |
+ |
let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
|
|
292 |
+ |
let body = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
|
|
293 |
+ |
|
|
294 |
+ |
assert!(!body.contains(token));
|
|
295 |
+ |
assert!(!body.contains("__HMAC__"));
|
|
296 |
+ |
assert!(body.contains(&repo_hmac(token, "max", "repo")));
|
|
297 |
+ |
}
|
|
298 |
+ |
|
| 233 |
299 |
|
#[test]
|
| 234 |
300 |
|
fn parse_repo_path_basic() {
|
| 235 |
301 |
|
let (owner, repo) = parse_repo_path("max/repo.git").unwrap();
|