magi-code 0.62.0

Repository-aware CLI coding agent for terminal work
Documentation
#[cfg(unix)]
use crate::sessions::{apply_repair_plan, discover_repair_plan};
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::path::Path;
#[cfg(unix)]
#[derive(Clone, Copy)]
struct TerminalState {
    stdin_is_tty: bool,
    stderr_is_tty: bool,
}

pub(crate) fn run(root: &Path, yes: bool, dry_run: bool) -> anyhow::Result<()> {
    #[cfg(not(unix))]
    {
        let _ = (root, yes, dry_run);
        anyhow::bail!(
            "session permission repair is unsupported on this platform; no permissions changed"
        )
    }
    #[cfg(unix)]
    {
        let mut stdout = io::stdout().lock();
        let mut stderr = io::stderr().lock();
        run_with_io(
            root,
            yes,
            dry_run,
            &mut io::stdin().lock(),
            &mut stdout,
            &mut stderr,
            TerminalState {
                stdin_is_tty: io::stdin().is_terminal(),
                stderr_is_tty: io::stderr().is_terminal(),
            },
        )
    }
}

#[cfg(unix)]
fn run_with_io<R: BufRead, W: Write, E: Write>(
    root: &Path,
    yes: bool,
    dry_run: bool,
    input: &mut R,
    stdout: &mut W,
    stderr: &mut E,
    terminal: TerminalState,
) -> anyhow::Result<()> {
    use std::os::unix::fs::MetadataExt;
    let metadata = match fs::symlink_metadata(root) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == io::ErrorKind::NotFound => {
            writeln!(stdout, "No session root exists; nothing to repair.")?;
            return Ok(());
        }
        Err(error) => return Err(error.into()),
    };
    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
        anyhow::bail!(
            "session root is not a non-symlink directory: {}",
            root.display()
        );
    }
    if metadata.uid() != unsafe { libc::geteuid() } {
        anyhow::bail!("session root owner is not current user: {}", root.display());
    }
    if metadata.mode() & 0o077 == 0 {
        writeln!(
            stdout,
            "Session permissions already secure; nothing to repair."
        )?;
        return Ok(());
    }
    let plan = discover_repair_plan(root).map_err(|_| {
        anyhow::anyhow!(
            "session layout is unsafe, unreadable, or unrecognized; inspect {} manually",
            root.display()
        )
    })?;
    let count = plan.target_count();
    if dry_run {
        writeln!(
            stdout,
            "Would repair permissions for {count} recognized session objects under {}.",
            root.display()
        )?;
        return Ok(());
    }
    if !yes && (!terminal.stdin_is_tty || !terminal.stderr_is_tty) {
        anyhow::bail!("interactive confirmation requires TTYs; use --yes or --dry-run")
    }
    if !yes {
        writeln!(
            stdout,
            "Found {count} recognized session objects under {}.",
            root.display()
        )?;
        write!(stderr, "Repair these permissions? [y/N] ")?;
        stderr.flush()?;
        let mut answer = String::new();
        input.take(4096).read_line(&mut answer)?;
        if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
            writeln!(stdout, "Cancelled; no permissions changed.")?;
            return Ok(());
        }
    }
    match apply_repair_plan(&plan) {
        Ok(changed) => {
            writeln!(
                stdout,
                "Repaired permissions for {changed} recognized session objects."
            )?;
            Ok(())
        }
        Err(error) => Err(error),
    }
}

#[cfg(test)]
mod clap_tests {
    use clap::Parser;

    #[test]
    fn command_flags_are_represented_by_clap() {
        let args = crate::cli::CliArgs::parse_from([
            "magi-code",
            "sessions",
            "repair-permissions",
            "--yes",
        ]);
        assert!(matches!(
            args.command,
            Some(crate::cli::CliCommand::Sessions { .. })
        ));
    }
}

#[cfg(all(test, unix))]
mod tests {
    use super::{TerminalState, run_with_io};
    use std::io::Cursor;
    use std::os::unix::fs::MetadataExt;
    use tempfile::TempDir;

    fn broad_root() -> TempDir {
        use std::os::unix::fs::PermissionsExt;
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("sessions");
        std::fs::create_dir(&root).unwrap();
        std::fs::write(root.join("id.jsonl"), b"session").unwrap();
        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
        std::fs::set_permissions(
            root.join("id.jsonl"),
            std::fs::Permissions::from_mode(0o644),
        )
        .unwrap();
        temp
    }

