From b7809eabeec3be09e459ea3c5561f1d84261b15e Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Fri, 26 Dec 2025 17:46:47 +0000 Subject: [PATCH] Add SMACK support for id, ls, mkdir, mkfifo, mknod --- .github/workflows/GnuTests.yml | 74 ++++++++++- Cargo.toml | 10 ++ src/uu/id/Cargo.toml | 1 + src/uu/id/src/id.rs | 53 ++++++-- src/uu/ls/Cargo.toml | 1 + src/uu/ls/src/ls.rs | 35 ++++++ src/uu/mkdir/Cargo.toml | 1 + src/uu/mkdir/locales/en-US.ftl | 1 + src/uu/mkdir/src/mkdir.rs | 11 ++ src/uu/mkfifo/Cargo.toml | 1 + src/uu/mkfifo/locales/en-US.ftl | 1 + src/uu/mkfifo/src/mkfifo.rs | 17 +++ src/uu/mknod/Cargo.toml | 1 + src/uu/mknod/locales/en-US.ftl | 1 + src/uu/mknod/src/mknod.rs | 15 +++ src/uucore/Cargo.toml | 1 + src/uucore/locales/en-US.ftl | 6 + src/uucore/src/lib/features.rs | 2 + src/uucore/src/lib/features/fsext.rs | 4 +- src/uucore/src/lib/features/smack.rs | 111 +++++++++++++++++ src/uucore/src/lib/lib.rs | 3 + util/build-test-rootfs.sh | 48 ++++++++ util/build-test-smack.sh | 176 +++++++++++++++++++++++++++ util/run-gnu-test-rootfs.sh | 60 +++++++++ util/run-gnu-test-smack.sh | 74 +++++++++++ util/run-gnu-tests-smack-ci.sh | 138 +++++++++++++++++++++ 26 files changed, 833 insertions(+), 13 deletions(-) create mode 100644 src/uucore/src/lib/features/smack.rs create mode 100755 util/build-test-rootfs.sh create mode 100755 util/build-test-smack.sh create mode 100755 util/run-gnu-test-rootfs.sh create mode 100755 util/run-gnu-test-smack.sh create mode 100755 util/run-gnu-tests-smack-ci.sh diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index dd11f926f0a..3f7f111c67c 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -2,7 +2,7 @@ name: GnuTests # spell-checker:ignore (abbrev/names) CodeCov gnulib GnuTests Swatinem # spell-checker:ignore (jargon) submodules devel -# spell-checker:ignore (libs/utils) autopoint chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt texinfo valgrind libattr libcap taiki-e +# spell-checker:ignore (libs/utils) autopoint chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt texinfo valgrind libattr libcap taiki-e zstd cpio # spell-checker:ignore (options) Ccodegen Coverflow Cpanic Zpanic # spell-checker:ignore (people) Dawid Dziurla * dawidd dtolnay # spell-checker:ignore (vars) FILESET SUBDIRS XPASS @@ -31,6 +31,8 @@ env: TEST_STTY_FULL_SUMMARY_FILE: 'gnu-stty-full-result.json' TEST_SELINUX_FULL_SUMMARY_FILE: 'selinux-gnu-full-result.json' TEST_SELINUX_ROOT_FULL_SUMMARY_FILE: 'selinux-root-gnu-full-result.json' + TEST_SMACK_FULL_SUMMARY_FILE: 'smack-gnu-full-result.json' + TEST_SMACK_ROOT_FULL_SUMMARY_FILE: 'smack-root-gnu-full-result.json' jobs: native: @@ -318,8 +320,59 @@ jobs: gnu/tests-selinux/*.log gnu/tests-selinux/*/*.log.gz + smack: + name: Run GNU tests (SMACK) + runs-on: ubuntu-24.04 + steps: + - name: Checkout code (uutils) + uses: actions/checkout@v6 + with: + path: 'uutils' + persist-credentials: false + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "./uutils -> target" + - name: Checkout code (GNU coreutils) + run: (mkdir -p gnu && cd gnu && bash ../uutils/util/fetch-gnu.sh) + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y qemu-system-x86 zstd cpio + - name: Run GNU SMACK tests + run: | + cd uutils + bash util/run-gnu-tests-smack-ci.sh "$GITHUB_WORKSPACE/gnu" "$GITHUB_WORKSPACE/gnu/tests-smack" + - name: Extract testing info into JSON + run: | + python3 uutils/util/gnu-json-result.py gnu/tests-smack > ${{ env.TEST_SMACK_FULL_SUMMARY_FILE }} + # SMACK tests run as root in QEMU, so use same results for root + cp ${{ env.TEST_SMACK_FULL_SUMMARY_FILE }} ${{ env.TEST_SMACK_ROOT_FULL_SUMMARY_FILE }} + - name: Upload SMACK json results + uses: actions/upload-artifact@v5 + with: + name: smack-gnu-full-result + path: ${{ env.TEST_SMACK_FULL_SUMMARY_FILE }} + - name: Upload SMACK root json results + uses: actions/upload-artifact@v5 + with: + name: smack-root-gnu-full-result + path: ${{ env.TEST_SMACK_ROOT_FULL_SUMMARY_FILE }} + - name: Compress SMACK test logs + run: gzip gnu/tests-smack/*/*.log 2>/dev/null || true + - name: Upload SMACK test logs + uses: actions/upload-artifact@v5 + with: + name: smack-test-logs + path: | + gnu/tests-smack/*.log + gnu/tests-smack/*/*.log.gz + aggregate: - needs: [native, selinux] + needs: [native, selinux, smack] permissions: actions: read # for dawidd6/action-download-artifact to query and download artifacts contents: read # for actions/checkout to fetch code @@ -384,6 +437,19 @@ jobs: name: selinux-root-gnu-full-result path: results merge-multiple: true + + - name: Download smack json results + uses: actions/download-artifact@v6 + with: + name: smack-gnu-full-result + path: results + merge-multiple: true + - name: Download smack root json results + uses: actions/download-artifact@v6 + with: + name: smack-root-gnu-full-result + path: results + merge-multiple: true - name: Extract/summarize testing info id: summary shell: bash @@ -394,8 +460,8 @@ jobs: path_UUTILS='uutils' json_count=$(ls -l results/*.json | wc -l) - if [[ "$json_count" -ne 5 ]]; then - echo "::error ::Failed to download all results json files (expected 4 files, found $json_count); failing early" + if [[ "$json_count" -ne 7 ]]; then + echo "::error ::Failed to download all results json files (expected 7 files, found $json_count); failing early" ls -lR results || true exit 1 fi diff --git a/Cargo.toml b/Cargo.toml index b8b6f48fc6a..a40cbe557f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,16 @@ feat_selinux = [ "selinux", "stat/selinux", ] +# "feat_smack" == enable support for SMACK Security Context (by using `--features feat_smack`) +# NOTE: +# * Running a uutils compiled with `feat_smack` requires a SMACK enabled Kernel at run time. +feat_smack = [ + "id/smack", + "ls/smack", + "mkdir/smack", + "mkfifo/smack", + "mknod/smack", +] ## ## feature sets ## (common/core and Tier1) feature sets diff --git a/src/uu/id/Cargo.toml b/src/uu/id/Cargo.toml index 9b947d956b6..de1752df1b3 100644 --- a/src/uu/id/Cargo.toml +++ b/src/uu/id/Cargo.toml @@ -29,3 +29,4 @@ path = "src/main.rs" [features] feat_selinux = ["selinux"] +smack = ["uucore/smack"] diff --git a/src/uu/id/src/id.rs b/src/uu/id/src/id.rs index 59f06809af5..e1ae42a7107 100644 --- a/src/uu/id/src/id.rs +++ b/src/uu/id/src/id.rs @@ -62,10 +62,10 @@ macro_rules! cstr2cow { } fn get_context_help_text() -> String { - #[cfg(not(feature = "selinux"))] - return translate!("id-context-help-disabled"); - #[cfg(feature = "selinux")] + #[cfg(any(feature = "selinux", feature = "smack"))] return translate!("id-context-help-enabled"); + #[cfg(not(any(feature = "selinux", feature = "smack")))] + return translate!("id-context-help-disabled"); } mod options { @@ -99,6 +99,7 @@ struct State { zflag: bool, // --zero cflag: bool, // --context selinux_supported: bool, + smack_supported: bool, ids: Option, // The behavior for calling GNU's `id` and calling GNU's `id $USER` is similar but different. // * The SELinux context is only displayed without a specified user. @@ -146,6 +147,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { false } }, + smack_supported: { + #[cfg(feature = "smack")] + { + uucore::smack::is_smack_enabled() + } + #[cfg(not(feature = "smack"))] + { + false + } + }, user_specified: !users.is_empty(), ids: None, }; @@ -179,7 +190,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let line_ending = LineEnding::from_zero_flag(state.zflag); if state.cflag { - return if state.selinux_supported { + if state.selinux_supported { // print SElinux context and exit #[cfg(all(any(target_os = "linux", target_os = "android"), feature = "selinux"))] if let Ok(context) = selinux::SecurityContext::current(false) { @@ -192,13 +203,28 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { translate!("id-error-cannot-get-context"), )); } - Ok(()) + return Ok(()); + } else if state.smack_supported { + // print SMACK context and exit + #[cfg(all(target_os = "linux", feature = "smack"))] + match uucore::smack::get_smack_label_for_self() { + Ok(label) => { + print!("{}{line_ending}", label); + } + Err(_) => { + return Err(USimpleError::new( + 1, + translate!("id-error-cannot-get-context"), + )); + } + } + return Ok(()); } else { - Err(USimpleError::new( + return Err(USimpleError::new( 1, translate!("id-error-context-selinux-only"), - )) - }; + )); + } } for i in 0..=users.len() { @@ -676,6 +702,17 @@ fn id_print(state: &State, groups: &[u32]) { print!(" context={}", String::from_utf8_lossy(bytes)); } } + + #[cfg(all(target_os = "linux", feature = "smack"))] + if state.smack_supported + && !state.user_specified + && std::env::var_os("POSIXLY_CORRECT").is_none() + { + // print SMACK context (does not depend on "-Z") + if let Ok(label) = uucore::smack::get_smack_label_for_self() { + print!(" context={}", label); + } + } } #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "openbsd")))] diff --git a/src/uu/ls/Cargo.toml b/src/uu/ls/Cargo.toml index e6cd07fa4a9..a96d0910899 100644 --- a/src/uu/ls/Cargo.toml +++ b/src/uu/ls/Cargo.toml @@ -60,3 +60,4 @@ harness = false [features] feat_selinux = ["selinux", "uucore/selinux"] +smack = ["uucore/smack"] diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 7abfcde8c5c..544469c7d7d 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -366,6 +366,7 @@ pub struct Config { time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) context: bool, selinux_supported: bool, + smack_supported: bool, group_directories_first: bool, line_ending: LineEnding, dired: bool, @@ -1167,6 +1168,16 @@ impl Config { false } }, + smack_supported: { + #[cfg(all(feature = "smack", target_os = "linux"))] + { + uucore::smack::is_smack_enabled() + } + #[cfg(not(all(feature = "smack", target_os = "linux")))] + { + false + } + }, group_directories_first: options.get_flag(options::GROUP_DIRECTORIES_FIRST), line_ending: LineEnding::from_zero_flag(options.get_flag(options::ZERO)), dired, @@ -3418,6 +3429,30 @@ fn get_security_context<'a>( } } + if config.smack_supported { + #[cfg(all(feature = "smack", target_os = "linux"))] + { + // For SMACK, use the path to get the label + // If must_dereference is true, we follow the symlink + let target_path = if must_dereference { + match std::fs::canonicalize(path) { + Ok(p) => p, + Err(_) => path.to_path_buf(), + } + } else { + path.to_path_buf() + }; + + match uucore::smack::get_smack_label_for_path(&target_path) { + Ok(label) => return Cow::Owned(label), + Err(_) => { + // No label or error getting label + return Cow::Borrowed(SUBSTITUTE_STRING); + } + } + } + } + Cow::Borrowed(SUBSTITUTE_STRING) } diff --git a/src/uu/mkdir/Cargo.toml b/src/uu/mkdir/Cargo.toml index 7d81094cb06..b2723e1cfbc 100644 --- a/src/uu/mkdir/Cargo.toml +++ b/src/uu/mkdir/Cargo.toml @@ -24,6 +24,7 @@ fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +smack = ["uucore/smack"] [[bin]] name = "mkdir" diff --git a/src/uu/mkdir/locales/en-US.ftl b/src/uu/mkdir/locales/en-US.ftl index 44fd5f5adf3..1e250d2707e 100644 --- a/src/uu/mkdir/locales/en-US.ftl +++ b/src/uu/mkdir/locales/en-US.ftl @@ -14,6 +14,7 @@ mkdir-error-empty-directory-name = cannot create directory '': No such file or d mkdir-error-file-exists = { $path }: File exists mkdir-error-failed-to-create-tree = failed to create whole tree mkdir-error-cannot-set-permissions = cannot set permissions { $path } +mkdir-error-smack-context = failed to set default file creation context to '{ $context }': # Verbose output mkdir-verbose-created-directory = { $util_name }: created directory { $path } diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index 6ee6100136d..22e6a71f427 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -300,6 +300,17 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( } } + // Apply SMACK context if requested + #[cfg(feature = "smack")] + if config.set_selinux_context && uucore::smack::is_smack_enabled() { + if let Some(ctx) = config.context { + if let Err(e) = uucore::smack::set_smack_label_for_path(path, ctx) { + let _ = std::fs::remove_dir(path); + return Err(USimpleError::new(1, e.to_string())); + } + } + } + Ok(()) } diff --git a/src/uu/mkfifo/Cargo.toml b/src/uu/mkfifo/Cargo.toml index 5edbfa6bde1..ca0cc4dcbec 100644 --- a/src/uu/mkfifo/Cargo.toml +++ b/src/uu/mkfifo/Cargo.toml @@ -25,6 +25,7 @@ fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +smack = ["uucore/smack"] [[bin]] name = "mkfifo" diff --git a/src/uu/mkfifo/locales/en-US.ftl b/src/uu/mkfifo/locales/en-US.ftl index 2a02e7d0d1f..4558308dc9e 100644 --- a/src/uu/mkfifo/locales/en-US.ftl +++ b/src/uu/mkfifo/locales/en-US.ftl @@ -11,3 +11,4 @@ mkfifo-error-invalid-mode = invalid mode: { $error } mkfifo-error-missing-operand = missing operand mkfifo-error-cannot-create-fifo = cannot create fifo { $path }: File exists mkfifo-error-cannot-set-permissions = cannot set permissions on { $path }: { $error } +mkfifo-error-smack-context = failed to set default file creation context to '{ $context }': diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index c55593dcbca..f15f7fea6ca 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -75,6 +75,23 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } } + + // Apply SMACK context if requested + #[cfg(feature = "smack")] + { + let set_smack_context = matches.get_flag(options::SELINUX); + let context = matches.get_one::(options::CONTEXT); + + if (set_smack_context || context.is_some()) && uucore::smack::is_smack_enabled() { + if let Some(ctx) = context { + use std::path::Path; + if let Err(e) = uucore::smack::set_smack_label_for_path(Path::new(&f), ctx) { + let _ = fs::remove_file(&f); + return Err(USimpleError::new(1, e.to_string())); + } + } + } + } } Ok(()) diff --git a/src/uu/mknod/Cargo.toml b/src/uu/mknod/Cargo.toml index 50e7e2fce3c..c0920951668 100644 --- a/src/uu/mknod/Cargo.toml +++ b/src/uu/mknod/Cargo.toml @@ -26,6 +26,7 @@ fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +smack = ["uucore/smack"] [[bin]] name = "mknod" diff --git a/src/uu/mknod/locales/en-US.ftl b/src/uu/mknod/locales/en-US.ftl index 4d655d4c0e9..b4a1d3d9bb1 100644 --- a/src/uu/mknod/locales/en-US.ftl +++ b/src/uu/mknod/locales/en-US.ftl @@ -32,3 +32,4 @@ mknod-error-invalid-mode = invalid mode ({ $error }) mknod-error-mode-permission-bits-only = mode must specify only file permission bits mknod-error-missing-device-type = missing device type mknod-error-invalid-device-type = invalid device type { $type } +mknod-error-smack-context = failed to set default file creation context to '{ $context }': diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index cc22aee5f5c..8909dc7ecaa 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -105,6 +105,21 @@ fn mknod(file_name: &str, config: Config) -> i32 { } } + // Apply SMACK context if requested + #[cfg(feature = "smack")] + if config.set_selinux_context && uucore::smack::is_smack_enabled() { + if let Some(ctx) = config.context { + if let Err(e) = + uucore::smack::set_smack_label_for_path(std::path::Path::new(file_name), ctx) + { + // if it fails, delete the file + let _ = std::fs::remove_file(file_name); + eprintln!("{}: {}", uucore::util_name(), e); + return 1; + } + } + } + errno } } diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index d30b88eeb49..0362dc09793 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -162,6 +162,7 @@ ranges = [] ringbuffer = [] safe-traversal = ["libc"] selinux = ["dep:selinux"] +smack = ["xattr"] signals = [] sum = [ "digest", diff --git a/src/uucore/locales/en-US.ftl b/src/uucore/locales/en-US.ftl index 384e4a83de9..930c792a9ea 100644 --- a/src/uucore/locales/en-US.ftl +++ b/src/uucore/locales/en-US.ftl @@ -46,6 +46,12 @@ selinux-error-context-retrieval-failure = failed to retrieve the security contex selinux-error-context-set-failure = failed to set default file creation context to '{ $context }': { $error } selinux-error-context-conversion-failure = failed to set default file creation context to '{ $context }': { $error } +# SMACK error messages +smack-error-not-enabled = SMACK is not enabled on this system +smack-error-label-retrieval-failure = failed to get SMACK label: { $error } +smack-error-label-set-failure = failed to set default file creation context to '{ $context }': { $error } +smack-error-io = I/O error: { $error } + # Safe traversal error messages safe-traversal-error-path-contains-null = path contains null byte safe-traversal-error-open-failed = failed to open '{ $path }': { $source } diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index 548f7f2bc95..e56968c50fa 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -85,6 +85,8 @@ pub mod hardware; pub mod selinux; #[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))] pub mod signals; +#[cfg(all(target_os = "linux", feature = "smack"))] +pub mod smack; #[cfg(feature = "feat_systemd_logind")] pub mod systemd_logind; #[cfg(all( diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext.rs index ce734ff2d32..2fb1389d8b8 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext.rs @@ -392,7 +392,9 @@ fn is_dummy_filesystem(fs_type: &str, mount_option: &str) -> bool { // for NetBSD 3.0 | "kernfs" // for Irix 6.5 - | "ignore" => true, + | "ignore" + // Linux initial RAM filesystem + | "rootfs" => true, _ => fs_type == "none" && !mount_option.contains(MOUNT_OPT_BIND) } diff --git a/src/uucore/src/lib/features/smack.rs b/src/uucore/src/lib/features/smack.rs new file mode 100644 index 00000000000..d6a5dcf219f --- /dev/null +++ b/src/uucore/src/lib/features/smack.rs @@ -0,0 +1,111 @@ +// This file is part of the uutils uucore package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore smackfs +//! SMACK (Simplified Mandatory Access Control Kernel) support + +use std::fs; +use std::io::{self, Read, Write}; +use std::path::Path; + +use thiserror::Error; + +use crate::error::{UError, strip_errno}; +use crate::translate; + +#[derive(Debug, Error)] +pub enum SmackError { + #[error("{}", translate!("smack-error-not-enabled"))] + SmackNotEnabled, + + #[error("{}", translate!("smack-error-label-retrieval-failure", "error" => strip_errno(.0)))] + LabelRetrievalFailure(io::Error), + + #[error("{}", translate!("smack-error-label-set-failure", "context" => .0.clone(), "error" => strip_errno(.1)))] + LabelSetFailure(String, io::Error), + + #[error("{}", translate!("smack-error-io", "error" => strip_errno(.0)))] + IoError(#[from] io::Error), +} + +impl UError for SmackError { + fn code(&self) -> i32 { + match self { + Self::SmackNotEnabled => 1, + Self::LabelRetrievalFailure(_) => 2, + Self::LabelSetFailure(_, _) => 3, + Self::IoError(_) => 4, + } + } +} + +impl From for i32 { + fn from(error: SmackError) -> Self { + error.code() + } +} + +/// Checks if SMACK is enabled by verifying smackfs is mounted. +pub fn is_smack_enabled() -> bool { + Path::new("/sys/fs/smackfs").exists() +} + +/// Gets the SMACK label for the current process. +pub fn get_smack_label_for_self() -> Result { + if !is_smack_enabled() { + return Err(SmackError::SmackNotEnabled); + } + + let mut label = String::new(); + fs::File::open("/proc/self/attr/current") + .map_err(SmackError::LabelRetrievalFailure)? + .read_to_string(&mut label) + .map_err(SmackError::LabelRetrievalFailure)?; + + Ok(label.trim().to_string()) +} + +/// Sets the SMACK label for the current process. +pub fn set_smack_label_for_self(label: &str) -> Result<(), SmackError> { + if !is_smack_enabled() { + return Err(SmackError::SmackNotEnabled); + } + + let label_owned = label.to_string(); + fs::File::create("/proc/self/attr/current") + .map_err(|e| SmackError::LabelSetFailure(label_owned.clone(), e))? + .write_all(label.as_bytes()) + .map_err(|e| SmackError::LabelSetFailure(label_owned, e))?; + + Ok(()) +} + +/// Gets the SMACK label for a filesystem path via xattr. +#[cfg(feature = "xattr")] +pub fn get_smack_label_for_path(path: &Path) -> Result { + if !is_smack_enabled() { + return Err(SmackError::SmackNotEnabled); + } + + match xattr::get(path, "security.SMACK64") { + Ok(Some(value)) => Ok(String::from_utf8_lossy(&value).trim().to_string()), + Ok(None) => Err(SmackError::LabelRetrievalFailure(io::Error::new( + io::ErrorKind::NotFound, + "no SMACK label set", + ))), + Err(e) => Err(SmackError::LabelRetrievalFailure(e)), + } +} + +/// Sets the SMACK label for a filesystem path via xattr. +#[cfg(feature = "xattr")] +pub fn set_smack_label_for_path(path: &Path, label: &str) -> Result<(), SmackError> { + if !is_smack_enabled() { + return Err(SmackError::SmackNotEnabled); + } + + xattr::set(path, "security.SMACK64", label.as_bytes()) + .map_err(|e| SmackError::LabelSetFailure(label.to_string(), e)) +} diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 29686ccdea5..9a2bafd9e0a 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -125,6 +125,9 @@ pub use crate::features::fsxattr; #[cfg(all(target_os = "linux", feature = "selinux"))] pub use crate::features::selinux; +#[cfg(all(target_os = "linux", feature = "smack"))] +pub use crate::features::smack; + //## core functions #[cfg(unix)] diff --git a/util/build-test-rootfs.sh b/util/build-test-rootfs.sh new file mode 100755 index 00000000000..e79c2da5f9c --- /dev/null +++ b/util/build-test-rootfs.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Build reusable initramfs for rootfs tests (run once) +# spell-checker:ignore rootfs libm libpthread libdl librt libgcc sysfs tmpfs poweroff +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +ROOTFS_DIR="$REPO_DIR/target/rootfs-test" + +echo "Building test rootfs in $ROOTFS_DIR..." + +rm -rf "$ROOTFS_DIR" +mkdir -p "$ROOTFS_DIR"/{bin,lib64,proc,sys,dev,tmp,etc,gnu} + +# Get busybox +BUSYBOX=/tmp/busybox +[ -f "$BUSYBOX" ] || curl -sL -o "$BUSYBOX" https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox +chmod +x "$BUSYBOX" +cp "$BUSYBOX" "$ROOTFS_DIR/bin/" +cd "$ROOTFS_DIR/bin" && "$BUSYBOX" --list | xargs -I{} ln -sf busybox {} 2>/dev/null + +# Copy libs +cp /lib64/{ld-linux-x86-64.so.2,libc.so.6,libm.so.6,libpthread.so.0,libdl.so.2,librt.so.1,libgcc_s.so.1} "$ROOTFS_DIR/lib64/" 2>/dev/null || \ +cp /lib/x86_64-linux-gnu/{ld-linux-x86-64.so.2,libc.so.6,libm.so.6,libpthread.so.0,libdl.so.2,librt.so.1,libgcc_s.so.1} "$ROOTFS_DIR/lib64/" + +# Copy entire GNU tests directory +cp -r "$REPO_DIR/../gnu/tests" "$ROOTFS_DIR/gnu/" + +# Create /etc/mtab placeholder (needed by busybox at startup) +touch "$ROOTFS_DIR/etc/mtab" + +# Create init script +cat > "$ROOTFS_DIR/init" << 'INIT' +#!/bin/sh +mount -t proc proc /proc +mount -t sysfs sys /sys +ln -sf /proc/mounts /etc/mtab +mkdir -p /tmp && mount -t tmpfs tmpfs /tmp +export PATH="/bin:$PATH" srcdir="/gnu" +cd /gnu/tests +sh "$TEST_SCRIPT" +echo "EXIT:$?" +poweroff -f +INIT +chmod +x "$ROOTFS_DIR/init" + +echo "Done. Base rootfs created at $ROOTFS_DIR" +echo "Run tests with: util/run-gnu-test-rootfs.sh tests/df/skip-rootfs.sh" diff --git a/util/build-test-smack.sh b/util/build-test-smack.sh new file mode 100755 index 00000000000..2e4fb195187 --- /dev/null +++ b/util/build-test-smack.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# Build reusable initramfs for SMACK tests (run once) +# Downloads Arch Linux kernel with SMACK support +# spell-checker:ignore rootfs zstd unzstd libm libpthread libdl librt libgcc libnss nsswitch sysfs smackfs devtmpfs tmpfs setuidgid poweroff +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +SMACK_DIR="$REPO_DIR/target/smack-test" + +echo "Building SMACK test environment in $SMACK_DIR..." + +rm -rf "$SMACK_DIR" +mkdir -p "$SMACK_DIR"/{rootfs/{bin,lib64,proc,sys,dev,tmp,etc,gnu},kernel} + +# Download Arch Linux kernel (has SMACK built-in) +MIRROR="https://geo.mirror.pkgbuild.com/core/os/x86_64" + +if [ ! -f /tmp/arch-vmlinuz ]; then + echo "Downloading Arch Linux kernel with SMACK..." + # Find current kernel package name dynamically + KERNEL_PKG=$(curl -sL "$MIRROR/" | grep -oP 'linux-[0-9][^"]*-x86_64\.pkg\.tar\.zst' | grep -v headers | sort -V | tail -1) + # Alternative: use a specific known-good version + # KERNEL_PKG="linux-6.12.6.arch1-1-x86_64.pkg.tar.zst" + if [ -z "$KERNEL_PKG" ]; then + echo "Error: Could not find Arch Linux kernel package" + exit 1 + fi + echo "Found kernel package: $KERNEL_PKG" + echo "Kernel version: $(echo $KERNEL_PKG | grep -oP 'linux-\K[0-9]+\.[0-9]+\.[0-9]+')" + curl -sL -o /tmp/arch-kernel.pkg.tar.zst "$MIRROR/$KERNEL_PKG" + zstd -d /tmp/arch-kernel.pkg.tar.zst -o /tmp/arch-kernel.pkg.tar 2>/dev/null || \ + unzstd /tmp/arch-kernel.pkg.tar.zst -o /tmp/arch-kernel.pkg.tar + # Extract kernel - list contents first to find the path + VMLINUZ_PATH=$(tar -tf /tmp/arch-kernel.pkg.tar | grep 'vmlinuz$' | head -1) + if [ -z "$VMLINUZ_PATH" ]; then + echo "Error: Could not find vmlinuz in kernel package" + tar -tf /tmp/arch-kernel.pkg.tar | head -20 + exit 1 + fi + echo "Extracting: $VMLINUZ_PATH" + tar -xf /tmp/arch-kernel.pkg.tar -C /tmp "$VMLINUZ_PATH" + mv "/tmp/$VMLINUZ_PATH" /tmp/arch-vmlinuz + rm -rf /tmp/usr /tmp/arch-kernel.pkg.tar /tmp/arch-kernel.pkg.tar.zst +fi +cp /tmp/arch-vmlinuz "$SMACK_DIR/kernel/vmlinuz" + +# Get busybox +BUSYBOX=/tmp/busybox +[ -f "$BUSYBOX" ] || curl -sL -o "$BUSYBOX" https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox +chmod +x "$BUSYBOX" +cp "$BUSYBOX" "$SMACK_DIR/rootfs/bin/" +cd "$SMACK_DIR/rootfs/bin" && "$BUSYBOX" --list | xargs -I{} ln -sf busybox {} +echo "Busybox applets created:" +ls -la "$SMACK_DIR/rootfs/bin/" | head -20 + +# Copy required libraries using ldd on a test binary to find them +echo "Finding and copying required libraries..." + +# Use ldconfig to find libraries +copy_lib_by_name() { + local lib="$1" + local path=$(ldconfig -p | grep "$lib" | head -1 | awk '{print $NF}') + if [ -n "$path" ] && [ -f "$path" ]; then + cp -L "$path" "$SMACK_DIR/rootfs/lib64/" + echo " Copied: $path" + else + echo " Warning: $lib not found via ldconfig" + # Fallback: search common paths + for dir in /lib64 /lib/x86_64-linux-gnu /usr/lib64 /usr/lib/x86_64-linux-gnu /usr/lib; do + for f in "$dir"/$lib "$dir"/$lib.*; do + if [ -f "$f" ]; then + cp -L "$f" "$SMACK_DIR/rootfs/lib64/" + echo " Copied: $f" + return + fi + done + done + echo " ERROR: $lib not found anywhere" + fi +} + +# Essential libraries +for lib in ld-linux-x86-64.so.2 libc.so.6 libm.so.6 libgcc_s.so.1 libpthread.so.0 libdl.so.2 librt.so.1 libnss_files.so.2; do + copy_lib_by_name "$lib" +done + +echo "Libraries in rootfs:" +ls -la "$SMACK_DIR/rootfs/lib64/" + +# Create symlink for library path compatibility +mkdir -p "$SMACK_DIR/rootfs/lib" +ln -sf /lib64 "$SMACK_DIR/rootfs/lib/x86_64-linux-gnu" + +# Create /etc/nsswitch.conf for NSS +echo "passwd: files +group: files +shadow: files" > "$SMACK_DIR/rootfs/etc/nsswitch.conf" + +# Copy entire GNU tests directory +cp -r "$REPO_DIR/../gnu/tests" "$SMACK_DIR/rootfs/gnu/" +cp "$REPO_DIR/../gnu/init.cfg" "$SMACK_DIR/rootfs/gnu/" 2>/dev/null || true + +# Create /etc/mtab placeholder +touch "$SMACK_DIR/rootfs/etc/mtab" + +# Create minimal /etc/passwd and /etc/group (include nobody user for non-root tests) +cat > "$SMACK_DIR/rootfs/etc/passwd" << 'PASSWD' +root:x:0:0:root:/root:/bin/sh +nobody:x:65534:65534:nobody:/nonexistent:/bin/sh +PASSWD +cat > "$SMACK_DIR/rootfs/etc/group" << 'GROUP' +root:x:0: +nobody:x:65534: +GROUP + +# Create init script (supports $RUN_AS_USER to run as non-root) +cat > "$SMACK_DIR/rootfs/init" << 'INIT' +#!/bin/sh +mount -t proc proc /proc +mount -t sysfs sys /sys +mount -t smackfs smackfs /sys/fs/smackfs 2>/dev/null || true + +# Set up permissive SMACK policy - allow all access for testing +if [ -d /sys/fs/smackfs ]; then + # Set current process to floor label + echo "_" > /proc/self/attr/current 2>/dev/null || true + # Allow floor label to access everything + echo "_ _ rwxat" > /sys/fs/smackfs/load 2>/dev/null || true + echo "_ * rwxat" > /sys/fs/smackfs/load 2>/dev/null || true + echo "* _ rwxat" > /sys/fs/smackfs/load 2>/dev/null || true +fi + +mount -t devtmpfs devtmpfs /dev 2>/dev/null || { + # Fallback: create minimal device nodes + mknod /dev/null c 1 3 2>/dev/null || true + mknod /dev/zero c 1 5 2>/dev/null || true + mknod /dev/tty c 5 0 2>/dev/null || true + chmod 666 /dev/null /dev/zero /dev/tty 2>/dev/null || true +} +ln -sf /proc/mounts /etc/mtab +mkdir -p /tmp && mount -t tmpfs tmpfs /tmp +chmod 1777 /tmp +export PATH="/bin:$PATH" srcdir="/gnu" LD_LIBRARY_PATH="/lib64:/lib/x86_64-linux-gnu" +export built_programs="id ls mkdir mknod mkfifo" + +# Debug: Check if SMACK is enabled +echo "=== SMACK STATUS ===" +cat /proc/filesystems | grep smack || echo "smackfs not in /proc/filesystems" +ls -la /sys/fs/smackfs 2>&1 || echo "/sys/fs/smackfs not available" +cat /proc/self/attr/current 2>&1 || echo "Cannot read SMACK label" +echo "=== END SMACK STATUS ===" + +if [ -n "$RUN_AS_USER" ]; then + # Create writable test directory for non-root user + mkdir -p /tmp/test-work/tests + chmod -R 777 /tmp/test-work + cd /tmp/test-work/tests + # Copy test files to writable location (preserving structure) + cp -r /gnu/tests/* /tmp/test-work/tests/ 2>/dev/null || true + cp /gnu/init.cfg /tmp/test-work/ 2>/dev/null || true + chmod -R 777 /tmp/test-work + export srcdir="/tmp/test-work" + # Run as non-root user using setuidgid (from busybox) + setuidgid $RUN_AS_USER sh "$TEST_SCRIPT" +else + cd /gnu/tests + sh "$TEST_SCRIPT" +fi +echo "EXIT:$?" +poweroff -f +INIT +chmod +x "$SMACK_DIR/rootfs/init" + +echo "Done. SMACK test environment created at $SMACK_DIR" +echo "Run tests with: util/run-gnu-test-smack.sh tests/id/smack.sh" diff --git a/util/run-gnu-test-rootfs.sh b/util/run-gnu-test-rootfs.sh new file mode 100755 index 00000000000..ac7370df121 --- /dev/null +++ b/util/run-gnu-test-rootfs.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Run GNU test in QEMU with rootfs visible +# Usage: run-gnu-test-rootfs.sh tests/df/skip-rootfs.sh +# spell-checker:ignore rootfs cpio newc nographic +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +ROOTFS_DIR="$REPO_DIR/target/rootfs-test" +WORK=/tmp/rootfs-test-$$ + +[ -z "$1" ] && { echo "Usage: $0 tests//.sh"; exit 1; } +TEST_SCRIPT="${1#tests/}" # Strip leading tests/ since we cd to /gnu/tests + +# Build base rootfs if needed +[ -d "$ROOTFS_DIR" ] || bash "$SCRIPT_DIR/build-test-rootfs.sh" + +# Get utility name from test path (e.g., df/skip-rootfs.sh -> df) +UTIL=$(echo "$TEST_SCRIPT" | cut -d/ -f1) + +# Build the utility +cargo build --manifest-path="$REPO_DIR/Cargo.toml" --package "uu_$UTIL" --bin "$UTIL" + +# Create working copy +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT +cp -a "$ROOTFS_DIR" "$WORK" + +# Copy built utility (remove busybox symlink first) +rm -f "$WORK/bin/$UTIL" +cp "$REPO_DIR/target/debug/$UTIL" "$WORK/bin/$UTIL" + +# Update init with test script path +sed -i "s|\$TEST_SCRIPT|$TEST_SCRIPT|g" "$WORK/init" + +# Build initramfs +cd "$WORK" && find . | cpio -o -H newc 2>/dev/null | gzip > "$WORK.gz" + +# Find kernel +KERNEL=$(ls /boot/vmlinuz-* 2>/dev/null | head -1) +[ -z "$KERNEL" ] && { echo "No kernel found"; exit 1; } + +# Run test +OUTPUT=$(timeout 60 qemu-system-x86_64 -kernel "$KERNEL" -initrd "$WORK.gz" \ + -append "console=ttyS0 quiet panic=-1" -nographic -m 256M -no-reboot 2>&1) + +rm -f "$WORK.gz" +echo "$OUTPUT" | tail -20 + +# Check result +if echo "$OUTPUT" | grep -q "EXIT:0"; then + echo "PASS: $TEST_SCRIPT" + exit 0 +elif echo "$OUTPUT" | grep -q "EXIT:77"; then + echo "SKIP: $TEST_SCRIPT" + exit 77 +else + echo "FAIL: $TEST_SCRIPT" + exit 1 +fi diff --git a/util/run-gnu-test-smack.sh b/util/run-gnu-test-smack.sh new file mode 100755 index 00000000000..d9f3dd4b555 --- /dev/null +++ b/util/run-gnu-test-smack.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Run GNU test in QEMU with SMACK enabled +# Usage: run-gnu-test-smack.sh tests/id/smack.sh +# spell-checker:ignore rootfs cpio newc nographic +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +SMACK_DIR="$REPO_DIR/target/smack-test" +WORK=/tmp/smack-test-$$ + +[ -z "$1" ] && { echo "Usage: $0 tests//.sh"; exit 1; } +TEST_SCRIPT="${1#tests/}" # Strip leading tests/ + +# Build SMACK environment if needed +[ -d "$SMACK_DIR" ] || bash "$SCRIPT_DIR/build-test-smack.sh" + +# Get utility name from test path (e.g., id/smack.sh -> id) +UTIL=$(echo "$TEST_SCRIPT" | cut -d/ -f1) + +# Determine which utilities to build based on test +# mkdir smack tests need mkdir, mknod, mkfifo, and ls (for require_smack_ check) +if [ "$UTIL" = "mkdir" ]; then + UTILS="ls mkdir mknod mkfifo" +else + UTILS="ls $UTIL" +fi + +# Build the utilities with smack feature (release mode for smaller binaries) +for U in $UTILS; do + cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package "uu_$U" --bin "$U" --features "uu_$U/smack" +done + +# Create working copy +cleanup() { rm -rf "$WORK" "$WORK.gz"; } +trap cleanup EXIT +cp -a "$SMACK_DIR/rootfs" "$WORK" + +# Copy built utilities (remove busybox symlinks first) +for U in $UTILS; do + rm -f "$WORK/bin/$U" + cp "$REPO_DIR/target/release/$U" "$WORK/bin/$U" +done + +# Update init with test script path +sed -i "s|\$TEST_SCRIPT|$TEST_SCRIPT|g" "$WORK/init" + +# For smack-no-root.sh test, run as non-root user +if echo "$TEST_SCRIPT" | grep -q "no-root"; then + sed -i "s|\$RUN_AS_USER|nobody|g" "$WORK/init" +else + sed -i "s|\$RUN_AS_USER||g" "$WORK/init" +fi + +# Build initramfs +cd "$WORK" && find . | cpio -o -H newc 2>/dev/null | gzip > "$WORK.gz" + +# Run test with SMACK-enabled kernel +OUTPUT=$(timeout 60 qemu-system-x86_64 -kernel "$SMACK_DIR/kernel/vmlinuz" -initrd "$WORK.gz" \ + -append "console=ttyS0 quiet panic=-1 security=smack lsm=smack" -nographic -m 256M -no-reboot 2>&1) + +echo "$OUTPUT" | tail -20 + +# Check result +if echo "$OUTPUT" | grep -q "EXIT:0"; then + echo "PASS: $1" + exit 0 +elif echo "$OUTPUT" | grep -q "EXIT:77"; then + echo "SKIP: $1" + exit 77 +else + echo "FAIL: $1" + exit 1 +fi diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh new file mode 100755 index 00000000000..f129bedc725 --- /dev/null +++ b/util/run-gnu-tests-smack-ci.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Run all GNU SMACK tests in QEMU for CI +# Usage: run-gnu-tests-smack-ci.sh [GNU_DIR] [OUTPUT_DIR] +# Outputs logs compatible with gnu-json-result.py +# spell-checker:ignore rootfs cpio newc nographic smackfs devtmpfs tmpfs setuidgid poweroff +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +GNU_DIR="${1:-$REPO_DIR/../gnu}" +OUTPUT_DIR="${2:-$REPO_DIR/target/smack-test-results}" +SMACK_DIR="$REPO_DIR/target/smack-test" + +echo "Running GNU SMACK tests..." +echo " GNU_DIR: $GNU_DIR" +echo " OUTPUT_DIR: $OUTPUT_DIR" + +# Always rebuild SMACK environment to ensure fresh state +echo "Building SMACK test environment..." +rm -rf "$SMACK_DIR" +bash "$SCRIPT_DIR/build-test-smack.sh" + +# Verify environment was built +if [ ! -d "$SMACK_DIR/rootfs" ]; then + echo "Error: SMACK rootfs not found at $SMACK_DIR/rootfs" + ls -la "$SMACK_DIR" 2>&1 || echo "SMACK_DIR does not exist" + exit 1 +fi + +# Debug: show what's in the rootfs +echo "=== ROOTFS CONTENTS ===" +echo "Libraries:" +ls -la "$SMACK_DIR/rootfs/lib64/" || echo "No lib64 directory" +echo "Binaries (first 20):" +ls -la "$SMACK_DIR/rootfs/bin/" | head -20 +echo "=== END ROOTFS CONTENTS ===" + +# Build all utilities needed for SMACK tests +echo "Building utilities with SMACK support..." +UTILS="ls id mkdir mknod mkfifo" +for U in $UTILS; do + cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package "uu_$U" --bin "$U" --features "uu_$U/smack" +done + +# Find all SMACK tests +SMACK_TESTS=$(grep -l 'require_smack_' -r "$GNU_DIR/tests/" 2>/dev/null || true) +if [ -z "$SMACK_TESTS" ]; then + echo "No SMACK tests found" + exit 0 +fi + +echo "Found SMACK tests:" +echo "$SMACK_TESTS" + +# Create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +# Run each test +for TEST_PATH in $SMACK_TESTS; do + # Get relative path from tests/ dir (e.g., id/smack.sh) + TEST_REL="${TEST_PATH#$GNU_DIR/tests/}" + TEST_DIR=$(dirname "$TEST_REL") + TEST_NAME=$(basename "$TEST_REL" .sh) + + echo "Running: $TEST_REL" + + # Determine if test needs non-root + RUN_AS_USER="" + if echo "$TEST_REL" | grep -q "no-root"; then + RUN_AS_USER="nobody" + fi + + # Create working copy + WORK="/tmp/smack-test-$$" + rm -rf "$WORK" "$WORK.gz" + cp -a "$SMACK_DIR/rootfs" "$WORK" + + # Copy built utilities + for U in $UTILS; do + rm -f "$WORK/bin/$U" + cp "$REPO_DIR/target/release/$U" "$WORK/bin/$U" + done + + # Update init with test script path + sed -i "s|\$TEST_SCRIPT|$TEST_REL|g" "$WORK/init" + + # Set user for non-root tests + if [ -n "$RUN_AS_USER" ]; then + sed -i "s|\$RUN_AS_USER|$RUN_AS_USER|g" "$WORK/init" + else + sed -i "s|\$RUN_AS_USER||g" "$WORK/init" + fi + + # Build initramfs + (cd "$WORK" && find . | cpio -o -H newc 2>/dev/null | gzip > "$WORK.gz") + + # Run in QEMU + OUTPUT=$(timeout 120 qemu-system-x86_64 \ + -kernel "$SMACK_DIR/kernel/vmlinuz" \ + -initrd "$WORK.gz" \ + -append "console=ttyS0 quiet panic=-1 security=smack lsm=smack" \ + -nographic -m 256M -no-reboot 2>&1) || true + + # Determine result + if echo "$OUTPUT" | grep -q "EXIT:0"; then + RESULT="PASS" + EXIT_STATUS=0 + elif echo "$OUTPUT" | grep -q "EXIT:77"; then + RESULT="SKIP" + EXIT_STATUS=77 + else + RESULT="FAIL" + EXIT_STATUS=1 + fi + + echo " $RESULT: $TEST_REL" + + # Show output for failed or skipped tests + if [ "$RESULT" = "FAIL" ] || [ "$RESULT" = "SKIP" ]; then + echo "=== QEMU OUTPUT FOR $TEST_REL ===" + echo "$OUTPUT" | tail -80 + echo "=== END OUTPUT ===" + fi + + # Create log file in format expected by gnu-json-result.py + mkdir -p "$OUTPUT_DIR/$TEST_DIR" + { + echo "$OUTPUT" + echo "" + echo "$RESULT $TEST_NAME.sh (exit status: $EXIT_STATUS)" + } > "$OUTPUT_DIR/$TEST_DIR/$TEST_NAME.log" + + # Cleanup + rm -rf "$WORK" "$WORK.gz" +done + +echo "Done. Results in $OUTPUT_DIR"