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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 25 additions & 86 deletions scripts/test/harnesses/wasmtestreport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
"""
Expand Down
1 change: 1 addition & 0 deletions src/cage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ pub mod memory;
pub mod signal;

pub use cage::*;
pub use dashmap::DashMap;
pub use memory::*;
pub use signal::*;
1 change: 1 addition & 0 deletions src/typemap/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ sysdefs = { path = "../sysdefs" }
default = ["fast"]
fast = []
secure = []

65 changes: 65 additions & 0 deletions src/typemap/src/path_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,69 @@ mod tests {
assert_eq!(path_without_trailing_slashes("/"), "/");
assert_eq!(path_without_trailing_slashes("///"), "/");
}

use cage::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/../etc/passwd"), cageid);

assert_eq!(result, PathBuf::from("/etc/passwd"));
}
Comment thread
vidyalakshmir marked this conversation as resolved.

#[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"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
symlink_confinement test: PASS
105 changes: 105 additions & 0 deletions tests/unit-tests/file_tests/deterministic/symlink_confinement.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main() {
unlink("evil_link");

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 sentinel-file checks below for actual
confinement/escape proof.
*/
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");

errno = 0;
int sentinel_fd = open("/tmp/lind/sentinel.txt", O_RDONLY);

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);
}

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_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);
}

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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that opening via evil_link behaves identically to opening /etc/passwd directly. if confinement were totally broken and both opens hit the host's real /etc/passwd, the two would still match each other and the test would still PASS.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree with this.

return 0;
}