1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::process::Command;
34use std::sync::{Arc, Mutex as StdMutex, OnceLock};
35
36use serde_json::{json, Value};
37use tokio::sync::Mutex as AsyncMutex;
38
39use khive_runtime::{NamespaceToken, RuntimeError};
40use khive_storage::event::Event;
41use khive_types::{EventKind, EventOutcome, SubstrateKind};
42
43use crate::write_argv::{
44 build_add_argv, build_branch_argv, build_commit_argv, build_push_argv, reject_force,
45 validate_repo_path, GitArgError,
46};
47use crate::write_policy::{GitWritePolicy, GitWritePolicyError};
48use crate::GitPack;
49
50fn to_invalid_input(e: GitArgError) -> RuntimeError {
51 RuntimeError::InvalidInput(e.to_string())
52}
53
54fn to_policy_denied(e: GitWritePolicyError) -> RuntimeError {
55 RuntimeError::InvalidInput(e.to_string())
56}
57
58static REPO_LOCKS: OnceLock<StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>> = OnceLock::new();
65
66fn repo_write_lock(repo: &Path) -> Arc<AsyncMutex<()>> {
67 let key = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
68 let registry = REPO_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
69 let mut guard = registry.lock().unwrap_or_else(|e| e.into_inner());
70 guard
71 .entry(key)
72 .or_insert_with(|| Arc::new(AsyncMutex::new(())))
73 .clone()
74}
75
76fn parse_repo_param(params: &Value) -> Result<PathBuf, RuntimeError> {
77 match params.get("repo") {
78 Some(Value::String(raw)) => Ok(PathBuf::from(raw)),
79 None | Some(Value::Null) => Err(RuntimeError::InvalidInput("repo is required".into())),
80 Some(other) => Err(RuntimeError::InvalidInput(format!(
81 "repo must be a string, got {other:?}"
82 ))),
83 }
84}
85
86fn parse_optional_string<'a>(
87 params: &'a Value,
88 name: &str,
89) -> Result<Option<&'a str>, RuntimeError> {
90 match params.get(name) {
91 None | Some(Value::Null) => Ok(None),
92 Some(Value::String(value)) => Ok(Some(value)),
93 Some(other) => Err(RuntimeError::InvalidInput(format!(
94 "{name} must be a string, got {other:?}"
95 ))),
96 }
97}
98
99fn parse_paths_param(params: &Value) -> Result<Vec<String>, RuntimeError> {
100 match params.get("paths") {
101 None | Some(Value::Null) => Ok(Vec::new()),
102 Some(Value::Array(arr)) => arr
103 .iter()
104 .map(|v| {
105 v.as_str().map(str::to_string).ok_or_else(|| {
106 RuntimeError::InvalidInput("paths entries must be strings".into())
107 })
108 })
109 .collect(),
110 Some(other) => Err(RuntimeError::InvalidInput(format!(
111 "paths must be an array of strings, got {other:?}"
112 ))),
113 }
114}
115
116fn parse_force_param(params: &Value) -> Result<Option<bool>, RuntimeError> {
122 match params.get("force") {
123 None | Some(Value::Null) => Ok(None),
124 Some(Value::Bool(b)) => Ok(Some(*b)),
125 Some(other) => Err(RuntimeError::InvalidInput(format!(
126 "force must be a boolean, got {other:?}; force-push is never permitted through this verb"
127 ))),
128 }
129}
130
131fn run_git(repo: &Path, argv: &[String]) -> Result<String, RuntimeError> {
151 let mut command = Command::new("git");
152 command
153 .arg("-c")
154 .arg("core.hooksPath=/dev/null")
155 .arg("-C")
156 .arg(repo)
157 .args(argv);
158 #[cfg(test)]
159 command
160 .env("GIT_CONFIG_GLOBAL", "/dev/null")
161 .env("GIT_CONFIG_SYSTEM", "/dev/null");
162 let output = command
163 .output()
164 .map_err(|e| RuntimeError::InvalidInput(format!("spawning git {argv:?}: {e}")))?;
165 if !output.status.success() {
166 return Err(RuntimeError::InvalidInput(format!(
167 "git {argv:?} failed: {}",
168 String::from_utf8_lossy(&output.stderr).trim()
169 )));
170 }
171 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
172}
173
174fn current_branch(repo: &Path) -> Result<String, RuntimeError> {
180 let out = run_git(
181 repo,
182 &[
183 "symbolic-ref".to_string(),
184 "--short".to_string(),
185 "HEAD".to_string(),
186 ],
187 )?;
188 Ok(out.trim().to_string())
189}
190
191struct WritePreflightError {
192 error: RuntimeError,
193 branch: Option<String>,
194 outcome: EventOutcome,
195}
196
197impl WritePreflightError {
198 fn denied(error: RuntimeError, branch: Option<&str>) -> Self {
199 Self {
200 error,
201 branch: branch.map(str::to_string),
202 outcome: EventOutcome::Denied,
203 }
204 }
205
206 fn runtime(error: RuntimeError) -> Self {
207 Self {
208 error,
209 branch: None,
210 outcome: EventOutcome::Error,
211 }
212 }
213}
214
215struct CommitPreflight {
216 branch: String,
217 add_argv: Option<Vec<String>>,
218 commit_argv: Vec<String>,
219}
220
221fn prepare_commit(repo: &Path, params: &Value) -> Result<CommitPreflight, WritePreflightError> {
222 validate_repo_path(repo)
223 .map_err(to_invalid_input)
224 .map_err(|e| WritePreflightError::denied(e, None))?;
225 let branch = current_branch(repo).map_err(WritePreflightError::runtime)?;
226 let message = params
227 .get("message")
228 .and_then(Value::as_str)
229 .ok_or_else(|| RuntimeError::InvalidInput("git.commit requires message".into()))
230 .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
231 let paths =
232 parse_paths_param(params).map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
233 let author = parse_optional_string(params, "author")
234 .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
235 let add_argv = if paths.is_empty() {
236 None
237 } else {
238 Some(
239 build_add_argv(&paths)
240 .map_err(to_invalid_input)
241 .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?,
242 )
243 };
244 let commit_argv = build_commit_argv(message, &paths, author)
245 .map_err(to_invalid_input)
246 .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
247 Ok(CommitPreflight {
248 branch,
249 add_argv,
250 commit_argv,
251 })
252}
253
254struct BranchPreflight {
255 name: String,
256 from: Option<String>,
257 argv: Vec<String>,
258}
259
260fn prepare_branch(repo: &Path, params: &Value) -> Result<BranchPreflight, WritePreflightError> {
261 validate_repo_path(repo)
262 .map_err(to_invalid_input)
263 .map_err(|e| WritePreflightError::denied(e, None))?;
264 let name = params
265 .get("name")
266 .and_then(Value::as_str)
267 .ok_or_else(|| RuntimeError::InvalidInput("git.branch requires name".into()))
268 .map_err(|e| WritePreflightError::denied(e, None))?;
269 let from = parse_optional_string(params, "from")
270 .map_err(|e| WritePreflightError::denied(e, Some(name)))?;
271 let argv = build_branch_argv(name, from)
272 .map_err(to_invalid_input)
273 .map_err(|e| WritePreflightError::denied(e, Some(name)))?;
274 Ok(BranchPreflight {
275 name: name.to_string(),
276 from: from.map(str::to_string),
277 argv,
278 })
279}
280
281struct PushPreflight {
282 branch: String,
283 remote: String,
284 argv: Vec<String>,
285}
286
287fn prepare_push(repo: &Path, params: &Value) -> Result<PushPreflight, WritePreflightError> {
288 validate_repo_path(repo)
289 .map_err(to_invalid_input)
290 .map_err(|e| WritePreflightError::denied(e, None))?;
291 let branch = params
292 .get("branch")
293 .and_then(Value::as_str)
294 .ok_or_else(|| RuntimeError::InvalidInput("git.push requires branch".into()))
295 .map_err(|e| WritePreflightError::denied(e, None))?;
296 let remote = parse_optional_string(params, "remote")
297 .map_err(|e| WritePreflightError::denied(e, Some(branch)))?
298 .unwrap_or("origin");
299 let force =
300 parse_force_param(params).map_err(|e| WritePreflightError::denied(e, Some(branch)))?;
301 reject_force(force)
302 .map_err(to_invalid_input)
303 .map_err(|e| WritePreflightError::denied(e, Some(branch)))?;
304 let argv = build_push_argv(remote, branch)
305 .map_err(to_invalid_input)
306 .map_err(|e| WritePreflightError::denied(e, Some(branch)))?;
307 Ok(PushPreflight {
308 branch: branch.to_string(),
309 remote: remote.to_string(),
310 argv,
311 })
312}
313
314impl GitPack {
315 fn enforce_write_policy(&self, repo: &Path, branch: &str) -> Result<PathBuf, RuntimeError> {
334 let policy = GitWritePolicy::from_config(&self.runtime().config().git_write);
335 policy.check(repo, branch).map_err(to_policy_denied)
336 }
337
338 async fn audit_early_failure(
339 &self,
340 token: &NamespaceToken,
341 verb: &str,
342 repo: &Path,
343 branch: Option<&str>,
344 outcome: EventOutcome,
345 error: RuntimeError,
346 ) -> RuntimeError {
347 self.emit_write_audit(token, verb, repo, branch, "deny", outcome, None)
348 .await;
349 error
350 }
351
352 async fn parse_audited_repo(
353 &self,
354 token: &NamespaceToken,
355 verb: &str,
356 params: &Value,
357 ) -> Result<PathBuf, RuntimeError> {
358 match parse_repo_param(params) {
359 Ok(repo) => Ok(repo),
360 Err(error) => Err(self
361 .audit_early_failure(
362 token,
363 verb,
364 Path::new("<invalid-repo>"),
365 None,
366 EventOutcome::Denied,
367 error,
368 )
369 .await),
370 }
371 }
372
373 pub(crate) async fn handle_commit(
374 &self,
375 token: &NamespaceToken,
376 params: Value,
377 ) -> Result<Value, RuntimeError> {
378 let repo = self
379 .parse_audited_repo(token, "git.commit", ¶ms)
380 .await?;
381 let lock = repo_write_lock(&repo);
382 let _guard = lock.lock().await;
383 let CommitPreflight {
384 branch,
385 add_argv,
386 commit_argv,
387 } = match prepare_commit(&repo, ¶ms) {
388 Ok(preflight) => preflight,
389 Err(failure) => {
390 return Err(self
391 .audit_early_failure(
392 token,
393 "git.commit",
394 &repo,
395 failure.branch.as_deref(),
396 failure.outcome,
397 failure.error,
398 )
399 .await)
400 }
401 };
402
403 let canonical_repo = match self.enforce_write_policy(&repo, &branch) {
404 Ok(p) => p,
405 Err(e) => {
406 self.emit_write_audit(
407 token,
408 "git.commit",
409 &repo,
410 Some(&branch),
411 "deny",
412 EventOutcome::Denied,
413 None,
414 )
415 .await;
416 return Err(e);
417 }
418 };
419
420 let exec: Result<String, RuntimeError> = (|| {
421 if let Some(add_argv) = &add_argv {
422 run_git(&canonical_repo, add_argv)?;
423 }
424 run_git(&canonical_repo, &commit_argv)?;
425 let sha = run_git(
426 &canonical_repo,
427 &["rev-parse".to_string(), "HEAD".to_string()],
428 )?
429 .trim()
430 .to_string();
431 Ok(sha)
432 })();
433
434 match exec {
435 Ok(sha) => {
436 self.emit_write_audit(
437 token,
438 "git.commit",
439 &canonical_repo,
440 Some(&branch),
441 "allow",
442 EventOutcome::Success,
443 Some(&sha),
444 )
445 .await;
446 Ok(json!({
447 "repo": canonical_repo.display().to_string(),
448 "sha": sha,
449 }))
450 }
451 Err(e) => {
452 self.emit_write_audit(
453 token,
454 "git.commit",
455 &canonical_repo,
456 Some(&branch),
457 "allow",
458 EventOutcome::Error,
459 None,
460 )
461 .await;
462 Err(e)
463 }
464 }
465 }
466
467 pub(crate) async fn handle_branch(
468 &self,
469 token: &NamespaceToken,
470 params: Value,
471 ) -> Result<Value, RuntimeError> {
472 let repo = self
473 .parse_audited_repo(token, "git.branch", ¶ms)
474 .await?;
475 let lock = repo_write_lock(&repo);
476 let _guard = lock.lock().await;
477 let BranchPreflight { name, from, argv } = match prepare_branch(&repo, ¶ms) {
478 Ok(preflight) => preflight,
479 Err(failure) => {
480 return Err(self
481 .audit_early_failure(
482 token,
483 "git.branch",
484 &repo,
485 failure.branch.as_deref(),
486 failure.outcome,
487 failure.error,
488 )
489 .await)
490 }
491 };
492
493 let canonical_repo = match self.enforce_write_policy(&repo, &name) {
494 Ok(p) => p,
495 Err(e) => {
496 self.emit_write_audit(
497 token,
498 "git.branch",
499 &repo,
500 Some(&name),
501 "deny",
502 EventOutcome::Denied,
503 None,
504 )
505 .await;
506 return Err(e);
507 }
508 };
509
510 match run_git(&canonical_repo, &argv) {
511 Ok(_) => {
512 self.emit_write_audit(
513 token,
514 "git.branch",
515 &canonical_repo,
516 Some(&name),
517 "allow",
518 EventOutcome::Success,
519 None,
520 )
521 .await;
522 Ok(json!({
523 "repo": canonical_repo.display().to_string(),
524 "name": name,
525 "from": from,
526 }))
527 }
528 Err(e) => {
529 self.emit_write_audit(
530 token,
531 "git.branch",
532 &canonical_repo,
533 Some(&name),
534 "allow",
535 EventOutcome::Error,
536 None,
537 )
538 .await;
539 Err(e)
540 }
541 }
542 }
543
544 pub(crate) async fn handle_push(
545 &self,
546 token: &NamespaceToken,
547 params: Value,
548 ) -> Result<Value, RuntimeError> {
549 let repo = self.parse_audited_repo(token, "git.push", ¶ms).await?;
550 let lock = repo_write_lock(&repo);
551 let _guard = lock.lock().await;
552 let PushPreflight {
553 branch,
554 remote,
555 argv,
556 } = match prepare_push(&repo, ¶ms) {
557 Ok(preflight) => preflight,
558 Err(failure) => {
559 return Err(self
560 .audit_early_failure(
561 token,
562 "git.push",
563 &repo,
564 failure.branch.as_deref(),
565 failure.outcome,
566 failure.error,
567 )
568 .await)
569 }
570 };
571
572 let canonical_repo = match self.enforce_write_policy(&repo, &branch) {
573 Ok(p) => p,
574 Err(e) => {
575 self.emit_write_audit(
576 token,
577 "git.push",
578 &repo,
579 Some(&branch),
580 "deny",
581 EventOutcome::Denied,
582 None,
583 )
584 .await;
585 return Err(e);
586 }
587 };
588
589 match run_git(&canonical_repo, &argv) {
590 Ok(_) => {
591 self.emit_write_audit(
592 token,
593 "git.push",
594 &canonical_repo,
595 Some(&branch),
596 "allow",
597 EventOutcome::Success,
598 None,
599 )
600 .await;
601 Ok(json!({
602 "repo": canonical_repo.display().to_string(),
603 "remote": remote,
604 "branch": branch,
605 }))
606 }
607 Err(e) => {
608 self.emit_write_audit(
609 token,
610 "git.push",
611 &canonical_repo,
612 Some(&branch),
613 "allow",
614 EventOutcome::Error,
615 None,
616 )
617 .await;
618 Err(e)
619 }
620 }
621 }
622
623 #[allow(clippy::too_many_arguments)]
640 async fn emit_write_audit(
641 &self,
642 token: &NamespaceToken,
643 verb: &str,
644 repo: &Path,
645 branch: Option<&str>,
646 decision: &str,
647 outcome: EventOutcome,
648 sha: Option<&str>,
649 ) {
650 let Ok(store) = self.runtime().events(token) else {
651 return;
652 };
653 let mut payload = json!({
654 "repo": repo.display().to_string(),
655 "decision": decision,
656 });
657 if let Some(b) = branch {
658 payload["branch"] = json!(b);
659 }
660 if let Some(s) = sha {
661 payload["sha"] = json!(s);
662 }
663 let event = Event::new(
664 token.namespace().as_str(),
665 verb,
666 EventKind::Audit,
667 SubstrateKind::Event,
668 token.actor().id.clone(),
669 )
670 .with_outcome(outcome)
671 .with_payload(payload);
672 if let Err(e) = store.append_event(event).await {
673 tracing::warn!(
674 verb,
675 error = %e,
676 "git write audit event store write failed (non-fatal)"
677 );
678 }
679 }
680}