Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 76 additions & 28 deletions cmd/confine/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ use std::{
env,
ffi::OsStr,
io,
os::unix::process::{CommandExt, ExitStatusExt},
os::unix::{
fs::PermissionsExt,
process::{CommandExt, ExitStatusExt},
},
path::{Path, PathBuf},
process::{ExitCode, ExitStatus},
};

use argh::FromArgs;
use fsfork::Contents;
use fsfork::{Contents, at, message};

/// How to give the arguments, as in the man page.
const USAGE: &str = "confine [-w path]... [-r path]... [-n name] [--no-ignore] [--rm | -d dir] [command [argument ...]]";

/// Directories under the home directory that the command may write to, so
/// that builds and package managers keep working.
Expand Down Expand Up @@ -106,32 +112,36 @@ enum Ran {
}

fn main() -> ExitCode {
let args: Args = argh::from_env();
if args.rm && args.dir.is_some() {
eprintln!("confine: --rm removes a fork, and -d runs the command without one");
return ExitCode::FAILURE;
}
if args.no_ignore && args.dir.is_some() {
eprintln!(
"confine: --no-ignore is about what is in a fork, and -d runs the command without one"
);
return ExitCode::FAILURE;
}
if args.name.is_some() && args.dir.is_some() {
eprintln!("confine: --name names a fork, and -d runs the command without one");
return ExitCode::FAILURE;
let args: Args = match message::parse(USAGE, 1) {
Ok(args) => args,
Err(code) => return code,
};
if args.dir.is_some() {
let conflict = if args.rm {
Some("--rm")
} else if args.no_ignore {
Some("--no-ignore")
} else if args.name.is_some() {
Some("-n")
} else {
None
};
if let Some(option) = conflict {
let problem = format!("{option} cannot be given with -d, which makes no fork");
return message::usage_error(&problem, USAGE, 1);
}
}
let e = match confine(&args) {
Ok(Ran::Exited(status)) => return exit_as(status),
Ok(Ran::Failed(e)) => e,
Err(e) => {
eprintln!("confine: {e}");
message::say(message::describe(&e));
return ExitCode::FAILURE;
}
};
// As env(1) does: 127 if the command was not found, 126 if it could not
// be run.
eprintln!("confine: {}: {e}", args.command.first().map_or("", |c| c));
message::say(message::describe(&e));
if e.kind() == io::ErrorKind::NotFound {
ExitCode::from(127)
} else {
Expand Down Expand Up @@ -167,13 +177,15 @@ fn confine(args: &Args) -> io::Result<Ran> {
} else {
args.command.clone()
};
// Before forking, so that a command that is not there makes no fork.
if let Err(e) = find_in_path(&command[0]) {
return Ok(Ran::Failed(e));
}
if let Some(dir) = &args.dir {
let typed = dir;
let dir = dir.canonicalize().map_err(|e| at(dir, e))?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"{}: not a directory",
dir.display()
)));
return Err(at(typed, io::Error::from_raw_os_error(libc::ENOTDIR)));
}
return run(&command, &dir, &writable, &readable, &hidden, false);
}
Expand All @@ -191,7 +203,7 @@ fn confine(args: &Args) -> io::Result<Ran> {
if (args.rm || !matches!(ran, Ok(Ran::Exited(_))))
&& let Err(e) = fsfork::remove(&fork)
{
eprintln!("confine: {e}");
message::say(message::describe(&e));
}
ran
}
Expand All @@ -206,12 +218,17 @@ fn run(
hidden: &[PathBuf],
wait: bool,
) -> io::Result<Ran> {
// So that it is said to be not found by `confine`, rather than by the
// sandbox, which is what is run.
if let Err(e) = find_in(&command[0], dir) {
return Ok(Ran::Failed(e));
}
let mut confined = os::confine(command, dir, writable, readable, hidden)?;
// Claude Code would otherwise ask whether to trust every new directory,
// and accepting would write to `~/.claude.json`, which it may not.
confined.current_dir(dir).env("CLAUDE_CODE_SANDBOXED", "1");
if !wait {
return Ok(Ran::Failed(confined.exec()));
return Ok(Ran::Failed(at(Path::new(&command[0]), confined.exec())));
}
for signal in SIGNALS {
unsafe { libc::signal(signal, libc::SIG_IGN) };
Expand All @@ -227,7 +244,7 @@ fn run(
}
Ok(match confined.status() {
Ok(status) => Ran::Exited(status),
Err(e) => Ran::Failed(e),
Err(e) => Ran::Failed(at(Path::new(&command[0]), e)),
})
}

Expand All @@ -244,7 +261,38 @@ fn exit_as(status: ExitStatus) -> ExitCode {
ExitCode::from(status.code().unwrap_or(1) as u8)
}

/// `e`, saying it is about `path`.
fn at(path: &Path, e: io::Error) -> io::Error {
io::Error::new(e.kind(), format!("{}: {e}", path.display()))
/// Finds `command`, if it has no `/` in it, in `$PATH`, as a shell would.
fn find_in_path(command: &str) -> io::Result<()> {
if command.contains('/') {
return Ok(());
}
let path = env::var_os("PATH").unwrap_or_default();
let dirs: Vec<PathBuf> = env::split_paths(&path).collect();
// A relative directory is in the one the command runs in, not this one.
let found = dirs.iter().any(|dir| {
dir.is_relative()
|| dir
.join(command)
.metadata()
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
});
if found {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{command}: command not found"),
))
}
}

