Skip to main content

ralph_workflow/git_helpers/wrapper/
io.rs

1// git_helpers/wrapper/io.rs — boundary module for git wrapper management.
2// File stem is `io` — recognized as boundary module by forbid_io_effects lint.
3
4// reason = "autogenerated"
5
6pub use super::phase::ProtectionCheckResult;
7
8use super::cleanup;
9use super::cleanup::cleanup_agent_phase_at;
10use super::marker;
11use super::marker::marker_path_from_ralph_dir;
12use super::marker::{
13    add_owner_write_if_not_symlink, repair_marker_if_tampered, set_readonly_mode_if_not_symlink,
14};
15use super::path_wrapper;
16use super::path_wrapper::remove_wrapper_dir_and_entry;
17use super::path_wrapper::TRACK_FILENAME;
18use super::phase;
19use super::phase::{
20    check_and_install_wrapper, check_marker_integrity, check_track_file_integrity,
21    HEAD_OID_FILENAME,
22};
23use super::phase_state::{AGENT_PHASE_HOOKS_DIR, AGENT_PHASE_RALPH_DIR, AGENT_PHASE_REPO_ROOT};
24use super::script::{escape_shell_single_quoted, make_wrapper_content};
25use crate::git_helpers::install::{HOOK_MARKER, RALPH_HOOK_NAMES};
26use crate::git_helpers::repo::{
27    get_hooks_dir_from, get_repo_root, ralph_git_dir, resolve_protection_scope,
28    resolve_protection_scope_from,
29};
30use crate::git_helpers::verify::{enforce_hook_permissions, reinstall_hooks_if_tampered};
31use crate::logger::Logger;
32use crate::workspace::Workspace;
33use std::env;
34use std::fs::{self, OpenOptions};
35use std::path::{Path, PathBuf};
36use which::which;
37
38mod io {
39    pub type Result<T> = std::io::Result<T>;
40    pub type Error = std::io::Error;
41    pub type ErrorKind = std::io::ErrorKind;
42}
43
44const WRAPPER_DIR_PREFIX: &str = "ralph-git-wrapper-";
45
46pub struct GitHelpers {
47    real_git: Option<PathBuf>,
48    wrapper_dir: Option<PathBuf>,
49    wrapper_repo_root: Option<PathBuf>,
50}
51
52impl GitHelpers {
53    pub(crate) const fn new() -> Self {
54        Self {
55            real_git: None,
56            wrapper_dir: None,
57            wrapper_repo_root: None,
58        }
59    }
60
61    fn init_real_git(&mut self) {
62        if self.real_git.is_none() {
63            self.real_git = which("git").ok();
64        }
65    }
66}
67
68impl Default for GitHelpers {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
75    let removed_wrapper_dir = helpers.wrapper_dir.take();
76
77    if let Some(wrapper_dir_path) = removed_wrapper_dir.as_ref() {
78        remove_wrapper_dir_and_entry(wrapper_dir_path);
79    }
80
81    let repo_root = helpers
82        .wrapper_repo_root
83        .take()
84        .or_else(|| get_repo_root().ok());
85
86    let track_file = resolve_track_file_path(&repo_root);
87
88    cleanup_wrapper_track_file(&track_file, &removed_wrapper_dir);
89}
90
91fn resolve_track_file_path(repo_root: &Option<PathBuf>) -> PathBuf {
92    repo_root.as_ref().map_or_else(
93        || PathBuf::from(".git/ralph").join(TRACK_FILENAME),
94        |r| ralph_git_dir(r).join(TRACK_FILENAME),
95    )
96}
97
98fn cleanup_wrapper_track_file(track_file: &Path, removed_wrapper_dir: &Option<PathBuf>) {
99    if let Ok(content) = fs::read_to_string(track_file) {
100        let wrapper_dir = PathBuf::from(content.trim());
101        let same_as_removed = removed_wrapper_dir
102            .as_ref()
103            .is_some_and(|p| p == &wrapper_dir);
104        if !same_as_removed {
105            remove_wrapper_dir_and_entry(&wrapper_dir);
106        }
107    }
108
109    #[cfg(unix)]
110    add_owner_write_if_not_symlink(track_file);
111    let _ = fs::remove_file(track_file);
112}
113
114pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
115    let repo_root = get_repo_root()?;
116    start_agent_phase_in_repo(&repo_root, helpers)
117}
118
119fn store_hooks_dir_if_resolvable(repo_root: &Path) {
120    if let Ok(hooks_dir) = get_hooks_dir_from(repo_root) {
121        if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
122            *guard = Some(hooks_dir);
123        }
124    }
125}
126
127fn store_agent_phase_paths(repo_root: &Path, ralph_dir: &Path) {
128    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
129        *guard = Some(repo_root.to_path_buf());
130    }
131    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
132        *guard = Some(ralph_dir.to_path_buf());
133    }
134    store_hooks_dir_if_resolvable(repo_root);
135}
136
137pub fn start_agent_phase_in_repo(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
138    helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
139
140    let ralph_dir = ralph_git_dir(repo_root);
141    store_agent_phase_paths(repo_root, &ralph_dir);
142
143    repair_marker_if_tampered(repo_root)?;
144    #[cfg(unix)]
145    set_readonly_mode_if_not_symlink(&marker_path_from_ralph_dir(&ralph_dir), 0o444);
146    crate::git_helpers::install::install_hooks_in_repo(repo_root)?;
147    enable_git_wrapper_at(repo_root, helpers)?;
148
149    phase::capture_head_oid(repo_root);
150    Ok(())
151}
152
153pub fn end_agent_phase() {
154    if let Ok(repo_root) = get_repo_root() {
155        end_agent_phase_in_repo(&repo_root);
156    }
157}
158
159pub fn end_agent_phase_in_repo(repo_root: &Path) {
160    let ralph_dir = ralph_git_dir(repo_root);
161    end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
162}
163
164pub fn clear_agent_phase_global_state() {
165    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
166        *guard = None;
167    }
168    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
169        *guard = None;
170    }
171    if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
172        *guard = None;
173    }
174}
175
176fn end_agent_phase_in_repo_at_ralph_dir(repo_root: &Path, ralph_dir: &Path) {
177    marker::remove_legacy_marker(repo_root);
178
179    let ralph_dir_ok =
180        crate::git_helpers::repo::sanitize_ralph_git_dir_at(ralph_dir).unwrap_or(false);
181
182    let marker_path = marker_path_from_ralph_dir(ralph_dir);
183    #[cfg(unix)]
184    add_owner_write_if_not_symlink(&marker_path);
185    let _ = fs::remove_file(&marker_path);
186
187    if ralph_dir_ok {
188        remove_head_oid_file(ralph_dir);
189        path_wrapper::cleanup_stray_tmp_files(ralph_dir);
190        let _ = fs::remove_dir(ralph_dir);
191    }
192}
193
194fn remove_head_oid_file(ralph_dir: &Path) {
195    let head_oid_path = ralph_dir.join(HEAD_OID_FILENAME);
196    if fs::symlink_metadata(&head_oid_path).is_err() {
197        return;
198    }
199    #[cfg(unix)]
200    add_owner_write_if_not_symlink(&head_oid_path);
201    let _ = fs::remove_file(&head_oid_path);
202}
203
204fn resolve_wrapper_dir(ralph_dir: &Path) -> Option<PathBuf> {
205    let tracked = path_wrapper::read_tracked_wrapper_dir(ralph_dir);
206    let on_path =
207        path_wrapper::find_wrapper_dir_on_path().filter(|p| path_wrapper::is_safe_existing_dir(p));
208    tracked.or(on_path)
209}
210
211fn do_restore_wrapper_tracking_file(
212    repo_root: &Path,
213    dir: &Path,
214    result: &mut ProtectionCheckResult,
215    logger: &Logger,
216) {
217    logger.warn("Git wrapper tracking file missing or invalid — restoring");
218    result.tampering_detected = true;
219    result.details.push("Git wrapper tracking file missing or invalid — restored".to_string());
220    if let Err(e) = path_wrapper::write_track_file_atomic(repo_root, dir) {
221        logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
222    }
223}
224
225fn restore_wrapper_tracking_file_if_missing(
226    ralph_dir: &Path,
227    repo_root: &Path,
228    wrapper_dir: &Option<PathBuf>,
229    result: &mut ProtectionCheckResult,
230    logger: &Logger,
231) {
232    if path_wrapper::read_tracked_wrapper_dir(ralph_dir).is_none() {
233        if let Some(ref dir) = wrapper_dir {
234            do_restore_wrapper_tracking_file(repo_root, dir, result, logger);
235        }
236    }
237}
238
239fn check_hooks_present(repo_root: &Path) -> bool {
240    get_hooks_dir_from(repo_root)
241        .ok()
242        .is_some_and(|hooks_dir| {
243            RALPH_HOOK_NAMES.iter().any(|name| {
244                let path = hooks_dir.join(name);
245                path.exists()
246                    && matches!(
247                        crate::files::file_contains_marker(&path, HOOK_MARKER),
248                        Ok(true)
249                    )
250            })
251        })
252}
253
254fn check_marker_present(marker_path: &Path) -> bool {
255    fs::symlink_metadata(marker_path)
256        .ok()
257        .is_some_and(|m| m.file_type().is_file() && !m.file_type().is_symlink())
258}
259
260fn flag_missing_protections_if_needed(
261    marker_path: &Path,
262    repo_root: &Path,
263    result: &mut ProtectionCheckResult,
264    logger: &Logger,
265) {
266    if !check_marker_present(marker_path) && !check_hooks_present(repo_root) {
267        logger.warn("Agent-phase git protections missing — reinstalling");
268        result.tampering_detected = true;
269        result
270            .details
271            .push("Marker and hooks missing before agent spawn — reinstalling".to_string());
272    }
273}
274
275fn handle_reinstall_result(
276    reinstall: io::Result<bool>,
277    result: &mut ProtectionCheckResult,
278    logger: &Logger,
279) {
280    match reinstall {
281        Ok(true) => {
282            result.tampering_detected = true;
283            result
284                .details
285                .push("Git hooks tampered with or missing — reinstalled".to_string());
286        }
287        Err(e) => {
288            logger.warn(&format!("Failed to verify/reinstall hooks: {e}"));
289        }
290        Ok(false) => {}
291    }
292}
293
294fn check_unauthorized_commit(repo_root: &Path, result: &mut ProtectionCheckResult, logger: &Logger) {
295    if phase::detect_unauthorized_commit(repo_root) {
296        logger.warn("CRITICAL: HEAD OID changed — unauthorized commit detected!");
297        result.tampering_detected = true;
298        result
299            .details
300            .push("HEAD OID changed since last check — unauthorized commit detected".to_string());
301        phase::capture_head_oid(repo_root);
302    }
303}
304
305pub fn ensure_agent_phase_protections(logger: &Logger) -> ProtectionCheckResult {
306    let mut result = ProtectionCheckResult::default();
307
308    let Ok(scope) = resolve_protection_scope() else {
309        return result;
310    };
311    let repo_root = scope.repo_root.clone();
312    let ralph_dir = scope.ralph_dir.clone();
313    let marker_path = marker_path_from_ralph_dir(&ralph_dir);
314    let track_file_path = path_wrapper::track_file_path_for_ralph_dir(&ralph_dir);
315
316    check_marker_integrity(&ralph_dir, &repo_root, &mut result, logger);
317    check_track_file_integrity(&ralph_dir, &repo_root, &mut result, logger);
318
319    let wrapper_dir = resolve_wrapper_dir(&ralph_dir);
320    if let Some(ref dir) = wrapper_dir {
321        path_wrapper::prepend_wrapper_dir_to_path(dir);
322    }
323    restore_wrapper_tracking_file_if_missing(&ralph_dir, &repo_root, &wrapper_dir, &mut result, logger);
324
325    check_and_install_wrapper(
326        &repo_root,
327        &ralph_dir,
328        &marker_path,
329        &track_file_path,
330        &mut result,
331        logger,
332    );
333
334    flag_missing_protections_if_needed(&marker_path, &repo_root, &mut result, logger);
335
336    phase::check_and_repair_marker_symlink(&marker_path, &repo_root, &mut result, logger);
337    phase::check_and_repair_marker_permissions(&marker_path, &repo_root, &mut result, logger);
338
339    handle_reinstall_result(reinstall_hooks_if_tampered(logger), &mut result, logger);
340
341    #[cfg(unix)]
342    enforce_hook_permissions(&repo_root, logger);
343
344    phase::check_track_file_permissions(&track_file_path, &mut result, logger);
345    check_unauthorized_commit(&repo_root, &mut result, logger);
346
347    result
348}
349
350pub fn cleanup_orphaned_wrapper_at(repo_root: &Path) {
351    cleanup::cleanup_prior_wrapper(repo_root);
352}
353
354pub fn cleanup_agent_phase_silent() {
355    let repo_root = AGENT_PHASE_REPO_ROOT
356        .try_lock()
357        .ok()
358        .and_then(|guard| guard.clone())
359        .or_else(|| get_repo_root().ok());
360
361    let Some(repo_root) = repo_root else {
362        return;
363    };
364
365    let stored_ralph_dir = AGENT_PHASE_RALPH_DIR
366        .try_lock()
367        .ok()
368        .and_then(|guard| guard.clone());
369    let stored_hooks_dir = AGENT_PHASE_HOOKS_DIR
370        .try_lock()
371        .ok()
372        .and_then(|guard| guard.clone());
373
374    cleanup_agent_phase_at(
375        &repo_root,
376        stored_ralph_dir.as_deref(),
377        stored_hooks_dir.as_deref(),
378    );
379}
380
381pub fn cleanup_agent_phase_silent_at(repo_root: &Path) {
382    cleanup_agent_phase_at(repo_root, None, None);
383}
384
385pub fn cleanup_agent_phase_protections_silent_at(repo_root: &Path) {
386    cleanup_agent_phase_at(repo_root, None, None);
387}
388
389#[cfg(any(test, feature = "test-utils"))]
390pub fn set_agent_phase_paths_for_test(
391    repo_root: Option<PathBuf>,
392    ralph_dir: Option<PathBuf>,
393    hooks_dir: Option<PathBuf>,
394) {
395    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
396        *guard = repo_root;
397    }
398    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
399        *guard = ralph_dir;
400    }
401    if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
402        *guard = hooks_dir;
403    }
404}
405
406#[cfg(any(test, feature = "test-utils"))]
407#[must_use]
408pub fn get_agent_phase_paths_for_test() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
409    let repo_root = AGENT_PHASE_REPO_ROOT
410        .lock()
411        .ok()
412        .and_then(|guard| guard.clone());
413    let ralph_dir = AGENT_PHASE_RALPH_DIR
414        .lock()
415        .ok()
416        .and_then(|guard| guard.clone());
417    let hooks_dir = AGENT_PHASE_HOOKS_DIR
418        .lock()
419        .ok()
420        .and_then(|guard| guard.clone());
421    (repo_root, ralph_dir, hooks_dir)
422}
423
424pub fn capture_head_oid(repo_root: &Path) {
425    phase::capture_head_oid(repo_root)
426}
427
428pub fn detect_unauthorized_commit(repo_root: &Path) -> bool {
429    phase::detect_unauthorized_commit(repo_root)
430}
431
432pub fn try_remove_ralph_dir(repo_root: &Path) -> bool {
433    cleanup::remove_ralph_dir(repo_root)
434}
435
436pub fn verify_ralph_dir_removed(repo_root: &Path) -> Vec<String> {
437    cleanup::verify_ralph_dir_removed(repo_root)
438}
439
440pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
441    cleanup::cleanup_orphaned_marker(logger)
442}
443
444pub fn verify_wrapper_cleaned(repo_root: &Path) -> Vec<String> {
445    cleanup::verify_wrapper_cleaned(repo_root)
446}
447
448pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
449    workspace.exists(Path::new(".git/ralph/no_agent_commit"))
450}
451
452pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
453    workspace.write(Path::new(".git/ralph/no_agent_commit"), "")
454}
455
456pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
457    workspace.remove_if_exists(Path::new(".git/ralph/no_agent_commit"))
458}
459
460pub fn cleanup_orphaned_marker_with_workspace(
461    workspace: &dyn Workspace,
462    logger: &Logger,
463) -> io::Result<()> {
464    let (removed_marker, removed_legacy_marker) = detect_and_remove_orphaned_markers(workspace)?;
465
466    if removed_marker || removed_legacy_marker {
467        logger.success("Removed orphaned enforcement marker");
468    } else {
469        logger.info("No orphaned marker found");
470    }
471    Ok(())
472}
473
474fn detect_and_remove_orphaned_markers(workspace: &dyn Workspace) -> io::Result<(bool, bool)> {
475    let marker_path = Path::new(".git/ralph/no_agent_commit");
476    let legacy_marker_path = Path::new(".no_agent_commit");
477
478    let removed_marker = if workspace.exists(marker_path) {
479        workspace.remove(marker_path)?;
480        true
481    } else {
482        false
483    };
484
485    let removed_legacy_marker = if workspace.exists(legacy_marker_path) {
486        workspace.remove(legacy_marker_path)?;
487        true
488    } else {
489        false
490    };
491
492    Ok((removed_marker, removed_legacy_marker))
493}
494
495fn validate_git_binary(real_git: &Path, git_path_str: &str) -> io::Result<()> {
496    if !real_git.is_absolute() {
497        return Err(io::Error::new(
498            io::ErrorKind::InvalidInput,
499            format!(
500                "git binary path is not absolute: '{git_path_str}'. \
501                 Using absolute paths prevents potential security issues."
502            ),
503        ));
504    }
505    if !real_git.exists() {
506        return Err(io::Error::new(
507            io::ErrorKind::NotFound,
508            format!("git binary does not exist at path: '{git_path_str}'"),
509        ));
510    }
511    #[cfg(unix)]
512    validate_git_binary_unix(real_git, git_path_str)?;
513    Ok(())
514}
515
516#[cfg(unix)]
517fn validate_git_binary_unix(real_git: &Path, git_path_str: &str) -> io::Result<()> {
518    match fs::metadata(real_git) {
519        Ok(metadata) if metadata.file_type().is_dir() => Err(io::Error::new(
520            io::ErrorKind::InvalidInput,
521            format!("git binary path is a directory, not a file: '{git_path_str}'"),
522        )),
523        Ok(_) => Ok(()),
524        Err(_) => Err(io::Error::new(
525            io::ErrorKind::PermissionDenied,
526            format!("cannot access git binary metadata at path: '{git_path_str}'"),
527        )),
528    }
529}
530
531fn path_to_escaped_str(path: &Path, label: &str) -> io::Result<String> {
532    let s = path.to_str().ok_or_else(|| {
533        io::Error::new(
534            io::ErrorKind::InvalidData,
535            format!("{label} contains invalid UTF-8 characters; cannot create wrapper script"),
536        )
537    })?;
538    escape_shell_single_quoted(s)
539}
540
541fn build_wrapper_escaped_args(
542    scope: &crate::git_helpers::repo::ProtectionScope,
543) -> io::Result<(String, String, String, String)> {
544    let normalized_repo_root =
545        crate::git_helpers::repo::normalize_protection_scope_path(&scope.repo_root);
546    let normalized_git_dir =
547        crate::git_helpers::repo::normalize_protection_scope_path(&scope.git_dir);
548    let ralph_dir = &scope.ralph_dir;
549    let marker_path = marker_path_from_ralph_dir(ralph_dir);
550    let track_file_path = path_wrapper::track_file_path_for_ralph_dir(ralph_dir);
551
552    let marker_escaped = path_to_escaped_str(&marker_path, "marker path")?;
553    let track_escaped = path_to_escaped_str(&track_file_path, "track file path")?;
554    let repo_root_escaped = path_to_escaped_str(&normalized_repo_root, "repo root")?;
555    let git_dir_escaped = path_to_escaped_str(&normalized_git_dir, "git dir")?;
556    Ok((marker_escaped, track_escaped, repo_root_escaped, git_dir_escaped))
557}
558
559fn write_wrapper_script(wrapper_path: &Path, content: &str) -> io::Result<()> {
560    let mut file = OpenOptions::new()
561        .write(true)
562        .create_new(true)
563        .open(wrapper_path)?;
564    std::io::Write::write_all(&mut file, content.as_bytes())?;
565    #[cfg(unix)]
566    {
567        use std::os::unix::fs::PermissionsExt;
568        let mut perms = fs::metadata(wrapper_path)?.permissions();
569        perms.set_mode(0o555);
570        fs::set_permissions(wrapper_path, perms)?;
571    }
572    Ok(())
573}
574
575fn enable_git_wrapper_at(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
576    cleanup::cleanup_prior_wrapper(repo_root);
577
578    helpers.init_real_git();
579    let Some(real_git) = helpers.real_git.as_ref() else {
580        return Ok(());
581    };
582
583    let git_path_str = real_git.to_str().ok_or_else(|| {
584        io::Error::new(
585            io::ErrorKind::InvalidData,
586            "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
587        )
588    })?;
589    validate_git_binary(real_git, git_path_str)?;
590
591    let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
592
593    helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
594
595    let scope = resolve_protection_scope_from(repo_root)?;
596    let (marker_escaped, track_escaped, repo_root_escaped, git_dir_escaped) =
597        build_wrapper_escaped_args(&scope)?;
598
599    let wrapper_content = make_wrapper_content(
600        &git_path_escaped,
601        &marker_escaped,
602        &track_escaped,
603        &repo_root_escaped,
604        &git_dir_escaped,
605    );
606
607    let wrapper_dir = tempfile::Builder::new()
608        .prefix(WRAPPER_DIR_PREFIX)
609        .tempdir()?;
610    let wrapper_dir_path = wrapper_dir.keep();
611    let wrapper_path = wrapper_dir_path.join("git");
612    write_wrapper_script(&wrapper_path, &wrapper_content)?;
613
614    let current_path = env::var("PATH").unwrap_or_default();
615    env::set_var(
616        "PATH",
617        format!("{}:{}", wrapper_dir_path.display(), current_path),
618    );
619
620    path_wrapper::write_track_file_atomic(repo_root, &wrapper_dir_path)?;
621
622    helpers.wrapper_dir = Some(wrapper_dir_path);
623    Ok(())
624}