From 39c3444cc4b5b2b09fd58b62025186faa394bf46 Mon Sep 17 00:00:00 2001 From: Ashwin Nandan Date: Mon, 3 Aug 2026 14:26:48 -0400 Subject: [PATCH 1/4] Add path confinement tests --- src/typemap/Cargo.toml | 4 ++ src/typemap/src/path_conversion.rs | 65 +++++++++++++++++++ .../deterministic/symlink_confinement.c | 45 +++++++++++++ 3 files changed, 114 insertions(+) create mode 100755 tests/unit-tests/file_tests/deterministic/symlink_confinement.c diff --git a/src/typemap/Cargo.toml b/src/typemap/Cargo.toml index fc69d7ac2..bd7fa7462 100644 --- a/src/typemap/Cargo.toml +++ b/src/typemap/Cargo.toml @@ -13,3 +13,7 @@ sysdefs = { path = "../sysdefs" } default = ["fast"] fast = [] secure = [] + +[dev-dependencies] +dashmap = "5.1" +parking_lot = "0.12" \ No newline at end of file diff --git a/src/typemap/src/path_conversion.rs b/src/typemap/src/path_conversion.rs index d5b51d1e1..a7da8ecda 100644 --- a/src/typemap/src/path_conversion.rs +++ b/src/typemap/src/path_conversion.rs @@ -225,4 +225,69 @@ mod tests { assert_eq!(path_without_trailing_slashes("/"), "/"); assert_eq!(path_without_trailing_slashes("///"), "/"); } + + use dashmap::DashMap; + + // Builds a minimal cage and registers it under 'cageid', so + // normpath(path, cageid) can find it via cage::get_cage() + // Every field besides 'cwd' is irrelevant to path logic, they're + // filled with empty/default values to satisfy the struct + fn make_test_cage(cageid: u64, cwd: &str) { + cage::cagetable_init(); + + let test_cage = cage::Cage { + cageid, + parent: cageid, + cwd: cage::RwLock::new(cage::Arc::new(PathBuf::from(cwd))), + rev_shm: cage::Mutex::new(Vec::new()), + signalhandler: DashMap::new(), + sigset: cage::AtomicU64::new(0), + pending_signals: cage::RwLock::new(vec![]), + epoch_handler: DashMap::new(), + os_tid_map: DashMap::new(), + main_threadid: cage::RwLock::new(0), + interval_timer: cage::IntervalTimer::new(cageid), + zombies: cage::RwLock::new(vec![]), + child_num: cage::AtomicU64::new(0), + vmmap: cage::RwLock::new(cage::Vmmap::new()), + final_exit_status: cage::RwLock::new(None), + exit_group_initiated: cage::AtomicBool::new(false), + is_dead: cage::AtomicBool::new(false), + grate_inflight: cage::AtomicU64::new(0), + }; + cage::add_cage(cageid, test_cage); + } + #[test] + //ISO-004: an absolute path must always be rebuilt from the virtual root + // never passed through some other branch unmodified + fn normpath_confines_absolute_path_to_virtual_root() { + let cageid = 1500; + make_test_cage(cageid, "/"); + + let result = normpath(PathBuf::from("/etc/passwd"), cageid); + + assert_eq!(result, PathBuf::from("/etc/passwd")); + } + + #[test] + //ISO-004: excess ".." must clamp at the virtual root instead of + // going negative even when climbing from a real nested cwd + fn normpath_clamps_excess_parent_dir_at_root() { + let cageid = 1501; + make_test_cage(cageid, "/home/user/project"); + + let result = normpath(PathBuf::from("../../../../../../etc/passwd"), cageid); + assert_eq!(result, PathBuf::from("/etc/passwd")); + } + + #[test] + // ISO-004: ordinary ".." must still resolve correctly, not just get + // clamped away, proves the clamp isn't overly aggressive + fn normpath_resolves_ordinary_parent_dir_correctly() { + let cageid = 1502; + make_test_cage(cageid, "/a/b"); + + let result = normpath(PathBuf::from("foo/../../bar"), cageid); + assert_eq!(result, PathBuf::from("/a/bar")); + } } diff --git a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c new file mode 100755 index 000000000..5c725a302 --- /dev/null +++ b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c @@ -0,0 +1,45 @@ +#include +#include +#include +#include +#include +#include + +int main() { + unlink("evil_link"); + + assert(symlink("/etc/passwd", "evil_link") == 0); + + errno = 0; + int direct_fd = open("/etc/passwd", O_RDONLY); + int direct_errno = errno; + + errno = 0; + int link_fd = open("evil_link", O_RDONLY); + int link_errno = errno; + + if(direct_fd == -1) { + assert(link_fd == -1); + assert(link_errno == direct_errno); + } else { + assert(link_fd != -1); + + char direct_buf[256]; + char link_buf[256]; + ssize_t direct_n = read(direct_fd, direct_buf, sizeof(direct_buf)); + ssize_t link_n = read(link_fd, link_buf, sizeof(link_buf)); + + assert(direct_n >= 0); + assert(link_n == direct_n); + assert(memcmp(direct_buf, link_buf, (size_t)direct_n) == 0); + + close(direct_fd); + close(link_fd); + } + + unlink("evil_link"); + + printf("symlink_confinement test: PASS\n"); + return 0; + +} \ No newline at end of file From da684a310690bd99799ce8f096edd67790c8ca01 Mon Sep 17 00:00:00 2001 From: Ashwin Nandan Date: Mon, 10 Aug 2026 19:59:21 -0400 Subject: [PATCH 2/4] Address review comments --- src/typemap/Cargo.toml | 1 - src/typemap/src/path_conversion.rs | 2 +- .../deterministic/symlink_confinement.c | 23 +++++++++++++++++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/typemap/Cargo.toml b/src/typemap/Cargo.toml index bd7fa7462..52ad771b2 100644 --- a/src/typemap/Cargo.toml +++ b/src/typemap/Cargo.toml @@ -16,4 +16,3 @@ secure = [] [dev-dependencies] dashmap = "5.1" -parking_lot = "0.12" \ No newline at end of file diff --git a/src/typemap/src/path_conversion.rs b/src/typemap/src/path_conversion.rs index a7da8ecda..130080a62 100644 --- a/src/typemap/src/path_conversion.rs +++ b/src/typemap/src/path_conversion.rs @@ -264,7 +264,7 @@ mod tests { let cageid = 1500; make_test_cage(cageid, "/"); - let result = normpath(PathBuf::from("/etc/passwd"), cageid); + let result = normpath(PathBuf::from("/etc/../etc/passwd"), cageid); assert_eq!(result, PathBuf::from("/etc/passwd")); } diff --git a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c index 5c725a302..bc8f5afae 100755 --- a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c +++ b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c @@ -39,7 +39,26 @@ int main() { unlink("evil_link"); + errno = 0; + /* + NOTE: /lind/README.md is specific to this dev-conatainer's mount + layout(repo checked out at /lind, matching LINDFS_ROOT's hardcoded + assumption in sysdefs). If this runs somewhere that mounts the repo + differently, this path may not exist at all, in which case this + check would accidentally pass via ENOENT. + Re-verify this path is valid if test is run in a new environment. + */ + int escape_fd = open("/lind/README.md", O_RDONLY); + int escape_errno = errno; + + if(escape_fd != -1) { + fprintf(stderr, "symlink_confinement test: FAIL -- opened " + "/lind/README.md from inside the cage, chroot escape\n"); + close(escape_fd); + assert(0); + } + assert(escape_errno == ENOENT); + printf("symlink_confinement test: PASS\n"); return 0; - -} \ No newline at end of file +} From a8f675b0250f86b31f29b7301a9038f47ba44c58 Mon Sep 17 00:00:00 2001 From: Ashwin Nandan Date: Tue, 25 Aug 2026 16:41:10 -0400 Subject: [PATCH 3/4] Address review comments --- src/cage/src/lib.rs | 1 + src/typemap/Cargo.toml | 2 -- src/typemap/src/path_conversion.rs | 2 +- .../expected/symlink_confinement.output | 1 + .../deterministic/symlink_confinement.c | 22 +++++++++++++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100755 tests/unit-tests/file_tests/deterministic/expected/symlink_confinement.output diff --git a/src/cage/src/lib.rs b/src/cage/src/lib.rs index 488dd9206..397a83581 100644 --- a/src/cage/src/lib.rs +++ b/src/cage/src/lib.rs @@ -3,5 +3,6 @@ pub mod memory; pub mod signal; pub use cage::*; +pub use dashmap::DashMap; pub use memory::*; pub use signal::*; diff --git a/src/typemap/Cargo.toml b/src/typemap/Cargo.toml index 52ad771b2..3e433d309 100644 --- a/src/typemap/Cargo.toml +++ b/src/typemap/Cargo.toml @@ -14,5 +14,3 @@ default = ["fast"] fast = [] secure = [] -[dev-dependencies] -dashmap = "5.1" diff --git a/src/typemap/src/path_conversion.rs b/src/typemap/src/path_conversion.rs index 130080a62..adf660916 100644 --- a/src/typemap/src/path_conversion.rs +++ b/src/typemap/src/path_conversion.rs @@ -226,7 +226,7 @@ mod tests { assert_eq!(path_without_trailing_slashes("///"), "/"); } - use dashmap::DashMap; + use cage::DashMap; // Builds a minimal cage and registers it under 'cageid', so // normpath(path, cageid) can find it via cage::get_cage() diff --git a/tests/unit-tests/file_tests/deterministic/expected/symlink_confinement.output b/tests/unit-tests/file_tests/deterministic/expected/symlink_confinement.output new file mode 100755 index 000000000..88fa0f937 --- /dev/null +++ b/tests/unit-tests/file_tests/deterministic/expected/symlink_confinement.output @@ -0,0 +1 @@ +symlink_confinement test: PASS diff --git a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c index bc8f5afae..ef2cbc61d 100755 --- a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c +++ b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c @@ -10,6 +10,12 @@ int main() { assert(symlink("/etc/passwd", "evil_link") == 0); + /* + NOTE: this only checks that symlink resolution is internally consistent + (following evil_link behaves like a normal read), not that either path + is actually confined. See the /lind/README.md-based checks below for + the actual confinement/escape proof. + */ errno = 0; int direct_fd = open("/etc/passwd", O_RDONLY); int direct_errno = errno; @@ -59,6 +65,22 @@ int main() { } assert(escape_errno == ENOENT); + unlink("evil_link_readme"); + assert(symlink("/lind/README.md", "evil_link_readme") == 0); + + errno = 0; + int link_escape_fd = open("evil_link_readme", O_RDONLY); + int link_escape_errno = errno; + if(link_escape_fd != -1) { + fprintf(stderr, "symlink_confinement test: FAIL -- opened " + "/lind/README.md via symlink from inside the cage, " + "chroot escape via symlink target\n"); + close(link_escape_fd); + assert(0); + } + assert(link_escape_errno == ENOENT); + unlink("evil_link_readme"); + printf("symlink_confinement test: PASS\n"); return 0; } From 17d45ec59df375100d9711c3a2bb88eb3beb7912 Mon Sep 17 00:00:00 2001 From: Ashwin Nandan Date: Wed, 2 Sep 2026 18:37:51 -0400 Subject: [PATCH 4/4] Address review comments --- scripts/test/harnesses/wasmtestreport.py | 111 ++++-------------- .../deterministic/symlink_confinement.c | 75 +++++++----- 2 files changed, 72 insertions(+), 114 deletions(-) diff --git a/scripts/test/harnesses/wasmtestreport.py b/scripts/test/harnesses/wasmtestreport.py index e8c486238..861fd8b46 100644 --- a/scripts/test/harnesses/wasmtestreport.py +++ b/scripts/test/harnesses/wasmtestreport.py @@ -402,14 +402,14 @@ def add_test_result(result, file_path, status, error_type, output, timing_info=N if status.lower() == "success": result["number_of_success"] += 1 result["success"].append(file_path) - logger.debug("SUCCESS") + logger.info("SUCCESS") else: result["number_of_failures"] += 1 result["failures"].append(file_path) error_message = error_types.get(error_type, "Undefined Failure") - logger.debug(f"FAILURE: {error_message}") + logger.error(f"FAILURE: {error_message}") if error_type in error_types: result[f"number_of_{error_type}"] += 1 result[error_type].append(file_path) @@ -1036,7 +1036,19 @@ def pre_test(tests_to_run=None, allow_precompiled=False): except OSError: # Fallback to copying in case symlink creation fails shutil.copy2(readlinkfile_path, symlink_path) - + ''' + ISO-004: create host-only and cage-only sentinel files with distinguishable content + so symlink_confinement.c can prove confinement by checking which file's content it + read, not just whether and open() succeeded + ''' + host_sentinel_dir = Path("/tmp/lind") + host_sentinel_dir.mkdir(parents=True, exist_ok=True) + (host_sentinel_dir / "sentinel.txt").write_text("LIND_HOST_ONLY") + + cage_sentinel_dir = LINDFS_ROOT / "tmp" / "lind" + cage_sentinel_dir.mkdir(parents=True, exist_ok=True) + (cage_sentinel_dir / "sentinel.txt").write_text("LIND_CAGE_ONLY") + # Create required executables if tests_to_run: executable_deps = analyze_executable_dependencies(tests_to_run) @@ -1298,7 +1310,7 @@ def is_file_in_folder(file_path, folder_list): # ---------------------------------------------------------------------- def should_run_file(file_path, run_folders, skip_folders, skip_test_cases): if file_path in skip_test_cases: - logger.debug(f"Skipping {file_path}") + logger.info(f"Skipping {file_path}") return False if skip_folders and is_file_in_folder(file_path, skip_folders): @@ -1580,97 +1592,24 @@ def get_test_mode(source_file): # results - results dictionary, timeout_sec - timeout for tests # - Output: None (modifies results dictionary) # ---------------------------------------------------------------------- -def _get_new_test_case(result, before_test_cases): - """Return the newly recorded test case after running one test, if any.""" - after_test_cases = set(result["test_cases"].keys()) - new_cases = list(after_test_cases - before_test_cases) - if not new_cases: - return None, None - test_name = new_cases[0] - return test_name, result["test_cases"][test_name] - - -def _print_failure_details(failed_tests): - """Print deferred failure details after the compact progress line.""" - if not failed_tests: - return - - print("\nFailures:", flush=True) - for test_name, test_case in failed_tests: - print(f"\n{test_name}", flush=True) - error_type = test_case.get("error_type") - if error_type: - print(f"Error type: {error_type}", flush=True) - - output = test_case.get("output", "") - if output: - print(output.rstrip(), flush=True) - - def run_tests(config, artifacts_root, results, timeout_sec): - """Execute all tests with compact progress and deferred failure details.""" + """Execute all tests""" total_count = len(config['tests_to_run']) - failed_tests = [] - skipped_count = 0 - - print(f"Running {total_count} tests") - for original_source in config['tests_to_run']: + for i, original_source in enumerate(config['tests_to_run']): + logger.info(f"[{i+1}/{total_count}] {original_source}") + dest_source = setup_test_file_in_artifacts(original_source, artifacts_root) # Determine test type and run appropriate test test_mode = get_test_mode(original_source) - if test_mode not in ("deterministic", "fail"): - logger.debug(f"Test file {original_source} is not in a deterministic or fail folder - skipping") - print("S", end="", flush=True) - skipped_count += 1 - continue - - result_bucket = results[test_mode] - before_test_cases = set(result_bucket["test_cases"].keys()) - if test_mode == "deterministic": - test_single_file_deterministic( - dest_source, - result_bucket, - timeout_sec, - allow_precompiled=config['allow_precompiled'], - ) + test_single_file_deterministic(dest_source, results["deterministic"], timeout_sec, allow_precompiled=config['allow_precompiled']) + elif test_mode == "fail": + test_single_file_fail(dest_source, results["fail"], timeout_sec, allow_precompiled=config['allow_precompiled']) else: - test_single_file_fail( - dest_source, - result_bucket, - timeout_sec, - allow_precompiled=config['allow_precompiled'], - ) - - test_name, test_case = _get_new_test_case(result_bucket, before_test_cases) - if test_case is None: - print("S", end="", flush=True) - skipped_count += 1 - continue - - if str(test_case.get("status", "")).lower() == "success": - print(".", end="", flush=True) - else: - print("X", end="", flush=True) - failed_tests.append((str(original_source), test_case)) - - print("\n", flush=True) - - passed_count = sum(results[k]["number_of_success"] for k in ("deterministic", "fail")) - failed_count = sum(results[k]["number_of_failures"] for k in ("deterministic", "fail")) - - summary_parts = [ - f"{passed_count} passed", - f"{failed_count} failed", - ] - if skipped_count: - summary_parts.append(f"{skipped_count} skipped") - - print(", ".join(summary_parts), flush=True) - _print_failure_details(failed_tests) - sys.stdout.flush() + # Log warning for tests not in deterministic/fail folders + logger.warning(f"Test file {original_source} is not in a deterministic or fail folder - skipping") def build_fail_message(case: str, native_output: str, wasm_output: str, native_retcode=None, wasm_retcode=None) -> str: """ diff --git a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c index ef2cbc61d..941717f26 100755 --- a/tests/unit-tests/file_tests/deterministic/symlink_confinement.c +++ b/tests/unit-tests/file_tests/deterministic/symlink_confinement.c @@ -13,8 +13,8 @@ int main() { /* NOTE: this only checks that symlink resolution is internally consistent (following evil_link behaves like a normal read), not that either path - is actually confined. See the /lind/README.md-based checks below for - the actual confinement/escape proof. + is actually confined. See the sentinel-file checks below for actual + confinement/escape proof. */ errno = 0; int direct_fd = open("/etc/passwd", O_RDONLY); @@ -46,40 +46,59 @@ int main() { unlink("evil_link"); errno = 0; - /* - NOTE: /lind/README.md is specific to this dev-conatainer's mount - layout(repo checked out at /lind, matching LINDFS_ROOT's hardcoded - assumption in sysdefs). If this runs somewhere that mounts the repo - differently, this path may not exist at all, in which case this - check would accidentally pass via ENOENT. - Re-verify this path is valid if test is run in a new environment. - */ - int escape_fd = open("/lind/README.md", O_RDONLY); - int escape_errno = errno; + int sentinel_fd = open("/tmp/lind/sentinel.txt", O_RDONLY); - if(escape_fd != -1) { - fprintf(stderr, "symlink_confinement test: FAIL -- opened " - "/lind/README.md from inside the cage, chroot escape\n"); - close(escape_fd); + if(sentinel_fd == -1) { + fprintf(stderr, "symlink_confinement test: FAIL - could not open " + "/tmp/lind/sentinel.txt from inside the cage (errno %d)\n", errno); assert(0); } - assert(escape_errno == ENOENT); - unlink("evil_link_readme"); - assert(symlink("/lind/README.md", "evil_link_readme") == 0); + char sentinel_buf[64] = {0}; + ssize_t sentinel_n = read(sentinel_fd, sentinel_buf, sizeof(sentinel_buf) - 1); + close(sentinel_fd); + + assert(sentinel_n >= 0); + sentinel_buf[sentinel_n] = '\0'; + + if(strcmp(sentinel_buf, "LIND_HOST_ONLY") == 0) { + fprintf(stderr, "symlink_confinement test: FAIL - read host sentinel " + "from inside the cage, chroot escape\n"); + assert(0); + } else if(strcmp(sentinel_buf, "LIND_CAGE_ONLY") != 0) { + fprintf(stderr, "symlink_confinement test: FAIL - unexpected sentinel " + "content: \"%s\"\n", sentinel_buf); + assert(0); + } + unlink("evil_link_sentinel"); + assert(symlink("/tmp/lind/sentinel.txt", "evil_link_sentinel") == 0); errno = 0; - int link_escape_fd = open("evil_link_readme", O_RDONLY); - int link_escape_errno = errno; - if(link_escape_fd != -1) { - fprintf(stderr, "symlink_confinement test: FAIL -- opened " - "/lind/README.md via symlink from inside the cage, " - "chroot escape via symlink target\n"); - close(link_escape_fd); + int link_sentinel_fd = open("evil_link_sentinel", O_RDONLY); + if(link_sentinel_fd == -1) { + fprintf(stderr, "symlink_confinement test: FAIL - could not open " + "evil_link sentinel from inside the cage (errno %d)\n", errno); assert(0); } - assert(link_escape_errno == ENOENT); - unlink("evil_link_readme"); + + char link_sentinel_buf[64] = {0}; + ssize_t link_sentinel_n = read(link_sentinel_fd, link_sentinel_buf, sizeof(link_sentinel_buf) - 1); + close(link_sentinel_fd); + + assert(link_sentinel_n >= 0); + link_sentinel_buf[link_sentinel_n] = '\0'; + + if(strcmp(link_sentinel_buf, "LIND_HOST_ONLY") == 0) { + fprintf(stderr, "symlink_confinement test: FAIL - read host sentinel " + "via symlink from inside the cage, chroot escape via symlink target\n"); + assert(0); + } else if(strcmp(link_sentinel_buf, "LIND_CAGE_ONLY") != 0) { + fprintf(stderr, "symlink_confinement test: FAIL - unexpected sentinel " + "content via symlink: \"%s\"\n", link_sentinel_buf); + assert(0); + } + + unlink("evil_link_sentinel"); printf("symlink_confinement test: PASS\n"); return 0;