/// Finds `command`, if it has a `/` in it, from `dir`, where it runs.
fn find_in(command: &str, dir: &Path) -> io::Result<()> {
if !command.contains('/') {
return Ok(());
}
dir.join(command)
.metadata()
.map(|_| ())
.map_err(|e| at(Path::new(command), e))
}
2 changes: 1 addition & 1 deletion cmd/confine/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ pub fn confine(
};
if abi < MIN_ABI {
return Err(io::Error::other(
"confine needs Landlock, in Linux 5.19 or later, enabled",
"Landlock is not available; it needs Linux 5.19 or later",
));
}
let mut handled = READ | WRITE;
Expand Down
39 changes: 38 additions & 1 deletion cmd/confine/tests/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ fn name_cannot_be_given_with_d() {
let dir = world.dir("project");
assert_fails(
&world.confine(&dir, &["--name", "x", "-d", ".", "true"]),
"--name",
"-n cannot be given with -d",
);
assert_no_forks(&world);
}
Expand Down Expand Up @@ -504,3 +504,40 @@ fn assert_fails(out: &Output, message: &str) {
stderr(out)
);
}

#[test]
fn says_a_command_that_is_not_there_is_not_found_and_makes_no_fork() {
let world = World::new();
let dir = world.dir("project");
let out = world.confine(&dir, &["no-such-command"]);
assert_eq!(out.status.code(), Some(127), "{}", stderr(&out));
assert_eq!(
stderr(&out),
"confine: no-such-command: command not found\n"
);
assert_no_forks(&world);
let out = world.confine(&dir, &["-d", ".", "./missing"]);
assert_eq!(out.status.code(), Some(127), "{}", stderr(&out));
assert_eq!(
stderr(&out),
"confine: ./missing: no such file or directory\n"
);
}

#[test]
fn says_what_is_wrong_with_the_arguments_and_how_to_give_them() {
let world = World::new();
let dir = world.dir("project");
let out = world.confine(&dir, &["--rm", "-d", ".", "true"]);
let said = stderr(&out);
let mut lines = said.lines();
assert_eq!(
lines.next(),
Some("confine: --rm cannot be given with -d, which makes no fork")
);
assert!(
lines.next().unwrap().starts_with("usage: confine "),
"{said}"
);
assert_eq!(lines.next(), None);
}
74 changes: 32 additions & 42 deletions cmd/fsdiff/fsdiff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
mod git;

use std::{
env, io,
io,
path::{Path, PathBuf},
process::ExitCode,
};

use argh::FromArgs;
use fsfork::{at, message};

/// How to give the arguments, as in the man page, a line for each form.
const USAGE: &str = "fsdiff [--stat] [fork]\nfsdiff [--stat] old new";

