#[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
);
}
}