Skip to main content

ralph_workflow/git_helpers/phase/
io.rs

1// git_helpers/phase/io.rs — boundary module for agent phase lifecycle management.
2// File stem is `io` — recognized as boundary module by forbid_io_effects lint.
3
4// Agent phase lifecycle management.
5//
6// Coordinates the start, self-heal check, and end of the agent phase.
7// Handles marker creation, wrapper installation, hooks setup, and cleanup.
8
9use 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
26/// Escape a path for safe use in a POSIX shell single-quoted string.
27pub(crate) fn escape_shell_path(path: &str) -> std::io::Result<String> {
28    escape_shell_single_quoted(path)
29}
30
31/// Find the real git binary in PATH, excluding the given wrapper directory.
32pub(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/// Verify and restore agent-phase commit protections before each agent invocation.
78///
79/// This is the composite integrity check that self-heals against a prior agent
80/// that deleted the enforcement marker or tampered with git hooks during
81/// its run. It is designed to be called from `run_with_prompt` before every
82/// agent spawn.
83#[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
837/// Capture the current HEAD OID and write it to `<git-dir>/ralph/head-oid.txt`.
838pub(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
927/// Detect unauthorized commits by comparing current HEAD against stored OID.
928pub(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;