/// Show what changed in a fork since it was made, or between two
/// directories, as a patch.
Expand All @@ -30,7 +34,7 @@ struct Args {
const TROUBLE: u8 = 2;

fn main() -> ExitCode {
let args = match parse() {
let args: Args = match message::parse(USAGE, TROUBLE) {
Ok(args) => args,
Err(code) => return code,
};
Expand All @@ -42,72 +46,58 @@ fn main() -> ExitCode {
match fsdiff(&args) {
Ok(false) => ExitCode::SUCCESS,
Ok(true) => ExitCode::from(1),
Err(e) => {
eprintln!("fsdiff: {e}");
Err(Wrong::Usage(problem)) => message::usage_error(problem, USAGE, TROUBLE),
Err(Wrong::Failed(e)) => {
message::say(message::describe(&e));
ExitCode::from(TROUBLE)
}
}
}

/// The arguments `fsdiff` was given, or the code to exit with if they were
/// not right, or asked for help.
fn parse() -> Result<Args, ExitCode> {
let strings: Vec<String> = env::args().collect();
let command = Path::new(&strings[0])
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("fsdiff");
let strs: Vec<&str> = strings[1..].iter().map(String::as_str).collect();
Args::from_args(&[command], &strs).map_err(|exit| match exit.status {
Ok(()) => {
print!("{}", exit.output);
ExitCode::SUCCESS
}
Err(()) => {
eprint!("{}", exit.output);
ExitCode::from(TROUBLE)
}
})
/// Why there is no difference to show.
enum Wrong {
/// The arguments were not right, for this reason.
Usage(&'static str),
/// Showing it failed.
Failed(io::Error),
}

impl From<io::Error> for Wrong {
fn from(e: io::Error) -> Self {
Wrong::Failed(e)
}
}

/// Shows the difference `args` ask for, and returns whether there is one.
fn fsdiff(args: &Args) -> io::Result<bool> {
fn fsdiff(args: &Args) -> Result<bool, Wrong> {
let (old, new) = match &args.paths[..] {
[old, new] => (directory(old)?, directory(new)?),
[] | [_] => {
let fork = args.paths.first().map_or(Path::new("."), PathBuf::as_path);
let typed = fork;
let fork = directory(fork)?;
let base = fsfork::base_of(&fork);
if !base.is_dir() {
return Err(io::Error::other(format!(
"{}: no base at {}; is it a fork?",
fork.display(),
base.display()
)));
let name = base.file_name().unwrap_or_default().to_string_lossy();
return Err(Wrong::Failed(io::Error::other(format!(
"{}: not a fork: no {name} next to it",
typed.display()
))));
}
(base, fork)
}
_ => {
return Err(io::Error::other(
"too many arguments: a fork, or two directories",
));
}
_ => return Err(Wrong::Usage("too many arguments")),
};
let repo = git::Repo::new()?;
let (before, after) = repo.record(&old, &new)?;
repo.diff(&before, &after, args.stat)
Ok(repo.diff(&before, &after, args.stat)?)
}

/// `path`, canonical, if it is a directory.
fn directory(path: &Path) -> io::Result<PathBuf> {
let dir = path
.canonicalize()
.map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", path.display())))?;
let dir = path.canonicalize().map_err(|e| at(path, e))?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"{}: not a directory",
dir.display()
)));
return Err(at(path, io::Error::from_raw_os_error(libc::ENOTDIR)));
}
Ok(dir)
}
10 changes: 6 additions & 4 deletions cmd/fsdiff/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use std::{
process::{self, Command, ExitStatus},
};

use fsfork::message::shown;

/// The signals from the terminal, which `fsdiff` ignores and git does not.
pub const SIGNALS: [i32; 3] = [libc::SIGINT, libc::SIGQUIT, libc::SIGHUP];

Expand Down Expand Up @@ -111,8 +113,8 @@ impl Repo {
)?;
if !added.success() {
return Err(io::Error::other(format!(
"git could not read {}",
tree.display()
"{}: git could not read it",
shown(tree).display()
)));
}
let written = self
Expand All @@ -122,8 +124,8 @@ impl Repo {
.output()?;
if !written.status.success() {
return Err(io::Error::other(format!(
"git could not record {}",
tree.display()
"{}: git could not record it",
shown(tree).display()
)));
}
Ok(String::from_utf8_lossy(&written.stdout).trim().to_string())
Expand Down
Loading
Loading