1use super::marker;
10use super::marker::set_readonly_mode_if_not_symlink;
11use super::path_wrapper::{
12 self, is_safe_existing_dir, prepend_wrapper_dir_to_path, read_tracked_wrapper_dir,
13 track_file_path_for_ralph_dir, write_track_file_atomic,
14};
15use super::script::{escape_shell_single_quoted, make_wrapper_content};
16use crate::git_helpers::repo::{normalize_protection_scope_path, ralph_git_dir};
17use crate::logger::Logger;
18use std::env;
19use std::fs::{self, OpenOptions};
20use std::path::{Path, PathBuf};
21use which::which;
22
23const HEAD_OID_FILE_NAME: &str = "head-oid.txt";
24const WRAPPER_DIR_PREFIX: &str = "ralph-git-wrapper-";
25
26pub(crate) fn escape_shell_path(path: &str) -> std::io::Result<String> {
28 escape_shell_single_quoted(path)
29}
30
31pub(crate) fn find_real_git_excluding(exclude_dir: &Path) -> Option<PathBuf> {
33 let path_var = env::var("PATH").ok()?;
34 let wrapper_path = exclude_dir.join("git");
35 find_git_in_path(path_var, exclude_dir, &wrapper_path)
36}
37
38fn find_git_in_path(path_var: String, exclude_dir: &Path, wrapper_path: &Path) -> Option<PathBuf> {
39 path_var.split(':').find_map(|entry| {
40 if entry.is_empty() || entry == exclude_dir.to_string_lossy() {
41 return None;
42 }
43 let candidate = Path::new(entry).join("git");
44 if candidate == *wrapper_path || !candidate.exists() {
45 return None;
46 }
47 if !is_executable_git(&candidate) {
48 return None;
49 }
50 Some(candidate)
51 })
52}
53
54#[cfg(unix)]
55fn has_execute_bit(candidate: &Path) -> bool {
56 use std::os::unix::fs::PermissionsExt;
57 fs::metadata(candidate).map_or(true, |meta| {
58 let mode = meta.permissions().mode() & 0o777;
59 (mode & 0o111) != 0
60 })
61}
62
63fn is_executable_git(candidate: &Path) -> bool {
64 if !matches!(fs::metadata(candidate), Ok(meta) if meta.file_type().is_file()) {
65 return false;
66 }
67 #[cfg(unix)]
68 {
69 has_execute_bit(candidate)
70 }
71 #[cfg(not(unix))]
72 {
73 true
74 }
75}
76
77#[derive(Debug, Clone, Default)]
84pub struct ProtectionCheckResult {
85 pub tampering_detected: bool,
86 pub details: Vec<String>,
87}
88
89fn is_non_regular_file(meta: &fs::Metadata) -> bool {
90 let ft = meta.file_type();
91 !ft.is_file() || ft.is_symlink()
92}
93
94fn quarantine_path_tampered(
95 path: &Path,
96 kind: &str,
97 warn_msg: &str,
98 detail_msg: &str,
99 fail_detail: &str,
100 result: &mut ProtectionCheckResult,
101 logger: &Logger,
102) {
103 use crate::git_helpers::repo::quarantine_path_in_place;
104 logger.warn(warn_msg);
105 result.tampering_detected = true;
106 result.details.push(detail_msg.to_string());
107 if let Err(e) = quarantine_path_in_place(path, kind) {
108 logger.warn(&format!("Failed to quarantine {kind} path: {e}"));
109 result.details.push(fail_detail.to_string());
110 }
111}
112
113fn check_path_is_regular_file(
114 path: &Path,
115 kind: &str,
116 warn_msg: &str,
117 detail_msg: &str,
118 fail_detail: &str,
119 result: &mut ProtectionCheckResult,
120 logger: &Logger,
121) {
122 if let Ok(meta) = fs::symlink_metadata(path) {
123 if is_non_regular_file(&meta) {
124 quarantine_path_tampered(path, kind, warn_msg, detail_msg, fail_detail, result, logger);
125 }
126 }
127}
128
129pub(crate) fn check_marker_integrity(
130 ralph_dir: &Path,
131 _repo_root: &Path,
132 result: &mut ProtectionCheckResult,
133 logger: &Logger,
134) {
135 let marker_path = marker::marker_path_from_ralph_dir(ralph_dir);
136 check_path_is_regular_file(
137 &marker_path,
138 "marker",
139 "Enforcement marker is not a regular file — quarantining and recreating",
140 "Enforcement marker was not a regular file — quarantined",
141 "Marker path quarantine failed",
142 result,
143 logger,
144 );
145}
146
147pub(crate) fn check_track_file_integrity(
148 ralph_dir: &Path,
149 _repo_root: &Path,
150 result: &mut ProtectionCheckResult,
151 logger: &Logger,
152) {
153 let track_file_path = track_file_path_for_ralph_dir(ralph_dir);
154 check_path_is_regular_file(
155 &track_file_path,
156 "track",
157 "Git wrapper tracking path is not a regular file — quarantining",
158 "Git wrapper tracking path was not a regular file — quarantined",
159 "Wrapper tracking path quarantine failed",
160 result,
161 logger,
162 );
163}
164
165fn remove_symlink_marker(
166 marker_path: &Path,
167 result: &mut ProtectionCheckResult,
168 logger: &Logger,
169) {
170 logger.warn("Enforcement marker is a symlink — removing and recreating");
171 let _ = fs::remove_file(marker_path);
172 result.tampering_detected = true;
173 result
174 .details
175 .push("Enforcement marker was a symlink — removed".to_string());
176}
177
178fn recreate_missing_marker(
179 marker_path: &Path,
180 repo_root: &Path,
181 result: &mut ProtectionCheckResult,
182 logger: &Logger,
183) {
184 logger.warn("Enforcement marker missing — recreating");
185 if let Err(e) = marker::create_marker_in_repo_root(repo_root) {
186 logger.warn(&format!("Failed to recreate enforcement marker: {e}"));
187 } else {
188 #[cfg(unix)]
189 set_readonly_mode_if_not_symlink(marker_path, 0o444);
190 }
191 result.tampering_detected = true;
192 result
193 .details
194 .push("Enforcement marker was missing — recreated".to_string());
195}
196
197fn read_marker_symlink_state(marker_path: &Path) -> (bool, bool) {
198 let marker_meta = fs::symlink_metadata(marker_path).ok();
199 let is_symlink = marker_meta
200 .as_ref()
201 .is_some_and(|meta| meta.file_type().is_symlink());
202 let exists_as_file = marker_meta
203 .as_ref()
204 .is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
205 (is_symlink, exists_as_file)
206}
207
208pub(crate) fn check_and_repair_marker_symlink(
209 marker_path: &Path,
210 repo_root: &Path,
211 result: &mut ProtectionCheckResult,
212 logger: &Logger,
213) {
214 let (is_symlink, exists_as_file) = read_marker_symlink_state(marker_path);
215 if is_symlink {
216 remove_symlink_marker(marker_path, result, logger);
217 }
218 if !exists_as_file {
219 recreate_missing_marker(marker_path, repo_root, result, logger);
220 }
221}
222
223#[cfg(unix)]
224fn restore_marker_perms(
225 marker_path: &Path,
226 mode: u32,
227 meta: &fs::Metadata,
228 result: &mut ProtectionCheckResult,
229 logger: &Logger,
230) {
231 use std::os::unix::fs::PermissionsExt;
232 logger.warn(&format!(
233 "Enforcement marker permissions loosened ({mode:#o}) — restoring to 0o444"
234 ));
235 let mut perms = meta.permissions();
236 perms.set_mode(0o444);
237 let _ = fs::set_permissions(marker_path, perms);
238 result.tampering_detected = true;
239 result.details.push(format!(
240 "Enforcement marker permissions loosened ({mode:#o}) — restored to 0o444"
241 ));
242}
243
244#[cfg(unix)]
245fn quarantine_marker_in_place(marker_path: &Path, logger: &Logger) -> bool {
246 match crate::git_helpers::repo::quarantine_path_in_place(marker_path, "marker-perms") {
247 Ok(_) => true,
248 Err(e) => {
249 logger.warn(&format!("Failed to quarantine marker path: {e}"));
250 false
251 }
252 }
253}
254
255#[cfg(unix)]
256fn recreate_marker_after_quarantine(
257 marker_path: &Path,
258 repo_root: &Path,
259 logger: &Logger,
260) {
261 match marker::create_marker_in_repo_root(repo_root) {
262 Ok(()) => set_readonly_mode_if_not_symlink(marker_path, 0o444),
263 Err(e) => logger.warn(&format!(
264 "Failed to recreate enforcement marker after quarantine: {e}"
265 )),
266 }
267}
268
269#[cfg(unix)]
270fn quarantine_and_recreate_marker(
271 marker_path: &Path,
272 repo_root: &Path,
273 result: &mut ProtectionCheckResult,
274 logger: &Logger,
275) {
276 logger.warn("Enforcement marker is not a regular file — quarantining");
277 result.tampering_detected = true;
278 result
279 .details
280 .push("Enforcement marker was not a regular file — quarantined".to_string());
281 if quarantine_marker_in_place(marker_path, logger) {
282 recreate_marker_after_quarantine(marker_path, repo_root, logger);
283 }
284}
285
286#[cfg(unix)]
287fn check_marker_file_perms(
288 marker_path: &Path,
289 repo_root: &Path,
290 meta: &fs::Metadata,
291 result: &mut ProtectionCheckResult,
292 logger: &Logger,
293) {
294 use std::os::unix::fs::PermissionsExt;
295 if meta.is_file() {
296 let mode = meta.permissions().mode() & 0o777;
297 if mode != 0o444 {
298 restore_marker_perms(marker_path, mode, meta, result, logger);
299 }
300 } else {
301 quarantine_and_recreate_marker(marker_path, repo_root, result, logger);
302 }
303}
304
305#[cfg(unix)]
306fn check_and_repair_marker_permissions_unix(
307 marker_path: &Path,
308 repo_root: &Path,
309 result: &mut ProtectionCheckResult,
310 logger: &Logger,
311) {
312 if matches!(
313 fs::symlink_metadata(marker_path),
314 Ok(meta) if meta.file_type().is_symlink()
315 ) {
316 return;
317 }
318 if let Ok(meta) = fs::metadata(marker_path) {
319 check_marker_file_perms(marker_path, repo_root, &meta, result, logger);
320 }
321}
322
323pub(crate) fn check_and_repair_marker_permissions(
324 marker_path: &Path,
325 repo_root: &Path,
326 result: &mut ProtectionCheckResult,
327 logger: &Logger,
328) {
329 #[cfg(unix)]
330 check_and_repair_marker_permissions_unix(marker_path, repo_root, result, logger);
331}
332
333#[cfg(unix)]
334fn repair_symlink_track_file(
335 track_file_path: &Path,
336 result: &mut ProtectionCheckResult,
337 logger: &Logger,
338) {
339 logger.warn("Track file path is a symlink — refusing to chmod and attempting repair");
340 result.tampering_detected = true;
341 result
342 .details
343 .push("Track file was a symlink — refused chmod".to_string());
344 let _ = fs::remove_file(track_file_path);
345 if let Some(dir) =
346 path_wrapper::find_wrapper_dir_on_path().filter(|p| is_safe_existing_dir(p))
347 {
348 let _ = write_track_file_atomic(&std::path::PathBuf::from("."), &dir);
349 }
350}
351
352#[cfg(unix)]
353fn quarantine_dir_track_file(
354 track_file_path: &Path,
355 result: &mut ProtectionCheckResult,
356 logger: &Logger,
357) {
358 logger.warn("Track file path is a directory — quarantining");
359 result.tampering_detected = true;
360 result
361 .details
362 .push("Track file was a directory — quarantined".to_string());
363 if let Err(e) = crate::git_helpers::repo::quarantine_path_in_place(
364 track_file_path,
365 "track-perms",
366 ) {
367 logger.warn(&format!("Failed to quarantine track file path: {e}"));
368 }
369}
370
371#[cfg(unix)]
372fn restore_track_file_perms(
373 track_file_path: &Path,
374 mode: u32,
375 meta: &fs::Metadata,
376 result: &mut ProtectionCheckResult,
377 logger: &Logger,
378) {
379 use std::os::unix::fs::PermissionsExt;
380 logger.warn(&format!(
381 "Track file permissions loosened ({mode:#o}) — restoring to 0o444"
382 ));
383 let mut perms = meta.permissions();
384 perms.set_mode(0o444);
385 let _ = fs::set_permissions(track_file_path, perms);
386 result.tampering_detected = true;
387 result.details.push(format!(
388 "Track file permissions loosened ({mode:#o}) — restored to 0o444"
389 ));
390}
391
392#[cfg(unix)]
393fn check_track_file_meta(
394 track_file_path: &Path,
395 meta: &fs::Metadata,
396 result: &mut ProtectionCheckResult,
397 logger: &Logger,
398) {
399 use std::os::unix::fs::PermissionsExt;
400 if meta.is_dir() {
401 quarantine_dir_track_file(track_file_path, result, logger);
402 }
403 if meta.is_file() {
404 let mode = meta.permissions().mode() & 0o777;
405 if mode != 0o444 {
406 restore_track_file_perms(track_file_path, mode, meta, result, logger);
407 }
408 }
409}
410
411pub(crate) fn check_track_file_permissions(
412 track_file_path: &Path,
413 result: &mut ProtectionCheckResult,
414 logger: &Logger,
415) {
416 #[cfg(unix)]
417 {
418 if matches!(
419 fs::symlink_metadata(track_file_path),
420 Ok(m) if m.file_type().is_symlink()
421 ) {
422 repair_symlink_track_file(track_file_path, result, logger);
423 } else if let Ok(meta) = fs::metadata(track_file_path) {
424 check_track_file_meta(track_file_path, &meta, result, logger);
425 }
426 }
427}
428
429fn restore_tracking_file(
430 repo_root: &Path,
431 dir: &Path,
432 result: &mut ProtectionCheckResult,
433 logger: &Logger,
434) {
435 logger.warn("Git wrapper tracking file missing or invalid — restoring");
436 result.tampering_detected = true;
437 result
438 .details
439 .push("Git wrapper tracking file missing or invalid — restored".to_string());
440 if let Err(e) = write_track_file_atomic(repo_root, dir) {
441 logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
442 }
443}
444
445fn check_wrapper_needs_restore(wrapper_path: &Path) -> bool {
446 fs::read_to_string(wrapper_path).map_or(true, |content| {
447 !content.contains("RALPH_AGENT_PHASE_GIT_WRAPPER")
448 || !content.contains("unset GIT_EXEC_PATH")
449 })
450}
451
452fn escape_git_path(real_git_str: &str, logger: &Logger) -> Option<String> {
453 match escape_shell_path(real_git_str) {
454 Ok(s) => Some(s),
455 Err(_) => {
456 logger.warn("Failed to generate safe wrapper script (git path)");
457 None
458 }
459 }
460}
461
462fn path_to_escaped_str(path: &Path, label: &str, logger: &Logger) -> Option<String> {
463 let s = path.to_str().or_else(|| {
464 logger.warn(&format!("{label} is not valid UTF-8; cannot restore wrapper"));
465 None
466 })?;
467 match escape_shell_path(s) {
468 Ok(escaped) => Some(escaped),
469 Err(_) => {
470 logger.warn(&format!("Failed to generate safe wrapper script ({label})"));
471 None
472 }
473 }
474}
475
476fn escape_wrapper_paths(
477 real_git_str: &str,
478 marker_path: &Path,
479 track_file_path: &Path,
480 logger: &Logger,
481) -> Option<(String, String, String)> {
482 let git_path_escaped = escape_git_path(real_git_str, logger)?;
483 let marker_escaped = path_to_escaped_str(marker_path, "marker path", logger)?;
484 let track_escaped = path_to_escaped_str(track_file_path, "track file path", logger)?;
485 Some((git_path_escaped, marker_escaped, track_escaped))
486}
487
488fn resolve_scope_escaped_paths(
489 repo_root: &Path,
490 logger: &Logger,
491) -> Option<(String, String)> {
492 let scope = crate::git_helpers::repo::resolve_protection_scope_from(repo_root).ok()?;
493 let repo_root_escaped = path_to_escaped_str(
494 &normalize_protection_scope_path(&scope.repo_root),
495 "repo root",
496 logger,
497 )?;
498 let git_dir_escaped = path_to_escaped_str(
499 &normalize_protection_scope_path(&scope.git_dir),
500 "git dir",
501 logger,
502 )?;
503 Some((repo_root_escaped, git_dir_escaped))
504}
505
506fn write_wrapper_script(
507 wrapper_dir: &Path,
508 wrapper_path: &Path,
509 wrapper_content: &str,
510 logger: &Logger,
511) {
512 let tmp_path = make_wrapper_tmp_path(wrapper_dir);
513 match open_wrapper_tmp(&tmp_path, wrapper_content) {
514 Ok(()) => {
515 #[cfg(unix)]
516 set_wrapper_permissions(&tmp_path, 0o555);
517 #[cfg(windows)]
518 set_wrapper_permissions_windows(&tmp_path);
519 if let Err(e) = fs::rename(&tmp_path, wrapper_path) {
520 let _ = fs::remove_file(&tmp_path);
521 logger.warn(&format!("Failed to restore wrapper script: {e}"));
522 }
523 }
524 Err(e) => {
525 logger.warn(&format!("Failed to write wrapper temp file: {e}"));
526 }
527 }
528}
529
530fn build_and_write_wrapper(
531 repo_root: &Path,
532 wrapper_dir: &Path,
533 wrapper_path: &Path,
534 real_git_path: &Path,
535 marker_path: &Path,
536 track_file_path: &Path,
537 logger: &Logger,
538) {
539 let Some(real_git_str) = real_git_path.to_str() else {
540 logger.warn("Resolved git binary path is not valid UTF-8; cannot restore wrapper");
541 return;
542 };
543 let Some((git_path_escaped, marker_escaped, track_escaped)) =
544 escape_wrapper_paths(real_git_str, marker_path, track_file_path, logger)
545 else {
546 return;
547 };
548 let Some((repo_root_escaped, git_dir_escaped)) =
549 resolve_scope_escaped_paths(repo_root, logger)
550 else {
551 return;
552 };
553 let wrapper_content = make_wrapper_content(
554 &git_path_escaped,
555 &marker_escaped,
556 &track_escaped,
557 &repo_root_escaped,
558 &git_dir_escaped,
559 );
560 write_wrapper_script(wrapper_dir, wrapper_path, &wrapper_content, logger);
561 if real_git_path == wrapper_path {
562 logger.warn(
563 "Resolved git binary points to wrapper; wrapper restore may be incomplete",
564 );
565 }
566}
567
568fn restore_wrapper_script(
569 repo_root: &Path,
570 wrapper_dir: &Path,
571 marker_path: &Path,
572 track_file_path: &Path,
573 result: &mut ProtectionCheckResult,
574 logger: &Logger,
575) {
576 logger.warn("Git wrapper script missing or tampered — restoring");
577 result.tampering_detected = true;
578 result
579 .details
580 .push("Git wrapper script missing or tampered — restored".to_string());
581 let real_git = find_real_git_excluding(wrapper_dir).or_else(|| which("git").ok());
582 match real_git {
583 Some(real_git_path) => {
584 let wrapper_path = wrapper_dir.join("git");
585 build_and_write_wrapper(
586 repo_root,
587 wrapper_dir,
588 &wrapper_path,
589 &real_git_path,
590 marker_path,
591 track_file_path,
592 logger,
593 );
594 }
595 None => {
596 logger.warn("Failed to resolve real git binary; cannot restore wrapper");
597 }
598 }
599}
600
601#[cfg(unix)]
602fn repair_wrapper_permissions(
603 wrapper_path: &Path,
604 mode: u32,
605 meta: &fs::Metadata,
606 result: &mut ProtectionCheckResult,
607 logger: &Logger,
608) {
609 use std::os::unix::fs::PermissionsExt;
610 logger.warn(&format!(
611 "Git wrapper permissions loosened ({mode:#o}) — restoring to 0o555"
612 ));
613 let mut perms = meta.permissions();
614 perms.set_mode(0o555);
615 let _ = fs::set_permissions(wrapper_path, perms);
616 result.tampering_detected = true;
617 result.details.push(format!(
618 "Git wrapper permissions loosened ({mode:#o}) — restored to 0o555"
619 ));
620}
621
622#[cfg(unix)]
623fn check_wrapper_permissions(
624 wrapper_path: &Path,
625 result: &mut ProtectionCheckResult,
626 logger: &Logger,
627) {
628 use std::os::unix::fs::PermissionsExt;
629 if let Ok(meta) = fs::metadata(wrapper_path) {
630 let mode = meta.permissions().mode() & 0o777;
631 if mode != 0o555 {
632 repair_wrapper_permissions(wrapper_path, mode, &meta, result, logger);
633 }
634 }
635}
636
637fn create_fresh_wrapper_dir(logger: &Logger) -> Option<PathBuf> {
638 match tempfile::Builder::new()
639 .prefix(WRAPPER_DIR_PREFIX)
640 .tempdir()
641 {
642 Ok(d) => Some(d.keep()),
643 Err(e) => {
644 logger.warn(&format!("Failed to create wrapper dir: {e}"));
645 None
646 }
647 }
648}
649
650fn write_wrapper_to_dir(
651 repo_root: &Path,
652 wrapper_dir: &Path,
653 marker_path: &Path,
654 track_file_path: &Path,
655 logger: &Logger,
656) {
657 let real_git = find_real_git_excluding(wrapper_dir).or_else(|| which("git").ok());
658 let Some(real_git_path) = real_git else {
659 return;
660 };
661 let wrapper_path = wrapper_dir.join("git");
662 build_and_write_wrapper(
663 repo_root,
664 wrapper_dir,
665 &wrapper_path,
666 &real_git_path,
667 marker_path,
668 track_file_path,
669 logger,
670 );
671}
672
673fn install_fresh_wrapper(
674 repo_root: &Path,
675 marker_path: &Path,
676 track_file_path: &Path,
677 result: &mut ProtectionCheckResult,
678 logger: &Logger,
679) {
680 logger.warn("Git wrapper missing — reinstalling");
681 result.tampering_detected = true;
682 result
683 .details
684 .push("Git wrapper missing before agent spawn — reinstalling".to_string());
685 let Some(wrapper_dir) = create_fresh_wrapper_dir(logger) else {
686 return;
687 };
688 prepend_wrapper_dir_to_path(&wrapper_dir);
689 write_wrapper_to_dir(repo_root, &wrapper_dir, marker_path, track_file_path, logger);
690 if let Err(e) = write_track_file_atomic(repo_root, &wrapper_dir) {
691 logger.warn(&format!("Failed to write wrapper tracking file: {e}"));
692 }
693}
694
695fn check_or_restore_existing_wrapper(
696 repo_root: &Path,
697 wrapper_dir: &Path,
698 marker_path: &Path,
699 track_file_path: &Path,
700 result: &mut ProtectionCheckResult,
701 logger: &Logger,
702) {
703 let wrapper_path = wrapper_dir.join("git");
704 if check_wrapper_needs_restore(&wrapper_path) {
705 restore_wrapper_script(
706 repo_root,
707 wrapper_dir,
708 marker_path,
709 track_file_path,
710 result,
711 logger,
712 );
713 }
714 #[cfg(unix)]
715 check_wrapper_permissions(&wrapper_path, result, logger);
716}
717
718fn maybe_restore_tracking_file(
719 repo_root: &Path,
720 tracked_wrapper_dir: &Option<PathBuf>,
721 wrapper_dir: &Option<PathBuf>,
722 result: &mut ProtectionCheckResult,
723 logger: &Logger,
724) {
725 if tracked_wrapper_dir.is_none() {
726 if let Some(ref dir) = wrapper_dir {
727 restore_tracking_file(repo_root, dir, result, logger);
728 }
729 }
730}
731
732fn dispatch_wrapper_check_or_install(
733 repo_root: &Path,
734 marker_path: &Path,
735 track_file_path: &Path,
736 wrapper_dir: &Option<PathBuf>,
737 result: &mut ProtectionCheckResult,
738 logger: &Logger,
739) {
740 match wrapper_dir {
741 Some(ref dir) => {
742 check_or_restore_existing_wrapper(
743 repo_root,
744 dir,
745 marker_path,
746 track_file_path,
747 result,
748 logger,
749 );
750 }
751 None => {
752 install_fresh_wrapper(repo_root, marker_path, track_file_path, result, logger);
753 }
754 }
755}
756
757pub(crate) fn check_and_install_wrapper(
758 repo_root: &Path,
759 ralph_dir: &Path,
760 marker_path: &Path,
761 track_file_path: &Path,
762 result: &mut ProtectionCheckResult,
763 logger: &Logger,
764) {
765 let tracked_wrapper_dir = read_tracked_wrapper_dir(ralph_dir);
766 let path_wrapper_dir =
767 path_wrapper::find_wrapper_dir_on_path().filter(|p| is_safe_existing_dir(p));
768 let wrapper_dir = tracked_wrapper_dir.clone().or(path_wrapper_dir);
769 if let Some(ref dir) = wrapper_dir {
770 prepend_wrapper_dir_to_path(dir);
771 }
772 maybe_restore_tracking_file(repo_root, &tracked_wrapper_dir, &wrapper_dir, result, logger);
773 dispatch_wrapper_check_or_install(repo_root, marker_path, track_file_path, &wrapper_dir, result, logger);
774}
775
776#[cfg(unix)]
777fn set_wrapper_permissions(path: &Path, mode: u32) {
778 use std::os::unix::fs::PermissionsExt;
779 if let Ok(meta) = fs::metadata(path) {
780 let mut perms = meta.permissions();
781 perms.set_mode(mode);
782 let _ = fs::set_permissions(path, perms);
783 }
784}
785
786#[cfg(windows)]
787fn set_wrapper_permissions_windows(path: &Path) {
788 if let Ok(meta) = fs::metadata(path) {
789 let mut perms = meta.permissions();
790 perms.set_readonly(true);
791 let _ = fs::set_permissions(path, perms);
792 if path.exists() {
793 let _ = fs::remove_file(path);
794 }
795 }
796}
797
798fn make_wrapper_tmp_path(wrapper_dir: &Path) -> PathBuf {
799 wrapper_dir.join(format!(
800 ".git-wrapper.tmp.{}.{}",
801 std::process::id(),
802 std::time::SystemTime::now()
803 .duration_since(std::time::UNIX_EPOCH)
804 .unwrap_or_default()
805 .as_nanos()
806 ))
807}
808
809fn open_wrapper_tmp(tmp_path: &Path, content: &str) -> std::io::Result<()> {
810 let open_tmp = {
811 #[cfg(unix)]
812 {
813 use std::os::unix::fs::OpenOptionsExt;
814 OpenOptions::new()
815 .write(true)
816 .create_new(true)
817 .custom_flags(libc::O_NOFOLLOW)
818 .open(tmp_path)
819 }
820 #[cfg(not(unix))]
821 {
822 OpenOptions::new()
823 .write(true)
824 .create_new(true)
825 .open(tmp_path)
826 }
827 };
828
829 open_tmp.and_then(|mut f| {
830 std::io::Write::write_all(&mut f, content.as_bytes())?;
831 std::io::Write::flush(&mut f)?;
832 let _ = f.sync_all();
833 Ok(())
834 })
835}
836
837pub(crate) fn capture_head_oid(repo_root: &Path) {
839 let Ok(head_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
840 return;
841 };
842 let _ = write_head_oid_file_atomic(repo_root, head_oid.trim());
843}
844
845fn make_head_oid_tmp_path(ralph_dir: &Path) -> PathBuf {
846 ralph_dir.join(format!(
847 ".head-oid.tmp.{}.{}",
848 std::process::id(),
849 std::time::SystemTime::now()
850 .duration_since(std::time::UNIX_EPOCH)
851 .unwrap_or_default()
852 .as_nanos()
853 ))
854}
855
856fn write_head_oid_to_tmp(tmp_path: &Path, oid: &str) -> std::io::Result<()> {
857 let mut tf = OpenOptions::new()
858 .write(true)
859 .create_new(true)
860 .open(tmp_path)?;
861 std::io::Write::write_all(&mut tf, oid.as_bytes())?;
862 std::io::Write::write_all(&mut tf, b"\n")?;
863 std::io::Write::flush(&mut tf)?;
864 let _ = tf.sync_all();
865 Ok(())
866}
867
868fn set_head_oid_tmp_readonly(tmp_path: &Path) -> std::io::Result<()> {
869 #[cfg(unix)]
870 set_readonly_mode_if_not_symlink(tmp_path, 0o444);
871 #[cfg(windows)]
872 {
873 let mut perms = fs::metadata(tmp_path)?.permissions();
874 perms.set_readonly(true);
875 fs::set_permissions(tmp_path, perms)?;
876 }
877 Ok(())
878}
879
880fn guard_head_oid_not_symlink(head_oid_path: &Path) -> std::io::Result<()> {
881 if matches!(
882 fs::symlink_metadata(head_oid_path),
883 Ok(m) if m.file_type().is_symlink()
884 ) {
885 Err(std::io::Error::new(
886 std::io::ErrorKind::InvalidData,
887 "head-oid path is a symlink; refusing to write baseline",
888 ))
889 } else {
890 Ok(())
891 }
892}
893
894fn write_head_oid_file_atomic(repo_root: &Path, oid: &str) -> std::io::Result<()> {
895 let ralph_dir = crate::git_helpers::repo::ensure_ralph_git_dir(repo_root)?;
896 let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
897 guard_head_oid_not_symlink(&head_oid_path)?;
898 let tmp_path = make_head_oid_tmp_path(&ralph_dir);
899 write_head_oid_to_tmp(&tmp_path, oid)?;
900 set_head_oid_tmp_readonly(&tmp_path)?;
901 #[cfg(windows)]
902 {
903 if head_oid_path.exists() {
904 let _ = fs::remove_file(&head_oid_path);
905 }
906 }
907 fs::rename(&tmp_path, &head_oid_path)
908}
909
910fn is_head_oid_symlink(head_oid_path: &Path) -> bool {
911 matches!(
912 fs::symlink_metadata(head_oid_path),
913 Ok(m) if m.file_type().is_symlink()
914 )
915}
916
917fn read_stored_oid(head_oid_path: &Path) -> Option<String> {
918 let stored = fs::read_to_string(head_oid_path).ok()?;
919 let trimmed = stored.trim().to_string();
920 if trimmed.is_empty() {
921 None
922 } else {
923 Some(trimmed)
924 }
925}
926
927pub(crate) fn detect_unauthorized_commit(repo_root: &Path) -> bool {
929 let head_oid_path = ralph_git_dir(repo_root).join(HEAD_OID_FILE_NAME);
930 if is_head_oid_symlink(&head_oid_path) {
931 return false;
932 }
933 let Some(stored_oid) = read_stored_oid(&head_oid_path) else {
934 return false;
935 };
936 let Ok(current_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
937 return false;
938 };
939 current_oid.trim() != stored_oid
940}
941
942pub(crate) const HEAD_OID_FILENAME: &str = HEAD_OID_FILE_NAME;