    #[test]
    fn prompt_accepts_yes_and_reads_one_line() {
        let temp = broad_root();
        let mut input = Cursor::new(b"yes\nignored".to_vec());
        let mut stdout = Vec::new();
        let mut stderr = Vec::new();
        run_with_io(
            &temp.path().join("sessions"),
            false,
            false,
            &mut input,
            &mut stdout,
            &mut stderr,
            TerminalState {
                stdin_is_tty: true,
                stderr_is_tty: true,
            },
        )
        .unwrap();
        assert_eq!(
            std::fs::symlink_metadata(temp.path().join("sessions/id.jsonl"))
                .unwrap()
                .mode()
                & 0o777,
            0o600
        );
        assert!(String::from_utf8(stderr).unwrap().contains("[y/N]"));
    }

    #[test]
    fn prompt_decline_and_eof_do_not_mutate() {
        for response in [b"no\n".as_slice(), b"".as_slice()] {
            let temp = broad_root();
            let mut input = Cursor::new(response.to_vec());
            let mut stdout = Vec::new();
            let mut stderr = Vec::new();
            run_with_io(
                &temp.path().join("sessions"),
                false,
                false,
                &mut input,
                &mut stdout,
                &mut stderr,
                TerminalState {
                    stdin_is_tty: true,
                    stderr_is_tty: true,
                },
            )
            .unwrap();
            assert_eq!(
                std::fs::symlink_metadata(temp.path().join("sessions/id.jsonl"))
                    .unwrap()
                    .mode()
                    & 0o777,
                0o644
            );
        }
    }

    #[test]
    fn default_mode_rejects_non_tty() {
        let temp = broad_root();
        let mut input = Cursor::new(b"yes\n".to_vec());
        let mut stdout = Vec::new();
        let mut stderr = Vec::new();
        let error = run_with_io(
            &temp.path().join("sessions"),
            false,
            false,
            &mut input,
            &mut stdout,
            &mut stderr,
            TerminalState {
                stdin_is_tty: false,
                stderr_is_tty: false,
            },
        )
        .unwrap_err();
        assert!(error.to_string().contains("--yes or --dry-run"));
    }

    fn unreadable_root() -> TempDir {
        use std::os::unix::fs::PermissionsExt;
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("sessions");
        std::fs::create_dir(&root).unwrap();
        let file = root.join("id.jsonl");
        std::fs::write(&file, b"session").unwrap();
        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o044)).unwrap();
        temp
    }

    #[test]
    fn unreadable_entry_fails_closed_before_prompt_or_apply() {
        for (yes, dry_run, terminal) in [
            (
                false,
                false,
                TerminalState {
                    stdin_is_tty: true,
                    stderr_is_tty: true,
                },
            ),
            (
                false,
                true,
                TerminalState {
                    stdin_is_tty: false,
                    stderr_is_tty: false,
                },
            ),
            (
                true,
                false,
                TerminalState {
                    stdin_is_tty: false,
                    stderr_is_tty: false,
                },
            ),
        ] {
            let temp = unreadable_root();
            let root = temp.path().join("sessions");
            let file = root.join("id.jsonl");
            let mut input = Cursor::new(b"yes\n".to_vec());
            let mut stdout = Vec::new();
            let mut stderr = Vec::new();
            let error = run_with_io(
                &root,
                yes,
                dry_run,
                &mut input,
                &mut stdout,
                &mut stderr,
                terminal,
            )
            .unwrap_err();
            assert!(
                error
                    .to_string()
                    .contains("unsafe, unreadable, or unrecognized")
            );
            assert!(!String::from_utf8(stderr).unwrap().contains("[y/N]"));
            assert_eq!(
                std::fs::symlink_metadata(&root).unwrap().mode() & 0o777,
                0o755
            );
            assert_eq!(
                std::fs::symlink_metadata(file).unwrap().mode() & 0o777,
                0o044
            );
        }
    }
    #[test]
    fn yes_applies_without_prompt_and_dry_run_does_not_mutate() {
        let temp = broad_root();
        let root = temp.path().join("sessions");
        let mut input = Cursor::new(Vec::new());
        let mut stdout = Vec::new();
        let mut stderr = Vec::new();
        run_with_io(
            &root,
            true,
            false,
            &mut input,
            &mut stdout,
            &mut stderr,
            TerminalState {
                stdin_is_tty: false,
                stderr_is_tty: false,
            },
        )
        .unwrap();
        assert!(stderr.is_empty());
        assert_eq!(
            std::fs::symlink_metadata(root.join("id.jsonl"))
                .unwrap()
                .mode()
                & 0o777,
            0o600
        );

        let temp = broad_root();
        let root = temp.path().join("sessions");
        let mut stdout = Vec::new();
        let mut stderr = Vec::new();
        run_with_io(
            &root,
            false,
            true,
            &mut input,
            &mut stdout,
            &mut stderr,
            TerminalState {
                stdin_is_tty: false,
                stderr_is_tty: false,
            },
        )
        .unwrap();
        assert!(stderr.is_empty());
        assert_eq!(
            std::fs::symlink_metadata(root.join("id.jsonl"))
                .unwrap()
                .mode()
                & 0o777,
            0o644
        );
    }
}