Skip to content

Commit 79316c5

Browse files
committed
complete sandbox
1 parent dddc115 commit 79316c5

5 files changed

Lines changed: 70 additions & 24 deletions

File tree

src/commands/decompress.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ pub struct DecompressOptions<'a> {
2828
/// Example: [Gz, Tar] (notice it's ordered in decompression order)
2929
pub formats: Vec<Extension>,
3030
pub output_dir: &'a Path,
31-
/// Used when extracting single file formats and not archive formats
32-
pub output_file_path: PathBuf,
3331
/// Whether the user passed `--dir` explicitly. When false (and `here` is false),
3432
/// archives are extracted into a basename-derived subdirectory of the CWD.
3533
pub output_dir_was_explicit: bool,
@@ -81,8 +79,7 @@ pub fn prepare_decompress_target(
8179
output_file_path.to_path_buf()
8280
};
8381
let is_cwd = target == *INITIAL_CURRENT_DIR;
84-
let is_valid =
85-
is_cwd || !target.fs_err_try_exists()? || (target.is_dir() && target.read_dir()?.next().is_none());
82+
let is_valid = is_cwd || !target.fs_err_try_exists()? || (target.is_dir() && target.read_dir()?.next().is_none());
8683

8784
let resolved = if is_valid {
8885
target
@@ -155,7 +152,11 @@ pub fn decompress_file(options: DecompressOptions) -> Result<()> {
155152
let reader = create_decoder_up_to_first_extension()?;
156153
let mut reader = chain_reader_decoder(&first_extension, reader)?;
157154

158-
let PreparedTarget::NonArchive { file: mut writer, path: final_output_path } = options.prepared else {
155+
let PreparedTarget::NonArchive {
156+
file: mut writer,
157+
path: final_output_path,
158+
} = options.prepared
159+
else {
159160
return Ok(());
160161
};
161162

@@ -225,9 +226,7 @@ pub fn decompress_file(options: DecompressOptions) -> Result<()> {
225226
};
226227
let unpack_fn: Box<dyn FnOnce(&Path) -> Result<u64>> = if options.formats.len() > 1 || input_is_stdin {
227228
// unrar's C API needs a path; spill the decoded stream into the wrapper dir.
228-
let mut temp_file = tempfile::Builder::new()
229-
.prefix(".ouch-rar-")
230-
.tempfile_in(&dir)?;
229+
let mut temp_file = tempfile::Builder::new().prefix(".ouch-rar-").tempfile_in(&dir)?;
231230
io::copy(&mut create_decoder_up_to_first_extension()?, &mut temp_file)?;
232231
Box::new(move |output_dir| {
233232
crate::archive::rar::unpack_archive(temp_file.path(), output_dir, options.password)

src/commands/list.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ pub fn list_archive_contents(
2222
list_options: ListOptions,
2323
question_policy: QuestionPolicy,
2424
password: Option<&[u8]>,
25+
// Pre-opened tempfile FD for multi-format RAR list under the sandbox.
26+
rar_spill_tempfile: Option<tempfile::NamedTempFile>,
2527
) -> Result<()> {
2628
let reader = fs::File::open(archive_path)?;
2729

@@ -100,7 +102,8 @@ pub fn list_archive_contents(
100102
#[cfg(feature = "unrar")]
101103
Rar => {
102104
if formats.len() > 1 {
103-
let mut temp_file = tempfile::NamedTempFile::new()?;
105+
// Reuse the tempfile pre-opened by the caller if there is one.
106+
let mut temp_file = rar_spill_tempfile.unwrap_or(tempfile::NamedTempFile::new()?);
104107
io::copy(&mut reader, &mut temp_file)?;
105108
Box::new(crate::archive::rar::list_archive(temp_file.path(), password)?)
106109
} else {

src/commands/mod.rs

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ mod compress;
44
mod decompress;
55
mod list;
66

7+
use std::path::PathBuf;
8+
79
use bstr::ByteSlice;
810
use decompress::{DecompressOptions, PreparedTarget, prepare_decompress_target};
911
use rayon::prelude::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
@@ -23,6 +25,7 @@ use crate::{
2325
self, BytesFmt, FileVisibilityPolicy, NoQuotePathFmt, PathFmt, QuestionAction, canonicalize, colors::*,
2426
file_size, is_path_stdin,
2527
},
28+
warning,
2629
};
2730

2831
/// Warn the user that (de)compressing this .zip archive might freeze their system.
@@ -132,7 +135,10 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
132135
} else if compress_result.is_err() {
133136
// Sandbox blocks unlinking the partial file; tell the user to delete it.
134137
eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
135-
eprintln!(" Compression did not finish; the partial file at {} may be corrupted.", PathFmt(&output_path));
138+
eprintln!(
139+
" Compression did not finish; the partial file at {} may be corrupted.",
140+
PathFmt(&output_path)
141+
);
136142
eprintln!(" Please delete it manually.");
137143
eprintln!(" Compression failed for reasons below.");
138144
}
@@ -232,21 +238,29 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
232238
for p in &prepared {
233239
match p {
234240
PreparedTarget::Archive { dir } => {
235-
policy.allow_read_write(sandbox::canonicalize_for_sandbox(dir));
241+
let canon = sandbox::canonicalize_for_sandbox(dir);
242+
// The sandbox cannot confine writes when the target is inside $HOME.
243+
if (output_dir_was_explicit || here) && sandbox::is_home_or_ancestor(&canon) {
244+
warning!(
245+
"Sandbox: extraction target {} is $HOME or an ancestor of it; the sandbox cannot meaningfully confine writes",
246+
PathFmt(&canon)
247+
);
248+
}
249+
policy.allow_read_write(canon);
236250
}
237251
// Non-archive: held FD covers writes, no path grant needed.
238252
PreparedTarget::NonArchive { .. } | PreparedTarget::Cancelled => {}
239253
}
240254
}
241-
// TODO: warn when that parent is $HOME or an ancestor.
242255
if remove {
243256
for f in &files {
244257
if is_path_stdin(f) {
245258
continue;
246259
}
247260
let canon = sandbox::canonicalize_for_sandbox(f);
248261
if let Some(parent) = canon.parent() {
249-
policy.allow_read_write(parent.to_path_buf());
262+
// The decompressor can delete the input archive but not write nearby.
263+
policy.allow_remove_in(parent.to_path_buf());
250264
}
251265
}
252266
}
@@ -256,14 +270,12 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
256270
files
257271
.par_iter()
258272
.zip(files_extensions)
259-
.zip(output_file_paths)
260273
.zip(prepared)
261-
.try_for_each(|(((input_path, formats), output_file_path), prepared)| {
274+
.try_for_each(|((input_path, formats), prepared)| {
262275
decompress_file(DecompressOptions {
263276
input_file_path: input_path,
264277
formats,
265278
output_dir: &output_dir,
266-
output_file_path,
267279
output_dir_was_explicit,
268280
here,
269281
question_policy,
@@ -312,14 +324,27 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
312324
// Ensure we were not told to list the content of a non-archive compressed file
313325
check::check_for_non_archive_formats(&files, &formats)?;
314326

327+
// Flatten the per-file formats up front so the next step can inspect them.
328+
let flat_formats: Vec<Vec<extension::CompressionFormat>> = formats
329+
.iter()
330+
.map(|f| extension::flatten_compression_formats(f))
331+
.collect();
332+
// Open RAR spill tempfiles up front so unrar can read from them under the sandbox.
333+
let mut rar_spill_tempfiles: Vec<Option<tempfile::NamedTempFile>> = flat_formats
334+
.iter()
335+
.map(|fs| {
336+
if fs.len() > 1 && matches!(fs.last(), Some(extension::CompressionFormat::Rar)) {
337+
tempfile::NamedTempFile::new().ok()
338+
} else {
339+
None
340+
}
341+
})
342+
.collect();
343+
315344
{
316345
let mut policy = SandboxPolicy::new();
317346
for f in &files {
318-
let canon = sandbox::canonicalize_for_sandbox(f);
319-
policy.allow_read(canon.clone());
320-
if let Some(parent) = canon.parent() {
321-
policy.allow_read(parent.to_path_buf());
322-
}
347+
policy.allow_read(sandbox::canonicalize_for_sandbox(f));
323348
}
324349
policy.set_disabled(args.no_sandbox).apply();
325350
}
@@ -329,11 +354,15 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
329354
quiet: args.quiet,
330355
};
331356

332-
for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
357+
for (i, ((archive_path, formats), spill)) in files
358+
.iter()
359+
.zip(flat_formats)
360+
.zip(rar_spill_tempfiles.iter_mut())
361+
.enumerate()
362+
{
333363
if i > 0 && !args.quiet {
334364
println!();
335365
}
336-
let formats = extension::flatten_compression_formats(&formats);
337366
list_archive_contents(
338367
archive_path,
339368
formats,
@@ -342,6 +371,7 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
342371
args.password
343372
.as_deref()
344373
.map(|str| <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed")),
374+
spill.take(),
345375
)?;
346376
}
347377

src/sandbox/landlock.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub fn apply(policy: &SandboxPolicy) {
3333
fn install(policy: &SandboxPolicy) -> Result<RulesetStatus, RulesetError> {
3434
let read_access = AccessFs::from_read(TARGET_ABI);
3535
let all_access = AccessFs::from_all(TARGET_ABI);
36+
// REMOVE_FILE + REFER lets `--remove` delete the input archive but nothing else.
37+
let remove_access: BitFlags<AccessFs> = AccessFs::RemoveFile | AccessFs::Refer;
3638

3739
let ruleset = Ruleset::default()
3840
.set_compatibility(CompatLevel::BestEffort)
@@ -41,6 +43,7 @@ fn install(policy: &SandboxPolicy) -> Result<RulesetStatus, RulesetError> {
4143

4244
let ruleset = add_rules(ruleset, policy.read_paths(), read_access)?;
4345
let ruleset = add_rules(ruleset, policy.read_write_paths(), all_access)?;
46+
let ruleset = add_rules(ruleset, policy.remove_in_paths(), remove_access)?;
4447

4548
Ok(ruleset.restrict_self()?.ruleset)
4649
}

src/sandbox/mod.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ mod landlock;
99
pub struct SandboxPolicy {
1010
read: Vec<PathBuf>,
1111
read_write: Vec<PathBuf>,
12+
/// Directories where the decompressor can delete the input archive but not write nearby.
13+
remove_in: Vec<PathBuf>,
1214
disabled: bool,
1315
}
1416

@@ -27,6 +29,12 @@ impl SandboxPolicy {
2729
self
2830
}
2931

32+
/// Allow deleting files inside `path` without granting any other write rights.
33+
pub fn allow_remove_in<P: Into<PathBuf>>(&mut self, path: P) -> &mut Self {
34+
self.remove_in.push(path.into());
35+
self
36+
}
37+
3038
pub fn set_disabled(&mut self, disabled: bool) -> &mut Self {
3139
self.disabled = disabled;
3240
self
@@ -40,6 +48,10 @@ impl SandboxPolicy {
4048
&self.read_write
4149
}
4250

51+
pub fn remove_in_paths(&self) -> &[PathBuf] {
52+
&self.remove_in
53+
}
54+
4355
/// Apply the policy. Failures emit a warning and let the process run
4456
/// unrestricted.
4557
pub fn apply(&self) {
@@ -66,7 +78,6 @@ pub fn canonicalize_for_sandbox(path: &Path) -> PathBuf {
6678
}
6779

6880
/// Returns true if `target` is $HOME itself or an ancestor of it.
69-
#[allow(dead_code)]
7081
pub fn is_home_or_ancestor(target: &Path) -> bool {
7182
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
7283
return false;

0 commit comments

Comments
 (0)