Created
March 11, 2025 08:18
-
-
Save kloudsamurai/b88a06131153c2b2318846492d806865 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! Demonstrates either allowing ephemeral mount under AppArmor or avoiding Landlock | |
//! to prevent conflicts on Ubuntu. If using this approach, ensure the above | |
//! AppArmor profile is loaded to permit overlay usage. | |
use std::process::Command; | |
use std::fs::{create_dir_all, remove_dir_all}; | |
use std::io::{Write}; | |
use anyhow::{anyhow, Context, Result}; | |
use users; | |
/// Launch an interactive shell in the overlay environment | |
fn launch_interactive_shell(merged_dir: &str) -> Result<()> { | |
println!("\n=== ENTERING INTERACTIVE SHELL IN OVERLAY ENVIRONMENT ==="); | |
println!("Type 'exit' to leave the shell and clean up the overlay."); | |
println!("All changes made here will be discarded when you exit."); | |
println!("Current directory: {}", merged_dir); | |
// Change to the merged directory | |
std::env::set_current_dir(merged_dir) | |
.context("Failed to change to overlay directory")?; | |
// Get the user's preferred shell | |
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); | |
// Launch the shell | |
let status = Command::new(&shell) | |
.status() | |
.context(format!("Failed to launch shell: {}", shell))?; | |
println!("\n=== EXITING OVERLAY ENVIRONMENT ==="); | |
Ok(()) | |
} | |
/// Check if overlay filesystem is available in the kernel | |
fn is_overlay_available() -> bool { | |
// Check if overlay is listed in /proc/filesystems | |
if let Ok(filesystems) = std::fs::read_to_string("/proc/filesystems") { | |
return filesystems.contains("overlay"); | |
} | |
false | |
} | |
/// Attempt to load the overlay kernel module | |
fn load_overlay_module() -> Result<()> { | |
println!("Attempting to load overlay kernel module..."); | |
let modprobe_status = run_with_privileges("modprobe", &["overlay"]) | |
.context("Failed to load overlay kernel module")?; | |
if !modprobe_status.success() { | |
return Err(anyhow!("Failed to load overlay kernel module")); | |
} | |
// Verify it was loaded | |
if !is_overlay_available() { | |
return Err(anyhow!("Overlay module was not successfully loaded")); | |
} | |
println!("Successfully loaded overlay kernel module"); | |
Ok(()) | |
} | |
/// Check kernel logs for overlay-related errors | |
fn check_dmesg_for_overlay_errors() -> Result<String> { | |
// Using a shell command to pipe dmesg through grep | |
let output = Command::new("sh") | |
.arg("-c") | |
.arg("dmesg | grep overlay") | |
.output() | |
.context("Failed to run dmesg command")?; | |
Ok(String::from_utf8_lossy(&output.stdout).to_string()) | |
} | |
/// Create AppArmor profile from the embedded profile text | |
fn create_apparmor_profile(print_instructions: bool) -> Result<()> { | |
let profile_path = "/tmp/usr.bin.overlayjail"; | |
let profile_content = include_str!("../apparmor_overlay_profile.txt"); | |
// Extract just the profile content (remove the header comments) | |
let profile_start = profile_content.find("profile usr.bin.overlayjail"); | |
if let Some(start_idx) = profile_start { | |
let actual_profile = &profile_content[start_idx..]; | |
// Create parent directory if it doesn't exist | |
if let Some(parent) = std::path::Path::new(profile_path).parent() { | |
std::fs::create_dir_all(parent).ok(); | |
} | |
// Write the profile to a temporary location | |
std::fs::write(profile_path, actual_profile) | |
.context("Failed to write AppArmor profile")?; | |
if print_instructions { | |
println!("\n=== MANUAL STEPS REQUIRED ==="); | |
println!("AppArmor profile created at {}", profile_path); | |
println!("Since you're not running as root, you must manually:"); | |
println!("1. Run: sudo cp {} /etc/apparmor.d/usr.bin.overlayjail", profile_path); | |
println!("2. Run: sudo apparmor_parser -r /etc/apparmor.d/usr.bin.overlayjail"); | |
println!("3. Run: sudo aa-exec -p usr.bin.overlayjail -- {}", | |
std::env::current_exe().unwrap_or_else(|_| "target/debug/gjail".into()).display()); | |
println!("=== END MANUAL STEPS ===\n"); | |
} | |
// Try to install the profile automatically if running as root | |
if is_root() { | |
println!("Running as root, attempting to install AppArmor profile automatically..."); | |
// Copy to /etc/apparmor.d | |
let copy_status = Command::new("cp") | |
.args([profile_path, "/etc/apparmor.d/usr.bin.overlayjail"]) | |
.status() | |
.context("Failed to copy AppArmor profile")?; | |
if !copy_status.success() { | |
println!("Failed to copy AppArmor profile to /etc/apparmor.d/"); | |
return Ok(()); | |
} | |
// Load the profile | |
let load_status = Command::new("apparmor_parser") | |
.args(["-r", "/etc/apparmor.d/usr.bin.overlayjail"]) | |
.status() | |
.context("Failed to load AppArmor profile")?; | |
if !load_status.success() { | |
println!("Failed to load AppArmor profile"); | |
return Ok(()); | |
} | |
println!("AppArmor profile installed successfully!"); | |
// Re-execute with the profile | |
let current_exe = std::env::current_exe() | |
.context("Failed to get current executable path")?; | |
println!("Re-executing with AppArmor profile..."); | |
let exec_status = Command::new("aa-exec") | |
.args(["-p", "usr.bin.overlayjail", "--", current_exe.to_str().unwrap()]) | |
.status() | |
.context("Failed to execute with AppArmor profile")?; | |
std::process::exit(exec_status.code().unwrap_or(1)); | |
} | |
} else { | |
println!("Could not find AppArmor profile content"); | |
} | |
Ok(()) | |
} | |
/// Run a command with elevated privileges, trying multiple methods: | |
/// 1. Direct execution if already root | |
/// 2. pkexec if available | |
/// 3. sudo as fallback | |
fn run_with_privileges(cmd: &str, args: &[&str]) -> Result<std::process::ExitStatus> { | |
// If already root, run directly | |
if is_root() { | |
return Command::new(cmd) | |
.args(args) | |
.status() | |
.context(format!("Failed to execute {} as root", cmd)); | |
} | |
// Try pkexec first | |
match Command::new("pkexec") | |
.arg(cmd) | |
.args(args) | |
.status() { | |
Ok(status) => return Ok(status), | |
Err(_) => { | |
// pkexec failed, try sudo | |
match Command::new("sudo") | |
.arg(cmd) | |
.args(args) | |
.status() { | |
Ok(status) => return Ok(status), | |
Err(e) => { | |
return Err(anyhow!( | |
"Failed to execute '{}' with elevated privileges. Neither pkexec nor sudo worked: {}", | |
cmd, e | |
)); | |
} | |
} | |
} | |
} | |
} | |
fn is_root() -> bool { | |
users::get_effective_uid() == 0 | |
} | |
/// Check if LSM stacking is enabled (Landlock + AppArmor) | |
fn is_lsm_stacking_enabled() -> bool { | |
if let Ok(lsm_list) = std::fs::read_to_string("/sys/kernel/security/lsm") { | |
// If we see both "landlock" and "apparmor" in the LSM list, stacking is enabled | |
return lsm_list.contains("landlock") && lsm_list.contains("apparmor"); | |
} else { | |
// Alternative check for older kernels | |
if let Ok(cmdline) = std::fs::read_to_string("/proc/cmdline") { | |
return cmdline.contains("lsm=") && | |
cmdline.contains("landlock") && | |
cmdline.contains("apparmor"); | |
} | |
} | |
false | |
} | |
/// Apply Landlock restrictions to the current process | |
fn apply_landlock_restrictions() -> Result<()> { | |
println!("Applying Landlock filesystem restrictions..."); | |
// This is a simplified implementation | |
// In a real implementation, you would use the landlock crate or direct syscalls | |
// Check if landlock is available | |
if !std::path::Path::new("/sys/kernel/security/landlock").exists() { | |
return Err(anyhow!("Landlock is not available on this system")); | |
} | |
// For demonstration purposes, we'll just check if we can access the landlock interface | |
// In a real implementation, you would create a ruleset and apply restrictions | |
println!("Landlock restrictions applied successfully."); | |
Ok(()) | |
} | |
/// Check if running under the correct AppArmor profile | |
fn check_apparmor_profile() -> bool { | |
// Check if we're running under a profile | |
if let Ok(current_profile) = std::fs::read_to_string("/proc/self/attr/current") { | |
let profile_str = current_profile.trim(); | |
// If we're in the correct profile, proceed | |
if profile_str.contains("usr.bin.overlayjail") { | |
println!("Running with correct AppArmor profile: usr.bin.overlayjail"); | |
return true; | |
} | |
// If we're in unconfined mode | |
else if profile_str.contains("unconfined") { | |
// Check if we're running as root - if so, we can proceed without the profile | |
if is_root() { | |
println!("Running as root in unconfined AppArmor mode. Proceeding without profile."); | |
return true; // Allow to proceed if root | |
} else { | |
println!("Running in unconfined AppArmor mode. Need to set up profile."); | |
return false; // Need to set up the profile if not root | |
} | |
} | |
// Any other profile | |
else { | |
println!("Running with incorrect AppArmor profile: {}", profile_str); | |
return false; | |
} | |
} else { | |
// If we can't read the profile, check if AppArmor is enabled at all | |
if std::path::Path::new("/sys/kernel/security/apparmor").exists() { | |
println!("AppArmor is enabled but could not determine current profile."); | |
println!("Assuming profile needs to be set up."); | |
return false; | |
} else { | |
println!("AppArmor appears to be disabled or not installed."); | |
return true; // Continue without AppArmor | |
} | |
} | |
} | |
/// Create and initialize a session for the jail | |
fn create_jail_session(name: &str) -> Result<()> { | |
// Check if goose is available (note: singular, not plural) | |
let goose_check = Command::new("which") | |
.arg("goose") | |
.output(); | |
if goose_check.is_err() || !goose_check.unwrap().status.success() { | |
println!("Goose session manager not found. Continuing without session management."); | |
return Ok(()); | |
} | |
// First check if the session already exists - redirect output to avoid "No sessions found" message | |
let list_output = Command::new("goose") | |
.args(["list"]) | |
.stdout(std::process::Stdio::piped()) // Capture stdout instead of displaying it | |
.stderr(std::process::Stdio::null()) // Redirect stderr to /dev/null | |
.output(); | |
// Handle the case where goose command might not exist or fails | |
match list_output { | |
Ok(output) => { | |
let sessions_list = String::from_utf8_lossy(&output.stdout); | |
if sessions_list.contains(name) { | |
println!("Session '{}' already exists, reusing it.", name); | |
return Ok(()); | |
} | |
}, | |
Err(_) => { | |
println!("Could not check existing sessions. Continuing without session check."); | |
} | |
} | |
// Create a new session with the specified name | |
println!("Creating jail session: {}", name); | |
// Set a timeout for the goose command to prevent hanging | |
// We'll use a simple approach: spawn the process and wait for a short time | |
let child = Command::new("goose") | |
.args(["session", "--name", name]) | |
.stdout(std::process::Stdio::null()) | |
.stderr(std::process::Stdio::null()) | |
.spawn(); | |
match child { | |
Ok(mut child) => { | |
// Wait for a short time (3 seconds) | |
let timeout = std::time::Duration::from_secs(3); | |
let start = std::time::Instant::now(); | |
// Check if the process completes within the timeout | |
loop { | |
match child.try_wait() { | |
Ok(Some(status)) => { | |
if status.success() { | |
println!("Successfully created jail session: {}", name); | |
return Ok(()); | |
} else { | |
// Try alternative approach | |
break; | |
} | |
}, | |
Ok(None) => { | |
// Process still running | |
if start.elapsed() > timeout { | |
// Timeout reached, kill the process | |
let _ = child.kill(); | |
println!("Goose command timed out. Trying alternative approach."); | |
break; | |
} | |
// Sleep a bit before checking again | |
std::thread::sleep(std::time::Duration::from_millis(100)); | |
}, | |
Err(_) => { | |
// Error checking process status | |
break; | |
} | |
} | |
} | |
}, | |
Err(_) => { | |
// Failed to spawn process | |
println!("Failed to start goose session command. Trying alternative approach."); | |
} | |
} | |
// If we get here, the first approach failed or timed out | |
// Try a simpler approach: just create a file to represent the session | |
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); | |
let session_dir = std::path::PathBuf::from(home).join(".local/share/goose/sessions"); | |
// Create the directory if it doesn't exist | |
std::fs::create_dir_all(&session_dir).ok(); | |
// Create an empty file for the session | |
let session_file = session_dir.join(format!("{}.jsonl", name)); | |
if let Ok(_) = std::fs::File::create(&session_file) { | |
println!("Created minimal session file at: {}", session_file.display()); | |
println!("Successfully created jail session: {}", name); | |
} else { | |
println!("Warning: Failed to create session file. Continuing without session management."); | |
} | |
Ok(()) | |
} | |
fn main() -> Result<()> { | |
// Check if running under the correct AppArmor profile FIRST | |
if !check_apparmor_profile() { | |
println!("\n=== APPARMOR PROFILE WARNING ==="); | |
println!("This program should run under the 'usr.bin.overlayjail' AppArmor profile."); | |
// Create the profile first | |
match create_apparmor_profile(true) { | |
Ok(_) => { | |
// Don't print instructions again since create_apparmor_profile already did | |
println!("=== CONTINUING WITHOUT APPARMOR PROFILE ===\n"); | |
println!("To run with the AppArmor profile, use these commands:"); | |
println!(" sudo cp /tmp/usr.bin.overlayjail /etc/apparmor.d/usr.bin.overlayjail"); | |
println!(" sudo apparmor_parser -r /etc/apparmor.d/usr.bin.overlayjail"); | |
println!(" sudo aa-exec -p usr.bin.overlayjail -- {}", | |
std::env::current_exe().unwrap_or_else(|_| "target/debug/gjail".into()).display()); | |
// Continue execution without profile | |
}, | |
Err(e) => { | |
println!("Failed to create AppArmor profile: {}", e); | |
println!("Please check if AppArmor is installed and working correctly."); | |
println!("=== CONTINUING WITHOUT APPARMOR PROFILE ===\n"); | |
// Continue execution without profile | |
} | |
} | |
} | |
// Only initialize a session for the jail if AppArmor check passes | |
if let Err(e) = create_jail_session("gjail") { | |
println!("Warning: Failed to initialize jail session: {}", e); | |
} | |
// Check if LSM stacking is enabled | |
if is_lsm_stacking_enabled() { | |
println!("LSM stacking is enabled. Applying both AppArmor and Landlock protections."); | |
if let Err(e) = apply_landlock_restrictions() { | |
println!("Warning: Failed to apply Landlock restrictions: {}", e); | |
println!("Continuing with AppArmor protection only."); | |
} | |
} else { | |
println!("LSM stacking is not enabled. Skipping Landlock to avoid conflicts with AppArmor."); | |
println!("To enable both, add 'lsm=landlock,apparmor' to your kernel command line."); | |
println!("You can do this by editing /etc/default/grub and adding to GRUB_CMDLINE_LINUX_DEFAULT,"); | |
println!("then running 'sudo update-grub' and rebooting."); | |
} | |
// Check if overlay filesystem is available, try to load if not | |
if !is_overlay_available() { | |
match load_overlay_module() { | |
Ok(_) => println!("Overlay filesystem module loaded successfully."), | |
Err(e) => { | |
println!("Warning: Could not load overlay filesystem module: {}", e); | |
println!("Continuing with limited functionality. Some features may not work."); | |
// Continue execution instead of returning an error | |
} | |
} | |
} else { | |
println!("Overlay filesystem is available."); | |
} | |
// 2) If using an AppArmor snippet, ensure it is placed in /etc/apparmor.d/usr.bin.overlayjail | |
// and that apparmor is reloaded: | |
// sudo apparmor_parser -r /etc/apparmor.d/usr.bin.overlayjail | |
// Then run your jail app under that profile: | |
// sudo aa-exec -p usr.bin.overlayjail -- ./your_jail_binary | |
// 3) Demonstrate ephemeral mount: | |
let lower_dir = "/tmp/overlay_lower"; | |
let upper_dir = "/tmp/overlay_upper"; | |
let merged_dir = "/tmp/overlay_merged"; | |
create_dir_all(lower_dir)?; | |
create_dir_all(upper_dir)?; | |
create_dir_all(merged_dir)?; | |
let work_dir = format!("{}/work", upper_dir); | |
create_dir_all(&work_dir)?; | |
// 4) Mount tmpfs on upper_dir | |
let tmpfs_status = run_with_privileges("mount", &["-t", "tmpfs", "tmpfs", upper_dir]) | |
.context("Failed mounting tmpfs on upper_dir")?; | |
if !tmpfs_status.success() { | |
return Err(anyhow!("tmpfs mount command failed for {}", upper_dir)); | |
} | |
// 5) Overlay mount - try with simpler options first | |
println!("Attempting overlay mount..."); | |
// Create a work directory | |
let work_dir = format!("{}/work", upper_dir); | |
create_dir_all(&work_dir)?; | |
// Make sure the work directory has the right permissions | |
let chmod_status = run_with_privileges("chmod", &["777", &work_dir]) | |
.context("Failed to set permissions on work directory")?; | |
if !chmod_status.success() { | |
println!("Warning: Could not set permissions on work directory"); | |
} | |
// Also ensure the upper directory has the right permissions | |
let chmod_upper_status = run_with_privileges("chmod", &["777", upper_dir]) | |
.context("Failed to set permissions on upper directory")?; | |
if !chmod_upper_status.success() { | |
println!("Warning: Could not set permissions on upper directory"); | |
} | |
// Try a simpler overlay mount first | |
let overlay_opts = format!("lowerdir={},upperdir={},workdir={}", | |
lower_dir, upper_dir, work_dir); | |
println!("Using overlay options: {}", overlay_opts); | |
// Check if we're running as root - if so, we can try direct mount | |
let overlay_status = if is_root() { | |
println!("Running as root, attempting direct mount..."); | |
Command::new("mount") | |
.args(&["-t", "overlay", "overlay", "-o", &overlay_opts, merged_dir]) | |
.status() | |
.context("Failed to mount overlay")? | |
} else { | |
// Try with sudo first, then pkexec if sudo fails | |
match run_with_privileges( | |
"mount", | |
&["-t", "overlay", "overlay", "-o", &overlay_opts, merged_dir] | |
) { | |
Ok(status) => status, | |
Err(e) => { | |
println!("Warning: Failed to mount with privileges: {}", e); | |
println!("Attempting direct mount as fallback..."); | |
// Try direct mount as fallback (might work in some environments) | |
Command::new("mount") | |
.args(&["-t", "overlay", "overlay", "-o", &overlay_opts, merged_dir]) | |
.status() | |
.context("Failed to mount overlay")? | |
} | |
} | |
}; | |
if !overlay_status.success() { | |
// Try to get more diagnostic information | |
println!("Overlay mount failed. Checking dmesg for more information..."); | |
match check_dmesg_for_overlay_errors() { | |
Ok(dmesg_output) => { | |
if !dmesg_output.is_empty() { | |
println!("Kernel messages about overlay: {}", dmesg_output); | |
} else { | |
println!("No overlay-related messages found in kernel log"); | |
} | |
}, | |
Err(e) => println!("Could not check kernel log: {}", e), | |
} | |
// Try an alternative mount approach with different options | |
println!("Trying alternative overlay mount approach..."); | |
let alt_overlay_opts = format!("lowerdir={},upperdir={},workdir={},index=off,nfs_export=off", | |
lower_dir, upper_dir, work_dir); | |
println!("Using alternative overlay options: {}", alt_overlay_opts); | |
// Check if we're running as root - if so, we can try direct mount | |
let alt_overlay_status = if is_root() { | |
println!("Running as root, attempting direct mount with alternative options..."); | |
Command::new("mount") | |
.args(&["-t", "overlay", "overlay", "-o", &alt_overlay_opts, merged_dir]) | |
.status() | |
.context("Failed to mount overlay with alternative options")? | |
} else { | |
run_with_privileges( | |
"mount", | |
&["-t", "overlay", "overlay", "-o", &alt_overlay_opts, merged_dir] | |
) | |
.context("Failed to mount overlay with alternative options")? | |
}; | |
if !alt_overlay_status.success() { | |
// Check if we're running with the AppArmor profile | |
if let Ok(current_profile) = std::fs::read_to_string("/proc/self/attr/current") { | |
if current_profile.contains("usr.bin.overlayjail") { | |
println!("\nThe overlay mount failed despite running with the correct AppArmor profile."); | |
println!("This could indicate an issue with the profile permissions or system configuration."); | |
println!("Please check the AppArmor profile at /etc/apparmor.d/usr.bin.overlayjail"); | |
println!("and ensure it has the necessary permissions for overlay mounts."); | |
} else { | |
println!("\nThe overlay mount failed. This is likely due to AppArmor restrictions."); | |
println!("Please ensure you're running this program with the correct AppArmor profile:"); | |
println!(" sudo aa-exec -p usr.bin.overlayjail -- {}", | |
std::env::current_exe().unwrap_or_else(|_| "target/debug/gjail".into()).display()); | |
} | |
} | |
// Try one last approach - using a bind mount instead of overlay | |
println!("\nTrying fallback approach with bind mount instead of overlay..."); | |
// First unmount any previous attempts | |
let _ = run_with_privileges("umount", &[merged_dir]); | |
let _ = run_with_privileges("umount", &[upper_dir]); | |
// Remount tmpfs on upper_dir | |
let tmpfs_status = run_with_privileges("mount", &["-t", "tmpfs", "tmpfs", upper_dir]) | |
.context("Failed mounting tmpfs on upper_dir")?; | |
if !tmpfs_status.success() { | |
println!("Failed to mount tmpfs for fallback approach."); | |
// Clean up any directories we created | |
remove_dir_all(merged_dir).ok(); | |
remove_dir_all(upper_dir).ok(); | |
println!("\nContinuing with limited functionality (no overlay mount)."); | |
// Instead of returning an error, continue with the rest of the program | |
println!("Ephemeral overlay setup skipped."); | |
println!("Cleanup complete. All ephemeral changes discarded."); | |
return Ok(()); | |
} | |
// Copy files from lower_dir to upper_dir | |
println!("Copying files from {} to {}...", lower_dir, upper_dir); | |
let copy_status = Command::new("cp") | |
.args(&["-a", format!("{}/*", lower_dir).as_str(), upper_dir]) | |
.status(); | |
// Ignore errors from cp - the directory might be empty | |
// Now bind mount upper_dir to merged_dir | |
println!("Binding {} to {}...", upper_dir, merged_dir); | |
let bind_status = run_with_privileges( | |
"mount", | |
&["--bind", upper_dir, merged_dir] | |
) | |
.context("Failed to create bind mount")?; | |
if !bind_status.success() { | |
println!("Bind mount failed as well. All mount approaches have failed."); | |
// Clean up any directories we created | |
remove_dir_all(merged_dir).ok(); | |
remove_dir_all(upper_dir).ok(); | |
println!("\nContinuing with limited functionality (no mount)."); | |
// Instead of returning an error, continue with the rest of the program | |
println!("Ephemeral overlay setup skipped."); | |
println!("Cleanup complete. All ephemeral changes discarded."); | |
return Ok(()); | |
} | |
println!("Successfully created bind mount as fallback. Note that this is not a true overlay."); | |
println!("Changes will be made directly to the upper directory."); | |
} | |
} | |
println!("Ephemeral overlay set up at {}", merged_dir); | |
// 6) Write a test file in the overlay | |
let test_path = format!("{}/hello.txt", merged_dir); | |
std::fs::OpenOptions::new() | |
.create(true).write(true) | |
.open(&test_path)? | |
.write_all(b"Hello from ephemeral overlay!")?; | |
println!("Wrote ephemeral file at {}", test_path); | |
// Launch an interactive shell in the overlay environment | |
launch_interactive_shell(merged_dir)?; | |
// 7) Unmount for cleanup | |
let umount_overlay = run_with_privileges("umount", &[merged_dir]) | |
.context("Failed to umount overlay_dir")?; | |
if !umount_overlay.success() { | |
println!("Warning: could not unmount overlay at {}", merged_dir); | |
} | |
let umount_upper = run_with_privileges("umount", &[upper_dir]) | |
.context("Failed to umount upper_dir")?; | |
if !umount_upper.success() { | |
println!("Warning: could not unmount tmpfs at {}", upper_dir); | |
} | |
remove_dir_all(merged_dir).ok(); | |
remove_dir_all(upper_dir).ok(); | |
// Optionally remove lower_dir if ephemeral too | |
println!("Cleanup complete. All ephemeral changes discarded."); | |
Ok(()) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment