diff --git a/changelog.d/13935-snap-census-protection.md b/changelog.d/13935-snap-census-protection.md new file mode 100644 index 00000000000..4f3cefee81b --- /dev/null +++ b/changelog.d/13935-snap-census-protection.md @@ -0,0 +1 @@ +Fix instrumented startup census page protection after the first-write watcher ends in the weights run; keep bulk and late-root protection consistent with the page policy. diff --git a/crates/perry-runtime/src/calendar.rs b/crates/perry-runtime/src/calendar.rs index 6951a5616df..0ed1c01d77b 100644 --- a/crates/perry-runtime/src/calendar.rs +++ b/crates/perry-runtime/src/calendar.rs @@ -76,6 +76,40 @@ pub(crate) fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u /// via the `/etc/localtime` symlink; falls back to `"UTC"`. Cached for the /// process lifetime (the host zone doesn't change under us). pub fn host_time_zone_name() -> &'static str { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("locale.time_zone", &format!("{:?}", ()), || { + use std::sync::OnceLock; + static TZ: OnceLock = OnceLock::new(); + crate::once_init::get_or_init(&TZ, || { + if let Some(raw) = std::env::var_os("TZ") { + if let Some(s) = raw.to_str() { + // A `TZ` of `:Europe/Berlin` / `:/path/zoneinfo/Europe/Berlin` + // (leading colon) or a bare IANA id. Empty `TZ` means UTC. + let s = s.trim().strip_prefix(':').unwrap_or_else(|| s.trim()); + if s.is_empty() { + return "UTC".to_string(); + } + if let Some(name) = iana_id_from_zoneinfo_path(s) { + return name; + } + return s.to_string(); + } + } + #[cfg(unix)] + { + if let Ok(target) = std::fs::read_link("/etc/localtime") { + if let Some(name) = iana_id_from_zoneinfo_path(&target.to_string_lossy()) { + return name; + } + } + } + "UTC".to_string() + }) + .as_str() + }); + } + use std::sync::OnceLock; static TZ: OnceLock = OnceLock::new(); crate::once_init::get_or_init(&TZ, || { diff --git a/crates/perry-runtime/src/clock.rs b/crates/perry-runtime/src/clock.rs index 998c76e828d..3090a2f8b0f 100644 --- a/crates/perry-runtime/src/clock.rs +++ b/crates/perry-runtime/src/clock.rs @@ -1,6 +1,20 @@ //! Numeric wall and performance clocks. use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) fn wall_millis() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.wall_millis", &format!("{:?}", ()), || { + crate::effect!(host_read, clock); + if let Some(now) = crate::timer::js_mock_timers_date_now() { + return now; + } + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) + }); + } + crate::effect!(host_read, clock); if let Some(now) = crate::timer::js_mock_timers_date_now() { return now; @@ -15,6 +29,14 @@ pub(crate) fn wall_millis() -> f64 { /// Returns ms since performance.timeOrigin as f64. #[no_mangle] pub extern "C" fn js_performance_now() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.performance", &format!("{:?}", ()), || { + crate::effect!(host_read, clock); + crate::perf_clock::performance_now_ms() + }); + } + crate::effect!(host_read, clock); crate::perf_clock::performance_now_ms() } diff --git a/crates/perry-runtime/src/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index aecf3bd412a..44d4aa1416d 100644 --- a/crates/perry-runtime/src/error_stack_frames.rs +++ b/crates/perry-runtime/src/error_stack_frames.rs @@ -448,6 +448,28 @@ fn capture_encoded( origin: walk::Origin, want: usize, ) -> ([u8; MAX_CAPTURED_FRAMES * PC_CHARS], usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("stack.capture", &format!("{:?}", (want,)), || { + let mut pcs = [0usize; MAX_CAPTURED_FRAMES]; + let n = walk::capture_js(origin, &mut pcs, want); + if diag_enabled() { + eprintln!( + "[stackdiag] capture: frames={n} want={want} top={:#x} fp0={:#x} pcs={:x?}", + walk::diag_stack_top(), + walk::diag_frame_pointer(), + &pcs[..n] + ); + } + let mut blob = [0u8; MAX_CAPTURED_FRAMES * PC_CHARS]; + if n == 0 { + return (blob, 0); + } + let len = encode_pcs(&pcs[..n], &mut blob); + (blob, len) + }); + } + let mut pcs = [0usize; MAX_CAPTURED_FRAMES]; let n = walk::capture_js(origin, &mut pcs, want); if diag_enabled() { @@ -901,6 +923,14 @@ fn stack_trace_limit() -> usize { /// it (see [`walk::origin`]). #[inline(always)] pub(crate) fn capture_frames_payload() -> Vec { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("stack.payload", &format!("{:?}", ()), || { + let origin = walk::origin(); + capture_frames_payload_from(origin) + }); + } + let origin = walk::origin(); capture_frames_payload_from(origin) } @@ -946,6 +976,38 @@ pub(crate) fn join_stack(head: &str, lines: &str) -> String { /// ran under (an [`ALPHABET`] index); the frame lines stop there. An empty /// payload is a capture that found nothing. pub(crate) fn frames_payload_to_lines(payload: &[u8]) -> String { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call( + "stack.rendered", + &format!("{:?}", (payload,)), + || { + let (limit, payload) = match payload.split_first() { + Some((c, rest)) => match decode_char(*c) { + Some(limit) => (limit as usize, rest), + None => (RENDER_LIMIT, payload), + }, + None => (RENDER_LIMIT, payload), + }; + if limit == 0 { + return String::new(); + } + let (blob, recorded) = match payload.iter().position(|b| *b == b'\n') { + Some(at) => (&payload[..at], std::str::from_utf8(&payload[at + 1..]).ok()), + None => (payload, None), + }; + // The recorded #5247 line is the innermost frame and counts toward it. + let resolved = render_frames(blob, limit - usize::from(recorded.is_some())); + match (recorded, resolved) { + (Some(line), Some(frames)) => format!("{line}\n{frames}"), + (Some(line), None) => line.to_string(), + (None, Some(frames)) => frames, + (None, None) => " at ".to_string(), + } + }, + ); + } + let (limit, payload) = match payload.split_first() { Some((c, rest)) => match decode_char(*c) { Some(limit) => (limit as usize, rest), diff --git a/crates/perry-runtime/src/i18n.rs b/crates/perry-runtime/src/i18n.rs index 0cce7d6e717..c261d0fba28 100644 --- a/crates/perry-runtime/src/i18n.rs +++ b/crates/perry-runtime/src/i18n.rs @@ -175,6 +175,65 @@ pub extern "C" fn perry_i18n_init( /// Detect the system locale from environment or platform APIs. fn detect_system_locale() -> Option { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("locale.system", &format!("{:?}", ()), || { + crate::effect!(host_read, locale); + // 1. Explicit POSIX env overrides first: LANGUAGE (GNU extension, + // colon-separated list), then LC_ALL > LC_MESSAGES > LANG. A user who + // runs `LANG=de_DE.UTF-8 ./app` has stated a preference; platform + // APIs like CFLocaleCopyCurrent would otherwise mask it on macOS, + // which pinned every console binary to the Mac's system language no + // matter what the environment said. GUI launches (Finder, + // SpringBoard, …) don't set these vars, so apps still fall through + // to the platform-native detection below. + if let Ok(lang) = std::env::var("LANGUAGE") { + if let Some(first) = lang.split(':').next() { + if !first.is_empty() && first != "C" && first != "POSIX" { + return Some(first.to_string()); + } + } + } + for var in &["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(val) = std::env::var(var) { + if !val.is_empty() && val != "C" && val != "POSIX" { + return Some(val); + } + } + } + + // 2. Platform-native APIs (GUI apps and env-less environments). + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "watchos", + target_os = "tvos", + target_os = "visionos" + ))] + { + if let Some(locale) = detect_apple_locale() { + return Some(locale); + } + } + + #[cfg(target_os = "windows")] + { + if let Some(locale) = detect_windows_locale() { + return Some(locale); + } + } + + #[cfg(target_os = "android")] + { + if let Some(locale) = detect_android_locale() { + return Some(locale); + } + } + + None + }); + } + crate::effect!(host_read, locale); // 1. Explicit POSIX env overrides first: LANGUAGE (GNU extension, // colon-separated list), then LC_ALL > LC_MESSAGES > LANG. A user who diff --git a/crates/perry-runtime/src/math.rs b/crates/perry-runtime/src/math.rs index f1d696cb187..d687cf51da2 100644 --- a/crates/perry-runtime/src/math.rs +++ b/crates/perry-runtime/src/math.rs @@ -285,6 +285,15 @@ pub extern "C" fn js_math_hypot(a: f64, b: f64) -> f64 { /// Math.random() -> number (0 <= x < 1) #[no_mangle] pub extern "C" fn js_math_random() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("random.Math", &format!("{:?}", ()), || { + crate::effect!(host_read, random); + let mut rng = rand::rng(); + rng.random::() + }); + } + crate::effect!(host_read, random); let mut rng = rand::rng(); rng.random::() diff --git a/crates/perry-runtime/src/navigator.rs b/crates/perry-runtime/src/navigator.rs index 38fbfb51170..c82f470f7a2 100644 --- a/crates/perry-runtime/src/navigator.rs +++ b/crates/perry-runtime/src/navigator.rs @@ -81,6 +81,20 @@ fn normalize_navigator_locale(locale: &str) -> Option { } fn navigator_language() -> String { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("locale.navigator", &format!("{:?}", ()), || { + crate::effect!(host_read, locale); + std::env::var("LC_ALL") + .ok() + .filter(|locale| !locale.is_empty() && locale != "C" && locale != "POSIX") + .or_else(|| std::env::var("LANG").ok()) + .as_deref() + .and_then(normalize_navigator_locale) + .unwrap_or_else(|| "en-US".to_string()) + }); + } + crate::effect!(host_read, locale); std::env::var("LC_ALL") .ok() diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 6d6ba51aa20..6b29a115abd 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -2456,7 +2456,11 @@ fn shape_descriptor_mint_fresh( // complete descriptor. // SAFETY: no slab reference is held; `slab()` above went out of scope. let id = unsafe { table.slab_mut().publish_dynamic(id, record) } - .map_err(|_| ShapeDescriptorError::IdExhausted)? + .map_err(|_error| { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + snap22::snapshot("publish_error", _error as u64 + 1); + ShapeDescriptorError::IdExhausted + })? .word .0; // `id` was just handed out by `alloc_shape_id`, which never reuses a @@ -2730,6 +2734,8 @@ pub(crate) unsafe fn receiver_extra_summary(_obj: *const crate::object::ObjectHe #[optimize(size)] #[inline(never)] fn shape_id_exhausted_abort() -> ! { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + snap22::snapshot("abort", 0); eprintln!("Perry ShapeId space exhausted; refusing to publish an untracked object shape"); std::process::abort() } @@ -7405,3 +7411,7 @@ pub(crate) fn recompute_record_handle_carriers() { } } } + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[path = "shapes_snap22.rs"] +pub(crate) mod snap22; diff --git a/crates/perry-runtime/src/object/shapes_snap22.rs b/crates/perry-runtime/src/object/shapes_snap22.rs new file mode 100644 index 00000000000..9ea0082e19d --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_snap22.rs @@ -0,0 +1,58 @@ +//! Native-only diagnostic evidence; no shape authority or allocator behavior changes. +use super::*; +use std::fmt::Write; +struct Line { + bytes: [u8; 2048], + len: usize, +} +impl std::fmt::Write for Line { + fn write_str(&mut self, text: &str) -> std::fmt::Result { + if self.len + text.len() > self.bytes.len() { + return Err(std::fmt::Error); + } + self.bytes[self.len..self.len + text.len()].copy_from_slice(text.as_bytes()); + self.len += text.len(); + Ok(()) + } +} +pub(crate) fn snapshot(label: &str, error: u64) { + let saved_errno = unsafe { *libc::__errno_location() }; + let _scope = crate::snap_census::record_scope(); + let mut out = Line { + bytes: [0; 2048], + len: 0, + }; + let _ = write!(out, "SNAP22_SHAPES {label} error={error} counters="); + for next in SHAPE_ID_NEXT.iter().chain(EXOTIC_SHAPE_ID_NEXT.iter()) { + let _ = write!( + out, + "{:08x},", + next.load(std::sync::atomic::Ordering::Relaxed) + ); + } + let table = &crate::state::state().shapes; + let slab = table.slab(); + let _ = write!( + out, + " semantic={:x} slab={:?}", + SHAPE_SEMANTIC_NEXT.load(std::sync::atomic::Ordering::Relaxed), + slab.snap22_counts() + ); + let mut pcs = [0; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + let _ = writeln!(out, " pcs={:x?}", &pcs[..n.min(12)]); + unsafe { + let mut at = 0; + while at < out.len { + let n = libc::write(2, out.bytes.as_ptr().add(at).cast(), out.len - at); + if n > 0 { + at += n as usize; + } else if n < 0 && *libc::__errno_location() == libc::EINTR { + continue; + } else { + break; + } + } + *libc::__errno_location() = saved_errno; + } +} diff --git a/crates/perry-runtime/src/object/shapes_store/slab.rs b/crates/perry-runtime/src/object/shapes_store/slab.rs index 0c5534099ef..173ad03ef66 100644 --- a/crates/perry-runtime/src/object/shapes_store/slab.rs +++ b/crates/perry-runtime/src/object/shapes_store/slab.rs @@ -733,3 +733,10 @@ impl ShapeSlab { dir.clear(); } } + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +impl ShapeSlab { + pub(in super::super) fn snap22_counts(&self) -> (usize, u32, u32, usize) { + (self.len, self.next_row, self.chunk_end, self.region.capacity()) + } +} diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 4fc6fd2a165..4a8acd7657e 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -555,6 +555,18 @@ pub extern "C" fn js_process_uptime() -> f64 { /// Get the current working directory #[no_mangle] pub extern "C" fn js_process_cwd() -> *mut StringHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("cwd", &format!("{:?}", ()), || { + crate::effect!(host_read, cwd); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| String::new()); + let bytes = cwd.as_bytes(); + js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + }); + } + crate::effect!(host_read, cwd); let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().into_owned()) @@ -580,6 +592,21 @@ pub unsafe extern "C" fn js_set_process_entry_path(ptr: *const u8, len: u32) { #[no_mangle] pub extern "C" fn js_process_argv_value() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("argv.value", &format!("{:?}", ()), || { + crate::effect!(host_read, argv); + if crate::process::worker_restrictions::in_node_worker() { + if let Some(value) = + crate::object::native_namespace_prop_override_get("process", "argv") + { + return value; + } + } + crate::value::js_nanbox_pointer(js_process_argv() as i64) + }); + } + crate::effect!(host_read, argv); if crate::process::worker_restrictions::in_node_worker() { if let Some(value) = crate::object::native_namespace_prop_override_get("process", "argv") { @@ -600,6 +627,53 @@ pub(crate) fn allocate_process_argv(capacity: u32) -> *mut ArrayHeader { /// Returns: string[] (array of NaN-boxed string pointers) #[no_mangle] pub extern "C" fn js_process_argv() -> *mut ArrayHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("argv", &format!("{:?}", ()), || { + crate::effect!(host_read, argv); + use crate::array::js_array_push_f64; + use crate::value::js_nanbox_string; + + // #9401: never `std::env::args()` — it panics on a non-UTF-8 argument. + let args: Vec = crate::process::process_args_lossy().collect(); + // Match Node.js behavior: argv[0] = binary path (like node path), + // argv[1] = source entry path (like script path), argv[2+] = user args. + // Node.js: ["/usr/bin/node", "/path/to/script.js", ...user_args] + // Compiled: ["/path/to/binary", "/path/to/entry.ts", ...user_args] + // If an older/foreign codegen does not seed the entry path, retain the + // historical binary-path fallback while keeping user args at index 2+. + let arr = allocate_process_argv((args.len() + 1) as u32); + + let mut result = arr; + if let Some(binary_path) = args.first() { + // Node exposes execPath here; argv0 separately retains the launch name. + let exec_path = crate::process::process_exec_path_string(); + let bytes = exec_path.as_bytes(); + let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let nanboxed = js_nanbox_string(str_ptr as i64); + result = js_array_push_f64(result, nanboxed); + // argv[1]: compiler-seeded source entry path. + let entry_path = PROCESS_ENTRY_PATH + .get() + .map(String::as_str) + .unwrap_or(binary_path); + let entry_bytes = entry_path.as_bytes(); + let str_ptr2 = js_string_from_bytes(entry_bytes.as_ptr(), entry_bytes.len() as u32); + let nanboxed2 = js_nanbox_string(str_ptr2 as i64); + result = js_array_push_f64(result, nanboxed2); + } + // argv[2+]: user arguments + for arg in args.iter().skip(1) { + let bytes = arg.as_bytes(); + let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let nanboxed = js_nanbox_string(str_ptr as i64); + result = js_array_push_f64(result, nanboxed); + } + + result + }); + } + crate::effect!(host_read, argv); use crate::array::js_array_push_f64; use crate::value::js_nanbox_string; @@ -659,6 +733,27 @@ pub(crate) fn current_process_id() -> u32 { /// Get the current process ID (process.pid) #[no_mangle] pub extern "C" fn js_process_pid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.pid", &format!("{:?}", ()), || { + #[cfg(unix)] + unsafe { + libc::getpid() as f64 + } + #[cfg(windows)] + { + extern "system" { + fn GetCurrentProcessId() -> u32; + } + unsafe { GetCurrentProcessId() as f64 } + } + #[cfg(not(any(unix, windows)))] + { + 0.0 + } + }); + } + #[cfg(unix)] unsafe { libc::getpid() as f64 @@ -679,6 +774,25 @@ pub extern "C" fn js_process_pid() -> f64 { /// Get the parent process ID (process.ppid) #[no_mangle] pub extern "C" fn js_process_ppid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.ppid", &format!("{:?}", ()), || { + #[cfg(unix)] + unsafe { + libc::getppid() as f64 + } + #[cfg(windows)] + { + // Fallback: return 1 (system process) + 1.0 + } + #[cfg(not(any(unix, windows)))] + { + 0.0 + } + }); + } + #[cfg(unix)] unsafe { libc::getppid() as f64 @@ -752,6 +866,23 @@ pub extern "C" fn js_process_versions() -> f64 { /// process.hrtime.bigint() -> bigint of nanoseconds #[no_mangle] pub extern "C" fn js_process_hrtime_bigint() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.hrtime_bigint", &format!("{:?}", ()), || { + crate::effect!(host_read, clock); + use crate::bigint_hooks::js_bigint_from_u64; + use crate::value::js_nanbox_bigint; + + let elapsed = get_hrtime_start().elapsed(); + // Add a base offset so the value is always > 0 even on the first call + let nanos = elapsed.as_nanos() as u64 + 1_000_000_000; + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("clock.hrtime.nanos", &nanos); + let bi = js_bigint_from_u64(nanos); + js_nanbox_bigint(bi as i64) + }); + } + crate::effect!(host_read, clock); use crate::bigint_hooks::js_bigint_from_u64; use crate::value::js_nanbox_bigint; @@ -770,6 +901,69 @@ pub extern "C" fn js_process_hrtime_bigint() -> f64 { /// diff is returned (clamped to non-negative). #[no_mangle] pub extern "C" fn js_process_hrtime(prior: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.hrtime", &format!("{:?}", (prior,)), || { + crate::effect!(host_read, clock); + use crate::value::JSValue; + // #2013 — Node throws TypeError ERR_INVALID_ARG_TYPE when `prior` + // is supplied but isn't an Array (e.g. `process.hrtime('abc')`). + // Undefined falls through to the no-prior baseline read; an array + // with NaN-y entries is silently zeroed by `extract_hrtime_prior`, + // matching Node's lenient numeric coercion inside the array. + let prior_jv = JSValue::from_bits(prior.to_bits()); + if !prior_jv.is_undefined() && !crate::process::is_array_value(prior_jv) { + let message = format!( + "The \"time\" argument must be an instance of Array. Received {}", + crate::fs::validate::describe_received(prior) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + // #3039 — a supplied prior tuple must have exactly two elements; Node + // throws RangeError [ERR_OUT_OF_RANGE] ("It must be 2. Received ") + // for any other length. + if !prior_jv.is_undefined() { + let arr = prior_jv.as_pointer::(); + let len = crate::array::js_array_length(arr); + if len != 2 { + let message = format!( + "The value of \"time\" is out of range. It must be 2. Received {}", + len + ); + crate::fs::validate::throw_range_error_with_code(&message); + } + } + let elapsed = get_hrtime_start().elapsed(); + let total_ns = elapsed.as_nanos() as u64 + 1_000_000_000; + let mut secs = (total_ns / 1_000_000_000) as i64; + let mut nanos = (total_ns % 1_000_000_000) as i64; + + let undef_bits = crate::value::TAG_UNDEFINED; + if prior.to_bits() != undef_bits { + let (prev_s, prev_ns) = extract_hrtime_prior(prior); + let mut diff_s = secs - prev_s; + let mut diff_ns = nanos - prev_ns; + if diff_ns < 0 { + diff_s -= 1; + diff_ns += 1_000_000_000; + } + if diff_s < 0 { + diff_s = 0; + diff_ns = 0; + } + secs = diff_s; + nanos = diff_ns; + } + + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("clock.hrtime.tuple", &(secs, nanos)); + let arr = crate::array::js_array_alloc(2); + let arr = crate::array::js_array_push(arr, JSValue::number(secs as f64)); + let arr = crate::array::js_array_push(arr, JSValue::number(nanos as f64)); + f64::from_bits(JSValue::pointer(arr as *const u8).bits()) + }); + } + crate::effect!(host_read, clock); use crate::value::JSValue; // #2013 — Node throws TypeError ERR_INVALID_ARG_TYPE when `prior` diff --git a/crates/perry-runtime/src/perf_clock.rs b/crates/perry-runtime/src/perf_clock.rs index afe88c835aa..ff6ecf7dcc4 100644 --- a/crates/perry-runtime/src/perf_clock.rs +++ b/crates/perry-runtime/src/perf_clock.rs @@ -14,6 +14,16 @@ struct PerfClock { static PERF_CLOCK: OnceLock = OnceLock::new(); fn wall_clock_ms() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.time_origin", &format!("{:?}", ()), || { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0) + }); + } + SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs_f64() * 1000.0) @@ -32,6 +42,15 @@ pub(crate) fn init_time_origin() { } pub(crate) fn time_origin_ms() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call( + "clock.time_origin_observed", + &format!("{:?}", ()), + || perf_clock().time_origin_ms, + ); + } + perf_clock().time_origin_ms } diff --git a/crates/perry-runtime/src/process/credentials.rs b/crates/perry-runtime/src/process/credentials.rs index 4d29e421d4f..9f5fc77cdfd 100644 --- a/crates/perry-runtime/src/process/credentials.rs +++ b/crates/perry-runtime/src/process/credentials.rs @@ -12,6 +12,21 @@ use crate::value::JSValue; /// POSIX credential accessors (#1408). On non-unix targets each returns 0. #[no_mangle] pub extern "C" fn js_process_getuid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.uid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::getuid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { @@ -24,6 +39,21 @@ pub extern "C" fn js_process_getuid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_geteuid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.euid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::geteuid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { @@ -36,6 +66,21 @@ pub extern "C" fn js_process_geteuid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_getgid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.gid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::getgid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { @@ -48,6 +93,21 @@ pub extern "C" fn js_process_getgid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_getegid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.egid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::getegid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 14dc614e85a..b1988b93411 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -1461,6 +1461,10 @@ fn js_process_env_impl() -> f64 { } let vars: Vec<(String, String)> = std::env::vars().collect(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if crate::snap_census::runner_on_for_inputs() { + crate::snap_census::record_input_value("env_block", &vars); + } // Pad alloc_limit so small env sets still have headroom; large // environments (CI runners) spill to the overflow Vec path. let alloc_limit = std::cmp::max(vars.len() as u32, crate::object::INLINE_SLOT_FLOOR as u32); diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index f17bf5e7808..3c55a6d7c73 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -681,3 +681,59 @@ pub(crate) fn init_guard(addr: usize, guard: usize) { #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] let _ = (addr, guard); } + +// Values and calls are retained only by the Linux instrumented census. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub use runner::input_record::Value as InputValue; + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub fn input_call(operation: &str, arguments: &str, call: impl FnOnce() -> T) -> T { + if !runner::inputs_on() || !runner::on_main_thread() { + return call(); + } + let before_errno = unsafe { *libc::__errno_location() }; + let token = { + let _scope = runner::input_record::Scope::enter(); + runner::input_event(operation, arguments.to_owned()) + }; + unsafe { + *libc::__errno_location() = before_errno; + } + runner::input_scope(1); + let result = call(); + let saved_errno = unsafe { *libc::__errno_location() }; + runner::input_scope(-1); + { + let _scope = runner::input_record::Scope::enter(); + runner::input_event("result", format!("{token}:{}", result.input_value())); + } + unsafe { + *libc::__errno_location() = saved_errno; + } + result +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub fn runner_on_for_inputs() -> bool { + runner::inputs_on() && runner::on_main_thread() +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub fn record_input_value(operation: &str, value: &T) { + if runner::inputs_on() && runner::on_main_thread() { + let _scope = runner::input_record::Scope::enter(); + runner::input_event(operation, value.input_value()); + } +} + +/// Expand a stdlib observation only in the runtime's instruments cohort. +/// The empty production expansion needs no new stdlib build-script dependency. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[macro_export] +macro_rules! census_inputs { ($($code:tt)*) => {{ $($code)* }}; } +#[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] +#[macro_export] +macro_rules! census_inputs { ($($code:tt)*) => {}; } + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn record_scope() -> impl Drop { runner::input_record::Scope::enter() } diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs new file mode 100644 index 00000000000..1f3e97a974f --- /dev/null +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -0,0 +1,495 @@ +//! Ordered execution-prefix evidence, streamed through native storage. +use super::{read_ir, top_init, State}; +use std::fmt::Write as _; + +pub(super) struct Record { + pub seq: u64, + pub writes: Vec>, + pub watching: bool, + pub watch_enabled: bool, + pub call_depth: u32, + pub allocated_bytes: u64, + fd: i32, +} +impl Default for Record { + fn default() -> Self { + Self { + seq: 0, + writes: Vec::new(), + watching: false, + watch_enabled: true, + call_depth: 0, + allocated_bytes: 0, + fd: -1, + } + } +} +pub(super) struct Write; + +pub(super) fn open(st: &mut State) -> std::io::Result<()> { + use std::os::fd::IntoRawFd; + let saved = unsafe { *libc::__errno_location() }; + let _scope = Scope::enter(); + st.inputs.fd = std::fs::File::create(format!("{}.inputs.tsv", st.out))?.into_raw_fd(); + emit( + st.inputs.fd, + b"seq\tir\tinit\tstack_parts\top\tvalue_hex\tpcs\tallocated_bytes\n", + ); + unsafe { + *libc::__errno_location() = saved; + } + Ok(()) +} + +/// The companion records these intervals as diagnostic execution. The marker +/// and file writes preserve errno and never enter a JS allocator. +pub(crate) fn logger_scope(enter: bool) { + if enter { + super::logging_scope(true); + } + unsafe { + let saved = *libc::__errno_location(); + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + u64::from(enter), + 0x5322 as libc::c_int, + ); + *libc::__errno_location() = saved; + } + if !enter { + super::logging_scope(false); + } +} +pub(crate) struct Scope; +impl Scope { + pub(crate) fn enter() -> Self { + logger_scope(true); + Self + } +} +impl Drop for Scope { + fn drop(&mut self) { + logger_scope(false); + } +} +fn emit(fd: i32, bytes: &[u8]) { + let saved = unsafe { *libc::__errno_location() }; + let mut at = 0; + while at < bytes.len() { + let n = unsafe { libc::write(fd, bytes.as_ptr().add(at).cast(), bytes.len() - at) }; + if n > 0 { + at += n as usize; + } else if n < 0 && unsafe { *libc::__errno_location() } == libc::EINTR { + continue; + } else { + let message = b"SNAP22 input record write failed\n"; + unsafe { + libc::write(2, message.as_ptr().cast(), message.len()); + libc::_exit(92); + } + } + } + unsafe { + *libc::__errno_location() = saved; + } +} +pub(super) fn event(st: &mut State, op: &str, value: String) -> u64 { + let saved = unsafe { *libc::__errno_location() }; + logger_scope(true); + let ir = read_ir(st.perf_fd); + let init = top_init(st); + st.inputs.seq += 1; + let seq = st.inputs.seq; + st.inputs.writes.resize_with(st.inits.len(), || None); + let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + let mut six = [0; 6]; + for (to, from) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { + *to = *from; + } + let mut row = format!("{seq}\t{ir}\t{init}\t"); + for (i, f) in st.frames.iter().enumerate() { + if i != 0 { + row.push(','); + } + let _ = write!( + row, + "{}:{}:{}", + f.init, + st.inits[f.init as usize].parts, + u8::from(f.kind == super::FrameKind::Job) + ); + } + let _ = write!(row, "\t{op}\t{}\t", hex(value.as_bytes())); + for (i, pc) in six.iter().enumerate() { + if i != 0 { + row.push(','); + } + let _ = write!(row, "{pc:x}"); + } + let _ = writeln!(row, "\t{}", st.inputs.allocated_bytes); + emit(st.inputs.fd, row.as_bytes()); + logger_scope(false); + unsafe { + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + seq as libc::off_t, + 0x5321 as libc::c_int, + ); + *libc::__errno_location() = saved; + } + seq +} +pub(super) fn watch(st: &State) -> bool { + let id = top_init(st) as usize; + st.inputs.watch_enabled + && st.inputs.call_depth == 0 + && id != 0 + && st.inits[id].parts >= 2 + && st.inputs.writes.get(id).is_some_and(Option::is_none) +} + +struct SignalRow { + bytes: [u8; N], + len: usize, +} +impl std::fmt::Write for SignalRow { + fn write_str(&mut self, value: &str) -> std::fmt::Result { + if self.len + value.len() > self.bytes.len() { + return Err(std::fmt::Error); + } + self.bytes[self.len..self.len + value.len()].copy_from_slice(value.as_bytes()); + self.len += value.len(); + Ok(()) + } +} +/// No allocation, formatting heap, JS API or deferred payload is permitted in +/// the fault handler. An oversized frame record fails explicitly, never truncates. +pub(super) fn remember_write(st: &mut State, word: usize, pc: usize) { + let saved = unsafe { *libc::__errno_location() }; + let id = top_init(st) as usize; + st.inputs.seq += 1; + st.inputs.writes[id] = Some(Write); + let ir = read_ir(st.perf_fd); + logger_scope(true); + let mut row = SignalRow { + bytes: [0; 65536], + len: 0, + }; + let result = (|| -> std::fmt::Result { + write!(row, "{}\t{ir}\t{id}\t", st.inputs.seq)?; + for (i, f) in st.frames.iter().enumerate() { + if i != 0 { + row.write_char(',')?; + } + write!( + row, + "{}:{}:{}", + f.init, + st.inits[f.init as usize].parts, + u8::from(f.kind == super::FrameKind::Job) + )?; + } + row.write_str("\tfirst_changed_tracked_word\t")?; + let mut text = SignalRow { + bytes: [0; 16], + len: 0, + }; + write!(text, "{word:x}")?; + for byte in &text.bytes[..text.len] { + write!(row, "{byte:02x}")?; + } + writeln!(row, "\t{pc:x}\t{}", st.inputs.allocated_bytes) + })(); + if result.is_err() { + unsafe { + libc::_exit(93); + } + } + emit(st.inputs.fd, &row.bytes[..row.len]); + logger_scope(false); + unsafe { + *libc::__errno_location() = saved; + } +} +pub(super) fn changed_word(st: &mut State, word: usize, pc: usize) { + let id = top_init(st) as usize; + if !watch(st) { + return; + } + let external = st.globals.contains(word as u64) + || (st.realm.0 <= word && word < st.realm.1) + || super::object_start(st, word) + .and_then(|h| st.born_by.get(h as u64)) + .is_some_and(|owner| owner as usize != id); + if external { + remember_write(st, word, pc); + } +} +pub(super) fn write(_st: &State) -> std::io::Result<()> { + // Rows are unbuffered write-all calls, including the fault-handler rows. + Ok(()) +} + +/// Used by libc callbacks, including callbacks from inside mimalloc. No +/// heap allocation, JS API, backtrace cache, or native allocator is entered. +pub(super) fn native_event(st: &mut State, op: &str, value: std::fmt::Arguments<'_>) { + let saved = unsafe { *libc::__errno_location() }; + let _scope = Scope::enter(); + let ir = read_ir(st.perf_fd); + st.inputs.seq += 1; + let seq = st.inputs.seq; + let mut text = SignalRow { + bytes: [0; 65536], + len: 0, + }; + if text.write_fmt(value).is_err() { + unsafe { + libc::_exit(93); + } + } + let mut row = SignalRow { + bytes: [0; 131072], + len: 0, + }; + let result = (|| -> std::fmt::Result { + write!(row, "{seq}\t{ir}\t{}\t", top_init(st))?; + for (i, f) in st.frames.iter().enumerate() { + if i != 0 { + row.write_char(',')?; + } + write!( + row, + "{}:{}:{}", + f.init, + st.inits[f.init as usize].parts, + u8::from(f.kind == super::FrameKind::Job) + )?; + } + write!(row, "\t{op}\t")?; + for b in &text.bytes[..text.len] { + write!(row, "{b:02x}")?; + } + writeln!(row, "\t0,0,0,0,0,0\t{}", st.inputs.allocated_bytes) + })(); + if result.is_err() { + unsafe { + libc::_exit(93); + } + } + emit(st.inputs.fd, &row.bytes[..row.len]); + unsafe { + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + seq as libc::off_t, + 0x5321 as libc::c_int, + ); + *libc::__errno_location() = saved; + } +} +pub(super) struct Hex<'a>(pub &'a [u8]); +impl std::fmt::Display for Hex<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for b in self.0 { + write!(f, "{b:02x}")?; + } + Ok(()) + } +} +pub(super) fn env_rows(st: &State) -> std::io::Result)>> { + use std::io::{BufRead, Error, ErrorKind}; + fn unhex(s: &str) -> std::io::Result> { + if s.len() % 2 != 0 { + return Err(Error::new(ErrorKind::InvalidData, "odd hex")); + } + (0..s.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&s[i..i + 2], 16) + .map_err(|_| Error::new(ErrorKind::InvalidData, "invalid hex")) + }) + .collect() + } + let mut rows = Vec::new(); + for line in + std::io::BufReader::new(std::fs::File::open(format!("{}.inputs.tsv", st.out))?).lines() + { + let line = line?; + let fields: Vec<_> = line.split('\t').collect(); + if fields.get(4) != Some(&"getenv") { + continue; + } + let value = String::from_utf8(unhex(fields[5])?) + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad env row"))?; + let (name, value) = value + .split_once('=') + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "missing env separator"))?; + let value = if value == "unset" { + None + } else { + Some(String::from_utf8_lossy(&unhex(value)?).into_owned()) + }; + rows.push(( + fields[2] + .parse() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad init"))?, + String::from_utf8_lossy(&unhex(name)?).into_owned(), + value, + )); + } + Ok(rows) +} +pub(super) fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(out, "{b:02x}"); + } + out +} +pub trait Value { + fn input_value(&self) -> String; +} +macro_rules! debug_value { + ($($t:ty),*) => { $(impl Value for $t { + fn input_value(&self) -> String { format!("{self:?}") } + })* } +} +debug_value!( + (), + bool, + u8, + u16, + u32, + u64, + usize, + i8, + i16, + i32, + i64, + isize, + u128, + String +); +impl Value for &str { + fn input_value(&self) -> String { + format!("string:{}", hex(self.as_bytes())) + } +} +impl Value for f64 { + fn input_value(&self) -> String { + format!("f64:{:016x}", self.to_bits()) + } +} +impl Value for Option { + fn input_value(&self) -> String { + match self { + None => "None".into(), + Some(v) => format!("Some({})", v.input_value()), + } + } +} +impl Value for Vec { + fn input_value(&self) -> String { + format!( + "[{}]", + self.iter() + .map(Value::input_value) + .collect::>() + .join(",") + ) + } +} +impl Value for [T; N] { + fn input_value(&self) -> String { + format!( + "[{}]", + self.iter() + .map(Value::input_value) + .collect::>() + .join(",") + ) + } +} +impl Value for (A, B) { + fn input_value(&self) -> String { + format!("({},{})", self.0.input_value(), self.1.input_value()) + } +} +impl Value for *mut crate::StringHeader { + fn input_value(&self) -> String { + if self.is_null() { + return "null".into(); + } + unsafe { + let bytes = + std::slice::from_raw_parts(self.add(1).cast::(), (**self).byte_len as usize); + format!("string:{}", hex(bytes)) + } + } +} +impl Value for *mut crate::ArrayHeader { + fn input_value(&self) -> String { + if self.is_null() { + return "null".into(); + } + let len = unsafe { (**self).length }; + if unsafe { + crate::array::array_has_element_descriptors_resolved(*self) || len > (**self).capacity + } { + return "unsupported_array_payload".into(); + } + let mut out = String::from("["); + for i in 0..len { + let mut value = unsafe { + (crate::array::array_elements_ptr(*self) as *const f64) + .add(i as usize) + .read() + .to_bits() + }; + if value == crate::value::TAG_HOLE { + value = 0x7ffc_0000_0000_0001; + } + if i != 0 { + out.push(','); + } + if value >> 48 == 0x7fff { + let ptr = (value & 0x0000_ffff_ffff_ffff) as *mut crate::StringHeader; + out.push_str(&ptr.input_value()); + } else { + let _ = write!(out, "bits:{value:016x}"); + } + } + out.push(']'); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn exact_scalar_and_absence_values() { + assert_ne!(0.0f64.input_value(), (-0.0f64).input_value()); + assert_ne!( + Option::::None.input_value(), + Some(String::new()).input_value() + ); + assert_eq!(hex(&[0, 0xff, 10]), "00ff0a"); + } +} + +impl Value for *mut crate::buffer::BufferHeader { + fn input_value(&self) -> String { + if self.is_null() { + return "null".into(); + } + let value = f64::from_bits(crate::JSValue::pointer(*self as *const u8).bits()); + crate::buffer::bytes::no_gc(|scope| match crate::buffer::bytes::bytes(value, scope) { + Ok(bytes) => format!("bytes:{}", hex(bytes)), + Err(_) => "unsupported_buffer_payload".into(), + }) + } +} diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 571e346fca4..69a92d50a3a 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -35,6 +35,8 @@ //! //! Linux x86_64 only (the reservation, `REG_ERR`, the trap flag). +#[path = "input_record.rs"] +pub(super) mod input_record; use super::Effect; use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, AtomicUsize, Ordering::Relaxed}; @@ -83,11 +85,62 @@ pub(super) fn on() -> bool { ANCHOR.on.load(Relaxed) } +// Const-initialized native TLS needs neither lazy initialization nor allocation. +// It lives outside the generated program's tracked TLS pages. +#[thread_local] +static LOGGING_DEPTH: std::cell::Cell = std::cell::Cell::new(0); + +/// Allocator callbacks must be rejected before formatting or borrowing State. +pub(super) fn inputs_on() -> bool { + on() && LOGGING_DEPTH.get() == 0 +} +pub(super) fn logging_scope(enter: bool) { + let depth = LOGGING_DEPTH.get(); + LOGGING_DEPTH.set(if enter { depth + 1 } else { depth - 1 }); +} + /// Collection is held until the boundary (not in the weights run). pub(super) fn holds_collection() -> bool { on() && !state().noprotect } +// Native helper threads are visible in the syscall companion, but must not +// borrow the main thread's census buffers. +pub(super) fn on_main_thread() -> bool { + let p = ANCHOR.state.load(Relaxed); + !p.is_null() && unsafe { (*p).main_tid == libc::syscall(libc::SYS_gettid) } +} + +unsafe fn foreign_input(operation: &[u8], argument: &[u8], value: &[u8]) { + let header = b"SNAP21_FOREIGN_INPUT"; + let mut rows = [ + libc::iovec { + iov_base: header.as_ptr() as *mut _, + iov_len: header.len(), + }, + libc::iovec { + iov_base: operation.as_ptr() as *mut _, + iov_len: operation.len(), + }, + libc::iovec { + iov_base: argument.as_ptr() as *mut _, + iov_len: argument.len(), + }, + libc::iovec { + iov_base: value.as_ptr() as *mut _, + iov_len: value.len(), + }, + ]; + let saved_errno = *libc::__errno_location(); + libc::syscall( + libc::SYS_writev, + -1 as libc::c_int, + rows.as_mut_ptr(), + rows.len(), + ); + *libc::__errno_location() = saved_errno; +} + fn state() -> &'static mut State { // SAFETY: set once at start, never freed; the runner is single-threaded // (module evaluation and jobs run on the main thread; a fault on another @@ -488,6 +541,7 @@ impl PageMap { } struct State { + inputs: input_record::Record, out: String, inits: Vec, frames: Vec, @@ -827,6 +881,7 @@ unsafe fn start_census(out: String) { ); let tls_block = (fs.saturating_sub(executable_tls_size()), fs); let mut st = Box::new(State { + inputs: input_record::Record::default(), out, inits: Vec::with_capacity(4096), frames: Vec::with_capacity(256), @@ -1006,6 +1061,7 @@ unsafe fn start_census(out: String) { st.last_ir = read_ir(st.perf_fd); ANCHOR.state.store(&mut *st as *mut State, Relaxed); std::mem::forget(st); + let _input_logging = input_record::Scope::enter(); let st = state(); // Nothing moves until the boundary. The weights run (no protection) // collects as production does: its Ir per init includes the collections. @@ -1059,8 +1115,24 @@ unsafe fn start_census(out: String) { } } } + st.inputs.watch_enabled = !std::env::var("PERRY_SNAP22_WATCH").is_ok_and(|v| v == "0"); + st.inputs.allocated_bytes = st.births.as_slice().iter().map(|b| b.size as u64).sum(); + input_record::open(st).expect("cannot open ordered input record"); install_handlers(); ANCHOR.on.store(true, Relaxed); + let _input_logging = input_record::Scope::enter(); + input_record::event(st, "census_start", String::new()); + let mut raw_env = Vec::>::new(); + let mut env_entry = environ; + while !env_entry.is_null() && !(*env_entry).is_null() { + raw_env.push(std::ffi::CStr::from_ptr(*env_entry).to_bytes().to_vec()); + env_entry = env_entry.add(1); + } + input_record::event( + st, + "initial_environment", + input_record::Value::input_value(&raw_env), + ); crate::arena::refresh_inline_allocation_limit(); } @@ -1226,7 +1298,9 @@ fn set_mode_for_top(st: &mut State) { }; } let want = top != 0 && st.inits[top].in_d && !st.noprotect; - if want != st.d_mode { + let watching = input_record::watch(st); + if want != st.d_mode || watching != st.inputs.watching { + st.inputs.watching = watching; st.d_mode = want; unsafe { apply_protection(st) }; } @@ -1236,6 +1310,7 @@ pub(super) fn init_enter(addr: usize) { if !on() && !start_if_requested() { return; } + let _input_logging = input_record::Scope::enter(); let st = state(); charge(st); let parent = st.frames.last().copied(); @@ -1311,12 +1386,16 @@ pub(super) fn init_enter(addr: usize) { init: id, kind: FrameKind::Init, }); + crate::object::shapes::snap22::snapshot("init_enter", addr as u64); + input_record::event(st, "init_enter", format!("{addr:x}")); set_mode_for_top(st); } pub(super) fn init_exit(threw: bool) { + let _input_logging = input_record::Scope::enter(); let st = state(); charge(st); + input_record::event(st, "init_exit", format!("threw={threw}")); let Some(f) = st.frames.pop() else { return; }; @@ -1340,6 +1419,7 @@ pub(super) fn init_guard(addr: usize, guard: usize) { } pub(super) fn init_part() { + let _input_logging = input_record::Scope::enter(); let st = state(); if let Some(f) = st.frames.last() { if f.kind == FrameKind::Init { @@ -1347,6 +1427,8 @@ pub(super) fn init_part() { i.parts = i.parts.saturating_add(1); } } + input_record::event(st, "init_part", String::new()); + set_mode_for_top(st); } pub(super) fn require_depth(delta: i32) { @@ -1357,6 +1439,7 @@ pub(super) fn require_depth(delta: i32) { /// target's init guard, 0 after it (the target was already evaluated, so no /// evaluation took the mark). pub(super) fn require_edge(on: bool) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.require_edge = on.then(|| top_init(st)); } @@ -1376,17 +1459,21 @@ pub(super) fn bookkeeping_depth(delta: i32) { } pub(super) fn job_enter(owner: u32) { + let _input_logging = input_record::Scope::enter(); let st = state(); charge(st); st.frames.push(Frame { init: owner, kind: FrameKind::Job, }); + input_record::event(st, "job_enter", owner.to_string()); set_mode_for_top(st); } pub(super) fn job_exit() { + let _input_logging = input_record::Scope::enter(); let st = state(); + input_record::event(st, "job_exit", String::new()); charge(st); if st.frames.last().is_some_and(|f| f.kind == FrameKind::Job) { st.frames.pop(); @@ -1395,6 +1482,7 @@ pub(super) fn job_exit() { } pub(super) fn entry_body() { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.entry_body_seq == 0 { st.seq += 1; @@ -1416,6 +1504,7 @@ pub(super) fn entry_body() { }); st.inits.push(init); st.entry_init = id; + input_record::event(st, "entry_body", String::new()); st.frames.push(Frame { init: id, kind: FrameKind::Init, @@ -1433,6 +1522,7 @@ pub(super) fn entry_module() { /// meta word at `slot`: an empty meta behaves as no meta, so the store is /// representation, like the string alias hint (design §2.10.11). pub(super) fn meta_install(slot: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.meta_rule { st.meta_slot = slot & !7; @@ -1443,6 +1533,7 @@ pub(super) fn meta_install(slot: usize) { /// written into it later is a write of the owner: under rule 4 the record /// of a pre-existing owner is pre-existing too. pub(super) fn meta_installed(slot: usize, meta: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.meta_slot = 0; st.meta_installs += 1; @@ -1478,6 +1569,7 @@ pub(super) fn global_key<'a>( obj: usize, key: impl FnOnce() -> Option<&'a [u8]>, ) -> Option<(u64, [u8; GKEY_NAME], u8)> { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.gkey_rule { return None; @@ -1505,11 +1597,13 @@ pub(super) fn global_key<'a>( /// The keyed operation [`global_key`] opened is over. pub(super) fn global_key_end(prev: (u64, [u8; GKEY_NAME], u8)) { + let _input_logging = input_record::Scope::enter(); let st = state(); (st.gkey, st.gkey_name, st.gkey_len) = prev; } pub(super) fn representation(delta: i32) -> bool { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.meta_rule { return false; @@ -1523,6 +1617,7 @@ pub(super) fn representation(delta: i32) -> bool { } pub(super) fn attached(obj: usize, cells: &[usize]) { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.transparent.is_empty() { return; @@ -1545,6 +1640,7 @@ pub(super) fn attached(obj: usize, cells: &[usize]) { } pub(super) fn dyn_code(kind: u8) { + let _input_logging = input_record::Scope::enter(); let st = state(); let top = st.frames.last().map_or(0, |f| f.init) as usize; if top == 0 { @@ -1563,8 +1659,39 @@ pub(super) fn dyn_code(kind: u8) { } } +pub(super) fn input_scope(delta: i32) { + let _input_logging = input_record::Scope::enter(); + let st = state(); + if delta > 0 { + st.inputs.call_depth += 1; + } else { + st.inputs.call_depth -= 1; + } + set_mode_for_top(st); +} + +pub(super) fn input_event(op: &str, value: String) -> u64 { + let _input_logging = input_record::Scope::enter(); + input_record::event(state(), op, value) +} + pub(super) fn host(class: Effect, what: &'static str) { + let _input_logging = input_record::Scope::enter(); let st = state(); + let input_top = top_init(st) as usize; + if class != Effect::TargetFact + && st.inputs.call_depth == 0 + && input_top != 0 + && st.inputs.writes.get(input_top).is_some_and(Option::is_none) + { + input_record::remember_write(st, 0, 0); + st.inputs.watching = false; + unsafe { + apply_protection(st); + } + } + + input_record::event(st, "effect", format!("{class:?}:{what}")); if st.sabotage_env && what == "env" { return; } @@ -1636,7 +1763,9 @@ fn object_start(st: &State, word: usize) -> Option { } pub(super) fn birth(addr: usize, size: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); + st.inputs.allocated_bytes += size as u64; // Memory handed out again (a free-list slot, a rewound bump) holds new // locations: an earlier birth there and what members of D did to its // words are gone. @@ -1676,6 +1805,7 @@ pub(super) fn birth(addr: usize, size: usize) { } pub(super) fn region_mapped(base: usize, len: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); if unsafe { libc::syscall(libc::SYS_gettid) } != st.main_tid { return; @@ -1688,6 +1818,7 @@ pub(super) fn region_mapped(base: usize, len: usize) { } pub(super) fn global_root(slot: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); // A slot registered again is one address linked into several modules // (codegen registers a class-static global in its defining module and in @@ -1702,10 +1833,7 @@ pub(super) fn global_root(slot: usize) { // That member is skipped here, so the first registration this run sees // is an importer's duplicate: bookkeeping as above, and the slot stays // D's binding, owned by no imaged init. - if st.slot_owner_rule - && !st.skip.is_empty() - && st.env_words.get(slot as u64) == Some(POISON) - { + if st.slot_owner_rule && !st.skip.is_empty() && st.env_words.get(slot as u64) == Some(POISON) { return; } count_unwatched_root(st, slot); @@ -1725,11 +1853,12 @@ pub(super) fn global_root(slot: usize) { } let page = slot & !(PAGE - 1); if st.d_mode && page_flags(st, page).is_some() { - unsafe { libc::mprotect(page as *mut _, PAGE, libc::PROT_NONE) }; + unsafe { libc::mprotect(page as *mut _, PAGE, policy(st, page)) }; } } fn mark_tracked(lo: usize, hi: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); let mut p = lo & !(PAGE - 1); while p < hi { @@ -1801,6 +1930,11 @@ fn tls_slot(st: &State, addr: usize) -> Option<*mut u8> { // --------------------------------------------------------------------------- fn policy(st: &State, page: usize) -> i32 { + if input_record::watch(st) + && page_flags(st, page).is_some_and(|f| unsafe { *f & PF_TRACKED != 0 }) + { + return libc::PROT_READ; + } // The weights run (`PERRY_SNAP_NOPROTECT=1`) takes no fault: its Ir per // init is the program's, not the census's. if st.noprotect { @@ -1858,7 +1992,15 @@ unsafe fn for_each_tracked_range(st: &State, mut f: impl FnMut(usize, usize)) { } unsafe fn apply_protection(st: &mut State) { - if st.top_mode { + if input_record::watch(st) { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ); + }); + return; + } + // Ending the first-write watcher restores RW in the weights run, + // even if a conflict has set d_mode. Bulk protection agrees with policy. + if st.top_mode || st.noprotect { for_each_tracked_range(st, |b, e| { libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); }); @@ -2100,6 +2242,7 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] |= EFLAGS_TF; return; } + let _input_logging = input_record::Scope::enter(); if st.in_handler > 0 { let uc = ctx as *mut libc::ucontext_t; let pc = (*uc).uc_mcontext.gregs[libc::REG_RIP as usize] as u64; @@ -2212,6 +2355,7 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l if st_ptr.is_null() || (*st_ptr).pending[0] == 0 { return chain(std::ptr::addr_of!(PREV_TRAP.0).cast(), sig); } + let _input_logging = input_record::Scope::enter(); let st = &mut *st_ptr; let pending = st.pending; if st.rset_active { @@ -2327,6 +2471,15 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.faults_ignored += 1; return; } + if write && input_record::watch(st) { + input_record::changed_word(st, word, pc); + if !input_record::watch(st) { + st.inputs.watching = false; + unsafe { + apply_protection(st); + } + } + } if st.transp_globals.contains(word as u64) { st.transparent_hits += 1; return; @@ -2622,6 +2775,7 @@ unsafe extern "C" { /// knobs, C libraries), not only the entries that declare `env`. #[no_mangle] pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char { + let saved_errno = *libc::__errno_location(); if name.is_null() { return std::ptr::null_mut(); } @@ -2638,19 +2792,36 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char e = e.add(1); } } - if on() { + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); let st = state(); - let top = st.frames.last().map_or(0, |f| f.init); - if st.env_reads.len() < 1 << 20 { - let value = (!found.is_null()).then(|| { - std::ffi::CStr::from_ptr(found) - .to_string_lossy() - .into_owned() - }); - st.env_reads - .push((top, String::from_utf8_lossy(key).into_owned(), value)); + if found.is_null() { + input_record::native_event( + st, + "getenv", + format_args!("{}=unset", input_record::Hex(key)), + ); + } else { + input_record::native_event( + st, + "getenv", + format_args!( + "{}={}", + input_record::Hex(key), + input_record::Hex(std::ffi::CStr::from_ptr(found).to_bytes()) + ), + ); } } + if inputs_on() && !on_main_thread() { + let value = if found.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(found).to_bytes() + }; + foreign_input(b"getenv", key, value); + } + *libc::__errno_location() = saved_errno; found } @@ -2665,10 +2836,12 @@ pub(super) fn skip_collection() -> bool { } pub(super) fn boundary(at: &'static str) { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.boundary_seq != 0 { return; } + input_record::event(st, "boundary", at.to_owned()); charge(st); st.seq += 1; st.boundary_seq = st.seq; @@ -2682,6 +2855,7 @@ pub(super) fn boundary(at: &'static str) { } crate::arena::refresh_inline_allocation_limit(); write_report(st); + input_record::write(st).expect("write ordered input record"); // The check run proves the image at the boundary; what runs after it // reads bindings of the skipped members of D, so the run ends here. if !st.skip.is_empty() { @@ -2743,6 +2917,7 @@ fn heap_pointer(st: &State, v: u64) -> bool { } extern "C" fn rset_report() { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.rset_active { return; @@ -2848,6 +3023,8 @@ pub(super) fn after_boundary_collection() { } fn write_report(st: &mut State) { + st.env_reads = + input_record::env_rows(st).expect("cannot read durable environment observations"); use std::io::Write; let Ok(mut f) = std::fs::File::create(&st.out) else { return; @@ -3836,11 +4013,13 @@ pub(super) fn births_len() -> usize { } pub(super) fn env_site(kind: i32) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.env_site = Some((top_init(st), kind as u8)); } pub(super) fn env_object(bits: u64) { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.env_rule { return host(Effect::HostRead, "env"); @@ -3862,6 +4041,7 @@ pub(super) fn env_object(bits: u64) { } pub(super) fn env_storage(begin: usize, obj: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); let h = obj - crate::gc::GC_HEADER_SIZE; let new: Vec<(usize, usize)> = st.births.as_slice()[begin.min(st.births.len)..] @@ -3882,7 +4062,9 @@ pub(super) fn env_storage(begin: usize, obj: usize) { } pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); + input_record::event(st, "env_get", format!("{name:?}={value:?}")); let top = top_init(st); let site = st.env_site.take().filter(|s| s.0 == top).map(|s| s.1); if !st.env_rule { @@ -3914,7 +4096,9 @@ pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { } pub(super) fn env_has(name: &str, present: bool) { + let _input_logging = input_record::Scope::enter(); let st = state(); + input_record::event(st, "env_has", format!("{name:?}={present}")); if !st.env_rule { return host(Effect::HostRead, "env"); } @@ -3981,6 +4165,7 @@ fn env_word_access(st: &mut State, v: u32, word: usize, write: bool, pc: usize) /// The check run: the evaluation now on top is a member of D. pub(super) fn skip_body() -> bool { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.skip.is_empty() { return false; @@ -4293,3 +4478,146 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } } } + +// Calling the kernel directly also covers libc callers that would otherwise +// read the vDSO without a syscall visible to the companion native trace. +#[no_mangle] +pub unsafe extern "C" fn clock_gettime( + clock: libc::clockid_t, + out: *mut libc::timespec, +) -> libc::c_int { + let rc = libc::syscall(libc::SYS_clock_gettime, clock, out) as libc::c_int; + let saved_errno = *libc::__errno_location(); + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + if rc == 0 && !out.is_null() { + input_record::native_event( + state(), + "native.clock_gettime", + format_args!( + "clock={clock};sec={};nsec={}", + (*out).tv_sec, + (*out).tv_nsec + ), + ); + } else { + input_record::native_event( + state(), + "native.clock_gettime", + format_args!("clock={clock};rc={rc};errno={saved_errno}"), + ); + } + } + *libc::__errno_location() = saved_errno; + rc +} + +#[no_mangle] +pub unsafe extern "C" fn gettimeofday( + out: *mut libc::timeval, + zone: *mut libc::c_void, +) -> libc::c_int { + let rc = libc::syscall(libc::SYS_gettimeofday, out, zone) as libc::c_int; + let saved_errno = *libc::__errno_location(); + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + if rc == 0 && !out.is_null() { + input_record::native_event( + state(), + "native.gettimeofday", + format_args!("sec={};usec={}", (*out).tv_sec, (*out).tv_usec), + ); + } else { + input_record::native_event( + state(), + "native.gettimeofday", + format_args!("rc={rc};errno={saved_errno}"), + ); + } + } + *libc::__errno_location() = saved_errno; + rc +} + +#[no_mangle] +pub unsafe extern "C" fn time(out: *mut libc::time_t) -> libc::time_t { + let rc = libc::syscall(libc::SYS_time, out) as libc::time_t; + let saved_errno = *libc::__errno_location(); + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + input_record::native_event( + state(), + "native.time", + format_args!("seconds={rc};errno={saved_errno}"), + ); + } + *libc::__errno_location() = saved_errno; + rc +} + +// Environment mutations have no syscall and must not disappear from native +// evidence. Resolve the libc operation afresh; this is instruments-only. +#[no_mangle] +pub unsafe extern "C" fn unsetenv(name: *const libc::c_char) -> libc::c_int { + let saved_errno = *libc::__errno_location(); + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + let key = if name.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(name).to_bytes() + }; + input_record::native_event( + state(), + "native.unsetenv", + format_args!("{}", input_record::Hex(key)), + ); + host(Effect::HostWrite, "env"); + } + let next = libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr()); + assert!(!next.is_null(), "libc unsetenv missing"); + let call: unsafe extern "C" fn(*const libc::c_char) -> libc::c_int = std::mem::transmute(next); + *libc::__errno_location() = saved_errno; + call(name) +} + +#[no_mangle] +pub unsafe extern "C" fn setenv( + name: *const libc::c_char, + value: *const libc::c_char, + overwrite: libc::c_int, +) -> libc::c_int { + let saved_errno = *libc::__errno_location(); + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + let key = if name.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(name).to_bytes() + }; + let val = if value.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(value).to_bytes() + }; + input_record::native_event( + state(), + "native.setenv", + format_args!( + "{}={};overwrite={overwrite}", + input_record::Hex(key), + input_record::Hex(val) + ), + ); + host(Effect::HostWrite, "env"); + } + let next = libc::dlsym(libc::RTLD_NEXT, c"setenv".as_ptr()); + assert!(!next.is_null(), "libc setenv missing"); + let call: unsafe extern "C" fn( + *const libc::c_char, + *const libc::c_char, + libc::c_int, + ) -> libc::c_int = std::mem::transmute(next); + *libc::__errno_location() = saved_errno; + call(name, value, overwrite) +} diff --git a/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index e6a8671fb62..eabd03e8153 100644 --- a/crates/perry-runtime/src/tty.rs +++ b/crates/perry-runtime/src/tty.rs @@ -80,12 +80,36 @@ pub(crate) fn scan_tty_resize_callback_root_mut(visitor: &mut crate::gc::Runtime #[cfg(unix)] fn isatty_impl(fd: i32) -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.isatty", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + unsafe { libc::isatty(fd) != 0 } + }); + } + crate::effect!(host_read, tty); unsafe { libc::isatty(fd) != 0 } } #[cfg(unix)] fn winsize_impl(fd: i32) -> Option<(i32, i32)> { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.winsize", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + unsafe { + let mut ws: libc::winsize = std::mem::zeroed(); + if libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 + { + Some((ws.ws_col as i32, ws.ws_row as i32)) + } else { + None + } + } + }); + } + crate::effect!(host_read, tty); unsafe { let mut ws: libc::winsize = std::mem::zeroed(); @@ -99,6 +123,18 @@ fn winsize_impl(fd: i32) -> Option<(i32, i32)> { #[cfg(windows)] fn isatty_impl(fd: i32) -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.isatty", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + // A std handle counts as a TTY when `GetConsoleMode` succeeds on it — + // the same probe `std::io::IsTerminal` uses (see `builtins/console.rs`) + // and the same criterion libuv uses to classify a handle as `UV_TTY`. + // Piped/redirected handles fail the probe → false, matching Node. + crate::win_console::is_console_fd(fd) + }); + } + crate::effect!(host_read, tty); // A std handle counts as a TTY when `GetConsoleMode` succeeds on it — // the same probe `std::io::IsTerminal` uses (see `builtins/console.rs`) @@ -109,6 +145,16 @@ fn isatty_impl(fd: i32) -> bool { #[cfg(windows)] fn winsize_impl(fd: i32) -> Option<(i32, i32)> { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.winsize", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + // GetConsoleScreenBufferInfo srWindow extents (right-left+1, + // bottom-top+1) for the stdout/stderr console handle. + crate::win_console::window_size(fd) + }); + } + crate::effect!(host_read, tty); // GetConsoleScreenBufferInfo srWindow extents (right-left+1, // bottom-top+1) for the stdout/stderr console handle. @@ -117,12 +163,28 @@ fn winsize_impl(fd: i32) -> Option<(i32, i32)> { #[cfg(not(any(unix, windows)))] fn isatty_impl(_fd: i32) -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.isatty", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + false + }); + } + crate::effect!(host_read, tty); false } #[cfg(not(any(unix, windows)))] fn winsize_impl(_fd: i32) -> Option<(i32, i32)> { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.winsize", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + None + }); + } + crate::effect!(host_read, tty); None } @@ -895,6 +957,9 @@ pub(crate) fn tty_listener_remove_all_value() -> f64 { #[no_mangle] pub extern "C" fn js_tty_read_stream_new(fd: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("native_resource.tty_stream", &fd); + validate_tty_read_fd(fd); ensure_tty_prototypes(); let keys = b"isRaw\0isTTY\0"; @@ -912,6 +977,9 @@ pub extern "C" fn js_tty_read_stream_new(fd: f64) -> f64 { #[no_mangle] pub extern "C" fn js_tty_write_stream_new(fd: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("native_resource.tty_stream", &fd); + validate_tty_write_fd(fd); ensure_tty_prototypes(); let obj = crate::object::js_object_alloc_class_with_keys( diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 11e035f9192..9df3c59a81b 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -55,6 +55,34 @@ fn validate_random_bytes_size(size: f64) -> usize { pub extern "C" fn js_crypto_random_bytes_buffer( size: f64, ) -> *mut perry_runtime::buffer::BufferHeader { + perry_runtime::census_inputs! { + return perry_runtime::snap_census::input_call( + "random.crypto.bytes", + &format!("{:?}", (size,)), + || { + perry_runtime::effect!(host_read, random); + let size = validate_random_bytes_size(size); + let (value, pin) = perry_runtime::buffer::bytes::new_bytes( + perry_runtime::buffer::bytes::Brand::Buffer, + size, + perry_runtime::buffer::bytes::Init::Uninit, + ); + unsafe { + rand::rng() + .fill_bytes(std::slice::from_raw_parts_mut(pin.as_mut_ptr(), pin.len())); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value( + "crypto.random.bytes", + &std::slice::from_raw_parts(pin.as_ptr(), pin.len()).to_vec(), + ); } + } + JSValue::from_bits(value.to_bits()) + .as_pointer::() + .cast_mut() + }, + ); + } + perry_runtime::effect!(host_read, random); let size = validate_random_bytes_size(size); let (value, pin) = perry_runtime::buffer::bytes::new_bytes( @@ -64,6 +92,11 @@ pub extern "C" fn js_crypto_random_bytes_buffer( ); unsafe { rand::rng().fill_bytes(std::slice::from_raw_parts_mut(pin.as_mut_ptr(), pin.len())); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value( + "crypto.random.bytes", + &std::slice::from_raw_parts(pin.as_ptr(), pin.len()).to_vec(), + ); } } JSValue::from_bits(value.to_bits()) .as_pointer::() @@ -115,11 +148,32 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: /// crypto.randomBytes(size).toString('hex') -> string #[no_mangle] pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { + perry_runtime::census_inputs! { + return perry_runtime::snap_census::input_call( + "random.crypto.hex", + &format!("{:?}", (size,)), + || { + perry_runtime::effect!(host_read, random); + let size = validate_random_bytes_size(size); + + let mut bytes = vec![0u8; size]; + rand::rng().fill_bytes(&mut bytes); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); } + let hex_str = perry_hex::encode(&bytes); + + js_string_from_bytes(hex_str.as_ptr(), hex_str.len() as u32) + }, + ); + } + perry_runtime::effect!(host_read, random); let size = validate_random_bytes_size(size); let mut bytes = vec![0u8; size]; rand::rng().fill_bytes(&mut bytes); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); } let hex_str = perry_hex::encode(&bytes); js_string_from_bytes(hex_str.as_ptr(), hex_str.len() as u32) @@ -133,6 +187,30 @@ pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { /// `{ disableEntropyCache: true }`, which draws fresh OS entropy. #[no_mangle] pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut StringHeader { + perry_runtime::census_inputs! { + return perry_runtime::snap_census::input_call( + "random.crypto.uuid", + &format!("{:?}", (options_bits,)), + || { + perry_runtime::effect!(host_read, random); + let uuid = if validate_random_uuid_options(options_bits) { + perry_uuid::v4_uncached() + } else { + perry_uuid::v4() + }; + // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as + // UTF-8 on every UUID only for the string constructor to copy them. + let uuid_bytes = uuid.as_bytes(); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value( + "crypto.random.uuid", + &uuid_bytes.to_vec(), + ); } + js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) + }, + ); + } + perry_runtime::effect!(host_read, random); let uuid = if validate_random_uuid_options(options_bits) { perry_uuid::v4_uncached() @@ -142,6 +220,8 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as // UTF-8 on every UUID only for the string constructor to copy them. let uuid_bytes = uuid.as_bytes(); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); } js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } @@ -153,9 +233,29 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String /// accepted for shape parity but does not change the generated value. #[no_mangle] pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { + perry_runtime::census_inputs! { + return perry_runtime::snap_census::input_call( + "random.crypto.uuidv7", + &format!("{:?}", ()), + || { + perry_runtime::effect!(host_read, random); + let uuid = perry_uuid::v7(); + let uuid_bytes = uuid.as_bytes(); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value( + "crypto.random.uuid", + &uuid_bytes.to_vec(), + ); } + js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) + }, + ); + } + perry_runtime::effect!(host_read, random); let uuid = perry_uuid::v7(); let uuid_bytes = uuid.as_bytes(); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); } js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } @@ -252,6 +352,34 @@ fn validate_random_int_arg(value: f64, arg_name: &str) -> i64 { /// Returns a uniformly distributed integer in `[min, max)`. #[no_mangle] pub extern "C" fn js_crypto_random_int(min_bits: f64, max_bits: f64) -> f64 { + perry_runtime::census_inputs! { + return perry_runtime::snap_census::input_call( + "random.crypto.int", + &format!("{:?}", (min_bits, max_bits,)), + || { + perry_runtime::effect!(host_read, random); + let min = validate_random_int_arg(min_bits, "min"); + let max = validate_random_int_arg(max_bits, "max"); + if max <= min { + let message = format!( + "The value of \"max\" is out of range. It must be greater than the value of \"min\" ({}). Received {}", + min, max + ); + perry_runtime::fs::validate::throw_range_error_with_code(&message); + } + let range = max as i128 - min as i128; + if range > RANDOM_INT_MAX_RANGE as i128 { + let message = format!( + "The value of \"max - min\" is out of range. It must be <= {}. Received {}", + RANDOM_INT_MAX_RANGE, range + ); + perry_runtime::fs::validate::throw_range_error_with_code(&message); + } + rand::rng().random_range(min..max) as f64 + }, + ); + } + perry_runtime::effect!(host_read, random); let min = validate_random_int_arg(min_bits, "min"); let max = validate_random_int_arg(max_bits, "max"); @@ -648,6 +776,11 @@ pub extern "C" fn js_crypto_random_fill_sync( perry_runtime::buffer::bytes::no_gc(|scope| unsafe { let data = perry_runtime::buffer::bytes::bytes_mut(value, scope)?; rand::rng().fill_bytes(&mut data[start..start + count]); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value( + "crypto.random.fill", + &data[start..start + count].to_vec(), + ); } #[cfg(test)] if b2c_sabotage("random_fill_range") && count > 0 { // Make the wrong-window witness deterministic rather than depend diff --git a/scripts/snap_inputs/README.md b/scripts/snap_inputs/README.md new file mode 100644 index 00000000000..0cc65ad891a --- /dev/null +++ b/scripts/snap_inputs/README.md @@ -0,0 +1,71 @@ +# Instrumented snapshot input evidence + +trace.py OUTPUT -- setarch -R INSTRUMENTED-BINARY ... records native inputs +and successful once-guarded module returns. It requires Linux x86-64, ptrace, +hardware perf counters and an executable with the census ABI and symbols. +It never links into a production executable. PERRY_SNAP_CENSUS=REPORT +also writes the ordered semantic events to REPORT.inputs.tsv. + +account.py REPORT OUTPUT ACCOUNT.json joins the counter domains and checks +every census instruction charge, rather than adding whole modules in entry order. +Here OUTPUT is the native JSONL from trace.py. Its instruction positions include +the recorder and tracer probes; they are not production instruction counts. + +sites.py reconstructs the identity ledger from final facts dump site records +joined to their proven verdicts. Its usage is printed by an argument error. + +Fixtures are diagnostic subjects. Keep compiler and runtime archives pinned +separately for the base and changed arm, compile each fixture's main.ts, then run +through the recorder to ROOT/fx/NAME/head/{report,native.jsonl,stdout}. +python3 check.py ROOT checks allinputs, r1 and r2. On the base, requiring the +ordered TSV must fail. r1 includes a dependency-phase parent. r2 writes an +outliving cell before entering a child. net exercises socket inputs. +stop is fresh normally and fails with SNAP21_FORCE_IMAGED_PID=1. + +replay is a counterexample component model, not an image loader. Normally it +passes. SNAP21_UNSAFE_REPLAY=R1 repeats a body effect when a body-started frame is +misclassified as a dependency driver. SNAP21_UNSAFE_REPLAY=R2 repeats a persistent +write. Both must exit 1 while the child's once guard prevents a second child run. +These reds do not establish end-to-end snapshot correctness. That requires a +real image loader, relocation and guard-mismatch tests in a later phase. + +The first changed tracked word is conservative evidence, not a proof of semantic +writes or their absence: representation writes, non-GC native state and local +allocations must be audited separately. A decoder's "representation" annotation +likewise cannot admit a native resource. Unknown input or incomplete payload is +a STOP; module names and TypeScript types never grant eligibility. + +SNAP22 abort survival: compile `fixtures/abort/main.ts` with the instrumented +cohort, then run with `SNAP22_ABORT_SENTINEL=before_abort` and +`PERRY_SNAP_CENSUS=`; expect SIGABRT/134. Run +`python3 scripts/snap_inputs/check_abort.py .inputs.tsv`. +The assertion is RED with SNAP21's boundary-buffered recorder. Each row now +uses native write-all immediately, including first-write rows from signal +context. Native logging intervals carry lseek marker 0x5322; the syscall +companion excludes only those explicit intervals and their dedicated fds. +No JS values, shapes, native frames or event payload buffers are retained +by the recorder. A failed or oversized write fails explicitly (92/93). + +The recorder suppresses allocator callbacks with const-initialized native TLS +before callback formatting or borrowing census state. Only diagnostic scopes +are suppressed; the actual observed JS/native operation runs outside them. +Clock and libc environment observation payloads use bounded stack buffers. + +For the GC/re-entry regression, compile fixtures/effect/main.ts (requires the +same installed effect package as the subject) and fixtures/pressure/main.ts. +Run each with PERRY_SNAP22_WATCH=0 and =1, PERRY_SNAP_NOPROTECT=1 and distinct +report paths. check_recorder.py ROOT [ARM=guard-head] asserts six ordered +prefixes under ROOT/fx/{effect,shapes,abort}/ARM, report-0/report-1 inputs, +stdout-0/stdout-1, and a live-object census proving GC completed. The abort +fixture intentionally exits 134 without reaching the boundary. On the old +recorder the effect GC crashes, pressure fails, and abort rows are absent or +out of order; a successful process exit alone cannot satisfy these assertions. + +Protection consistency regression: compile `fixtures/protection/main.ts` with +an instrumented cohort, then run `bash scripts/snap_inputs/protection_fixture.sh +BINARY OUTPUT base|head`. A dynamic child changes a cell born before its import, +then accesses other pages. On the base the first-write watcher triggers a +rule-4 conflict, bulk protection closes unrelated pages, and the process faults +although its page policy is RW. Watch=0 finishes; the fix finishes in both modes. +The checker requires the nonzero first-write row, exact output, and the native +mprotect evidence. It also rejects any bulk PROT_NONE in this NOPROTECT fixture. diff --git a/scripts/snap_inputs/account.py b/scripts/snap_inputs/account.py new file mode 100644 index 00000000000..8c7b3eb48fa --- /dev/null +++ b/scripts/snap_inputs/account.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Join runtime and native counters, reconstruct the census's exact Ir charges.""" +import bisect,collections,csv,json,struct,sys +from pathlib import Path + +def load(report,native): + events=list(csv.DictReader(open(str(report)+".inputs.tsv"),delimiter="\t")) + for e in events: + e["seq"]=int(e["seq"]);e["ir"]=int(e["ir"]);e["init"]=int(e["init"]) + e["value"]=bytes.fromhex(e["value_hex"]).decode("utf-8") + e["stack"]=[tuple(map(int,p.split(":"))) for p in e["stack_parts"].split(",") if p] + raw=[json.loads(x) for x in open(native)] + opens=[x for x in raw if x.get("nr")==298 and x.get("ret",-1)>=0] + fdset={x["ret"] for x in opens} + reads=[x for x in raw if x.get("nr")==0 and x["args"][0] in fdset and x.get("ret")==8 and isinstance(x.get("bytes"),str)] + groups=collections.defaultdict(list) + for r in reads: + r["runtime_ir"]=struct.unpack("=previous,(stamp,previous,op) + sums[owner]+=stamp-previous;previous=stamp + actual={} + for line in open(report): + if line.startswith("#"):continue + row=line.rstrip("\n").split("\t") + actual[int(row[0])]=int(row[12]) + mismatches={i:(actual.get(i,0),sums.get(i,0)) for i in set(actual)|set(sums) if actual.get(i,0)!=sums.get(i,0)} + return events,raw,{"tid":tid,"fd":fd,"offset":offset,"initial":initial,"charges":charges, + "actual":actual,"reconstructed":dict(sums),"mismatches":mismatches, + "real_ids":sorted({e["init"] for e in events if e["op"]=="init_enter"})} + +def main(): + report=Path(sys.argv[1]);native=Path(sys.argv[2]);out=Path(sys.argv[3]) + events,raw,account=load(report,native) + out.write_text(json.dumps(account,indent=2)+"\n") + print("counter_offset",account["offset"],"charges",len(account["charges"]), + "exact_census_accounting",not account["mismatches"]) + if account["mismatches"]:print("mismatches",account["mismatches"]) + else: + ids=set(account["real_ids"]) + total=sum(v for i,v in account["actual"].items() if i in ids) + print("real_init_Ir",total,"real_inits",len(ids)) + return bool(account["mismatches"]) +if __name__=="__main__":sys.exit(main()) + diff --git a/scripts/snap_inputs/check.py b/scripts/snap_inputs/check.py new file mode 100644 index 00000000000..4c5a0c227e3 --- /dev/null +++ b/scripts/snap_inputs/check.py @@ -0,0 +1,43 @@ +import csv,json,collections,sys +from pathlib import Path +S=Path(sys.argv[1]) +for f in ['allinputs','r1','r2']: + d=S/'fx'/f/'head' + r=list(csv.DictReader(open(d/'report.inputs.tsv'),delimiter='\t')) + assert all(int(a['seq'])0 for a in r) + n=[json.loads(x) for x in open(d/'native.jsonl')] + assert n[-1]['kind']=='summary' and n[-1]['main_rc']==0,n[-1] + assert not n[-1]['memory_failures'],n[-1] + unknown=[(x.get('nr'),x.get('name'),x.get('args')) for x in n if x.get('complete') is False] + assert not unknown,unknown + markers={int(x['runtime_seq']) for x in n if x['kind']=='runtime_marker'} + ordinary={int(x['seq']) for x in r if x['op']!='first_changed_tracked_word'} + assert ordinary<=markers,(ordinary-markers) + ops=collections.Counter(x['op'] for x in r) + assert any(x["kind"]=="guard_enter" for x in n),"guard entry probe never ran" + assert any(x["kind"]=="guard_complete" for x in n),"completed-module boundary probe never ran" + print(f,'events',len(r),'markers',len(markers),'native',len(n),'complete=True') + if f=='allinputs': + required=['initial_environment','getenv','env_get','env_has','env_block','argv','cwd','locale.navigator', + 'locale.time_zone','tty.isatty','ids.uid','ids.gid','ids.pid','ids.ppid','clock.wall_millis', + 'clock.performance','clock.hrtime_bigint','random.Math','random.crypto.bytes','random.crypto.uuid', + 'crypto.random.bytes','stack.capture','stack.payload','stack.rendered','native.clock_gettime'] + assert not(set(required)-ops.keys()),set(required)-ops.keys() + assert any(x.get('name') in ('read','pread64') and x.get('bytes') for x in n) + assert any(x.get('name')=='getrandom' and x.get('bytes') for x in n) + assert any(x.get('is_error') for x in n) + print('all required scalar/byte host classes observed with values') + if f=='r1': + inputs=[x for x in r if x['op']=='env_get'] + assert inputs + assert any(any(int(p.split(':')[1])==1 for p in x['stack_parts'].split(',') if p) for x in inputs) + print('R1 parent dependency-phase flag observed before child input') + if f=='r2': + first_write=[x for x in r if x['op']=='first_changed_tracked_word'] + enters=[x for x in r if x['op']=='init_enter'] + assert any(int(w['seq'])0 for r in rows) +assert all(int(a["seq"]) 2 else 'guard-head' +for fixture in ('effect', 'shapes', 'abort'): + for watch in (0, 1): + d = root / 'fx' / fixture / arm + path = d / f'report-{watch}.inputs.tsv' + assert path.is_file() and path.read_bytes().endswith(b'\n'), path + rows = list(csv.DictReader(path.open(), delimiter='\t')) + assert rows and all(set(r) == set(rows[0]) and None not in r.values() for r in rows), path + assert [int(r['seq']) for r in rows] == list(range(1, len(rows) + 1)), path + for row in rows: + bytes.fromhex(row['value_hex']) + if fixture == 'abort': + subprocess.run([sys.executable, str(Path(__file__).with_name('check_abort.py')), str(path)], check=True) + else: + assert any(r['op'] == 'boundary' for r in rows), f'{path}: GC boundary never reached' + assert (d / f'report-{watch}.live.tsv').is_file(), f'{path}: GC completion missing' + stdout = (d / f'stdout-{watch}').read_text().split() + assert stdout == (['function'] if fixture == 'effect' else ['1000'] * 8), stdout + print(f'{fixture} watch={watch}: complete ordered prefix, {len(rows)} events') diff --git a/scripts/snap_inputs/fixtures/abort/abort.ts b/scripts/snap_inputs/fixtures/abort/abort.ts new file mode 100644 index 00000000000..5872ab9d9bb --- /dev/null +++ b/scripts/snap_inputs/fixtures/abort/abort.ts @@ -0,0 +1,4 @@ +const x = process.env.SNAP22_ABORT_SENTINEL; +const y = process.argv; +process.abort(); +export { x, y }; diff --git a/scripts/snap_inputs/fixtures/abort/main.ts b/scripts/snap_inputs/fixtures/abort/main.ts new file mode 100644 index 00000000000..289ed994634 --- /dev/null +++ b/scripts/snap_inputs/fixtures/abort/main.ts @@ -0,0 +1,2 @@ +import "./abort"; +console.log("UNREACHABLE"); diff --git a/scripts/snap_inputs/fixtures/allinputs/inputs.ts b/scripts/snap_inputs/fixtures/allinputs/inputs.ts new file mode 100644 index 00000000000..5317e117e99 --- /dev/null +++ b/scripts/snap_inputs/fixtures/allinputs/inputs.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { randomBytes, randomUUID } from "node:crypto"; +import { isatty } from "node:tty"; +import { performance } from "node:perf_hooks"; +const env = process.env; +const envNames = Object.keys(env); +const values = [ + process.env.SNAP21_SENTINEL, "SNAP21_ABSENT" in env, envNames.length, + process.argv.join("|"), process.cwd(), navigator.language, + new Intl.DateTimeFormat().resolvedOptions().timeZone, + isatty(0), isatty(1), isatty(2), process.stdout.columns, + process.getuid(), process.getgid(), process.pid, process.ppid, + Date.now(), performance.now(), process.hrtime.bigint().toString(), + Math.random(), randomBytes(4).toString("hex"), randomUUID(), + readFileSync("package.json","utf8"), readFileSync("inputs.ts","utf8"), + new Error("SNAP21").stack, +]; +console.log("INPUTS21", values.length); +export { values }; diff --git a/scripts/snap_inputs/fixtures/allinputs/main.ts b/scripts/snap_inputs/fixtures/allinputs/main.ts new file mode 100644 index 00000000000..96adfc9142d --- /dev/null +++ b/scripts/snap_inputs/fixtures/allinputs/main.ts @@ -0,0 +1,3 @@ +await import("./inputs.ts"); +gc(); +console.log("DONE21"); diff --git a/scripts/snap_inputs/fixtures/allinputs/package.json b/scripts/snap_inputs/fixtures/allinputs/package.json new file mode 100644 index 00000000000..d0bd4117f85 --- /dev/null +++ b/scripts/snap_inputs/fixtures/allinputs/package.json @@ -0,0 +1 @@ +{"name":"snap21-host-input-fixture","type":"module"} diff --git a/scripts/snap_inputs/fixtures/effect/main.ts b/scripts/snap_inputs/fixtures/effect/main.ts new file mode 100644 index 00000000000..494671cad93 --- /dev/null +++ b/scripts/snap_inputs/fixtures/effect/main.ts @@ -0,0 +1,3 @@ +import * as Result from "effect/Result"; +console.log(typeof Result.succeed); +(globalThis as any).gc(); diff --git a/scripts/snap_inputs/fixtures/net/main.ts b/scripts/snap_inputs/fixtures/net/main.ts new file mode 100644 index 00000000000..8abacd34ff1 --- /dev/null +++ b/scripts/snap_inputs/fixtures/net/main.ts @@ -0,0 +1,3 @@ +await import("./network.ts"); +gc(); +console.log("NET21DONE"); diff --git a/scripts/snap_inputs/fixtures/net/network.ts b/scripts/snap_inputs/fixtures/net/network.ts new file mode 100644 index 00000000000..c39935b9517 --- /dev/null +++ b/scripts/snap_inputs/fixtures/net/network.ts @@ -0,0 +1,6 @@ +import { createServer } from "node:net"; +const server=createServer(); +server.listen(0,"127.0.0.1"); +const address=server.address(); +server.close(); +export { address }; diff --git a/scripts/snap_inputs/fixtures/pressure/m0.ts b/scripts/snap_inputs/fixtures/pressure/m0.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m0.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m1.ts b/scripts/snap_inputs/fixtures/pressure/m1.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m1.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m2.ts b/scripts/snap_inputs/fixtures/pressure/m2.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m2.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m3.ts b/scripts/snap_inputs/fixtures/pressure/m3.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m3.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m4.ts b/scripts/snap_inputs/fixtures/pressure/m4.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m4.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m5.ts b/scripts/snap_inputs/fixtures/pressure/m5.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m5.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m6.ts b/scripts/snap_inputs/fixtures/pressure/m6.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m6.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m7.ts b/scripts/snap_inputs/fixtures/pressure/m7.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m7.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/main.ts b/scripts/snap_inputs/fixtures/pressure/main.ts new file mode 100644 index 00000000000..ccd2f55c842 --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/main.ts @@ -0,0 +1,10 @@ +import { value as value0 } from "./m0"; +import { value as value1 } from "./m1"; +import { value as value2 } from "./m2"; +import { value as value3 } from "./m3"; +import { value as value4 } from "./m4"; +import { value as value5 } from "./m5"; +import { value as value6 } from "./m6"; +import { value as value7 } from "./m7"; +console.log(value0,value1,value2,value3,value4,value5,value6,value7); +(globalThis as any).gc(); diff --git a/scripts/snap_inputs/fixtures/protection/holder.ts b/scripts/snap_inputs/fixtures/protection/holder.ts new file mode 100644 index 00000000000..20278960783 --- /dev/null +++ b/scripts/snap_inputs/fixtures/protection/holder.ts @@ -0,0 +1,2 @@ +export const cells: {value: number}[] = []; +for (let i = 0; i < 3000; i++) cells.push({value: i}); diff --git a/scripts/snap_inputs/fixtures/protection/main.ts b/scripts/snap_inputs/fixtures/protection/main.ts new file mode 100644 index 00000000000..239b7830467 --- /dev/null +++ b/scripts/snap_inputs/fixtures/protection/main.ts @@ -0,0 +1,4 @@ +import { cells } from './holder'; +console.log('before', cells.length); +const m = await import('./writer'); +console.log('boundary complete', m.result); diff --git a/scripts/snap_inputs/fixtures/protection/writer.ts b/scripts/snap_inputs/fixtures/protection/writer.ts new file mode 100644 index 00000000000..5e75a3fb10d --- /dev/null +++ b/scripts/snap_inputs/fixtures/protection/writer.ts @@ -0,0 +1,5 @@ +import { cells } from './holder'; +cells[1500].value = -1; +let sum = 0; +for (let i = 0; i < cells.length; i++) sum += cells[i].value; +export const result = sum; diff --git a/scripts/snap_inputs/fixtures/r1/leaf.ts b/scripts/snap_inputs/fixtures/r1/leaf.ts new file mode 100644 index 00000000000..1e08e59e3b7 --- /dev/null +++ b/scripts/snap_inputs/fixtures/r1/leaf.ts @@ -0,0 +1 @@ +export const value = Number(process.env.SNAP21_SENTINEL ?? "7"); diff --git a/scripts/snap_inputs/fixtures/r1/main.ts b/scripts/snap_inputs/fixtures/r1/main.ts new file mode 100644 index 00000000000..e3a23f8db2a --- /dev/null +++ b/scripts/snap_inputs/fixtures/r1/main.ts @@ -0,0 +1,3 @@ +await import("./parent.ts"); +gc(); +console.log("R1DONE"); diff --git a/scripts/snap_inputs/fixtures/r1/parent.ts b/scripts/snap_inputs/fixtures/r1/parent.ts new file mode 100644 index 00000000000..f3e852f74aa --- /dev/null +++ b/scripts/snap_inputs/fixtures/r1/parent.ts @@ -0,0 +1,2 @@ +import { value } from "./leaf.ts"; +export const answer=value+1; diff --git a/scripts/snap_inputs/fixtures/r2/leaf.ts b/scripts/snap_inputs/fixtures/r2/leaf.ts new file mode 100644 index 00000000000..400295fab26 --- /dev/null +++ b/scripts/snap_inputs/fixtures/r2/leaf.ts @@ -0,0 +1 @@ +export const value=process.argv.length; diff --git a/scripts/snap_inputs/fixtures/r2/main.ts b/scripts/snap_inputs/fixtures/r2/main.ts new file mode 100644 index 00000000000..4ea6f89e31b --- /dev/null +++ b/scripts/snap_inputs/fixtures/r2/main.ts @@ -0,0 +1,3 @@ +await import("./parent.ts"); +gc(); +console.log("R2DONE", globalThis.snap21count); diff --git a/scripts/snap_inputs/fixtures/r2/parent.ts b/scripts/snap_inputs/fixtures/r2/parent.ts new file mode 100644 index 00000000000..3dbd30f076e --- /dev/null +++ b/scripts/snap_inputs/fixtures/r2/parent.ts @@ -0,0 +1,3 @@ +globalThis.snap21count = (globalThis.snap21count ?? 0)+1; +await import("./leaf.ts"); +globalThis.snap21count += 10; diff --git a/scripts/snap_inputs/fixtures/replay/main.ts b/scripts/snap_inputs/fixtures/replay/main.ts new file mode 100644 index 00000000000..926503d7bf0 --- /dev/null +++ b/scripts/snap_inputs/fixtures/replay/main.ts @@ -0,0 +1,15 @@ +// Counterexample component model: deliberately seed the heap at an unsafe +// child boundary. This does not implement or exercise an image loader. +const wrong=process.env.SNAP21_UNSAFE_REPLAY; +let childDone=wrong!==undefined; +let childRuns=childDone?1:0; +const output:string[]=wrong==="R1"?["before"]:[]; +let durableCell=wrong==="R2"?1:0; +function child(){if(childDone)return;childDone=true;childRuns++;} +function bodyStartedParent(){output.push("before");child();output.push("after");} +function writeParent(){const guarded=process.env.SNAP21_REPLAY_VALUE;durableCell++;child();return guarded;} +if(wrong==="R1" || wrong===undefined)bodyStartedParent(); +if(wrong==="R2" || wrong===undefined)writeParent(); +const valid=childRuns===1 && output.join(",")===(wrong==="R2"?"":"before,after") && durableCell===(wrong==="R1"?0:1); +console.log(valid?"EQUIVALENT":"REPEATED_EFFECT",childRuns,output.join(","),durableCell); +if(!valid)process.exit(1); diff --git a/scripts/snap_inputs/fixtures/stop/main.ts b/scripts/snap_inputs/fixtures/stop/main.ts new file mode 100644 index 00000000000..0e35a3eb7cf --- /dev/null +++ b/scripts/snap_inputs/fixtures/stop/main.ts @@ -0,0 +1,4 @@ +await import("./pid.ts"); +gc(); +console.log(globalThis.snap21pid===process.pid ? "fresh" : "STALE"); +if(globalThis.snap21pid!==process.pid) process.exit(1); diff --git a/scripts/snap_inputs/fixtures/stop/pid.ts b/scripts/snap_inputs/fixtures/stop/pid.ts new file mode 100644 index 00000000000..37abc3c6c49 --- /dev/null +++ b/scripts/snap_inputs/fixtures/stop/pid.ts @@ -0,0 +1 @@ +globalThis.snap21pid = process.env.SNAP21_FORCE_IMAGED_PID ? Number(process.env.SNAP21_FORCE_IMAGED_PID) : process.pid; diff --git a/scripts/snap_inputs/protection_fixture.sh b/scripts/snap_inputs/protection_fixture.sh new file mode 100644 index 00000000000..1b7643468ac --- /dev/null +++ b/scripts/snap_inputs/protection_fixture.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Run against separately pinned base/head instrumented binaries. +set -euo pipefail +ulimit -c 0 +binary=$(readlink -f "${1:?binary}") +out=${2:?output directory} +arm=${3:?base or head} +mkdir -p "$out" +out=$(readlink -f "$out") +scripts=$(cd "$(dirname "$0")" && pwd) +: > "$out/results.tsv" +for watch in 0 1; do + set +e + env PERRY_SNAP_NOPROTECT=1 PERRY_SNAP22_WATCH=$watch PERRY_SNAP_CENSUS="$out/report-$watch" \ + timeout 120 python3 "$scripts/trace.py" "$out/native-$watch.jsonl" -- \ + setarch -R "$binary" > "$out/stdout-$watch" 2> "$out/stderr-$watch" + rc=$? + set -e + printf '%s\t%s\n' "$watch" "$rc" >> "$out/results.tsv" +done +python3 "$scripts/check_protection.py" "$out" "$arm" diff --git a/scripts/snap_inputs/sites.py b/scripts/snap_inputs/sites.py new file mode 100644 index 00000000000..09c0200b815 --- /dev/null +++ b/scripts/snap_inputs/sites.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Facts ledger identities from source-span site records, never count verdict rows.""" +import collections,json,os,sys +from pathlib import Path +def canon(p): + return os.path.realpath(p) if p.startswith("/") else p +def ledger(path): + sites=collections.defaultdict(set); verdicts=collections.defaultdict(list) + for line in open(path,errors="strict"): + if not line.startswith("D {"):continue + d=json.loads(line[2:]) + if d.get("record")=="site" and "key" in d and d.get("kind") in ("property","element","value-call","new"): + call=d["kind"] in ("value-call","new") + key=(d.get("unit"),canon(d["file"]),d["start"],d["key"],call) + sites[key].add((canon(d["file"]),d["start"],d["end"],d["kind"],d["key"])) + elif d.get("record")=="verdict": + key=(d.get("unit"),canon(d["file"]),d["start"],d["key"],d["call"]) + verdicts[key].append(not d["verdict"].startswith("No")) + proved=set();missing=[];mixed=[] + for key,vs in verdicts.items(): + if any(vs) and not all(vs):mixed.append(key) + if all(vs): + ids=sites.get(key) + if not ids:missing.append(key) + else:proved.update(ids) + return proved,{"verdict_rows":sum(map(len,verdicts.values())),"groups":len(verdicts), + "mixed":mixed,"unmatched_proven":missing} +def main(): + root=Path(sys.argv[1]); out=Path(sys.argv[2]);out.mkdir(exist_ok=True) + names=["hello","commander_help","commander","fastify_inject","awfy_richards","binary_trees"] + summary=[] + for name in names: + a,da=ledger(root/f"{name}.main.facts.dump");b,db=ledger(root/f"{name}.head.facts.dump") + lost=sorted(a-b);gained=sorted(b-a) + (out/f"{name}.main.json").write_text(json.dumps(sorted(a),indent=2)+"\n") + (out/f"{name}.head.json").write_text(json.dumps(sorted(b),indent=2)+"\n") + (out/f"{name}.lost.json").write_text(json.dumps(lost,indent=2)+"\n") + (out/f"{name}.gained.json").write_text(json.dumps(gained,indent=2)+"\n") + (out/f"{name}.diagnostics.json").write_text(json.dumps({"main":da,"head":db},indent=2)+"\n") + complete=not(da["unmatched_proven"] or db["unmatched_proven"] or da["mixed"] or db["mixed"]) + summary.append((name,len(a),len(b),len(lost),len(gained),complete)) + print(*summary[-1],sep="\t") + if not complete:print("INCOMPLETE",name,da,db) + (out/"summary.tsv").write_text("witness\tmain\thead\tlost\tgained\tcomplete\n"+"".join("\t".join(map(str,r))+"\n" for r in summary)) + return int(any(not r[-1] or r[3] for r in summary)) +if __name__=="__main__":sys.exit(main()) + diff --git a/scripts/snap_inputs/trace.py b/scripts/snap_inputs/trace.py new file mode 100644 index 00000000000..0ab6b63a816 --- /dev/null +++ b/scripts/snap_inputs/trace.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Native input values, exact per-thread instruction positions and module returns. + +Only instrumented executables exposing js_snap_entry_body get return probes. +A guarded-evaluation return is recorded after its registry commit, not at +the earlier census callback exit. Runtime perf reads calibrate the two +counter domains exactly; account.py checks every census Ir charge. +Unknown syscalls and unreadable buffers remain explicit, never silently +certified. Crypto entry probes audit otherwise unhooked RNG providers; +their names are evidence, never an image-admission rule. +This diagnostic companion is never linked into a produced executable. +""" +import ctypes as C, errno, json, os, signal, struct, sys, subprocess, hashlib +from pathlib import Path +L=C.CDLL(None,use_errno=True) +L.ptrace.restype=C.c_long +L.syscall.restype=C.c_long +TRACEME=0; SYSCALL=24; SETOPTIONS=0x4200; GETEVENTMSG=0x4201; INFO=0x420e +WALL=0x40000000 +NAMES={0:"read",1:"write",2:"open",3:"close",4:"stat",5:"fstat",6:"lstat", +7:"poll",8:"lseek",9:"mmap",10:"mprotect",11:"munmap",12:"brk",13:"rt_sigaction", +14:"rt_sigprocmask",15:"rt_sigreturn",16:"ioctl",17:"pread64",18:"pwrite64", +19:"readv",20:"writev",21:"access",22:"pipe",23:"select",24:"sched_yield", +25:"mremap",28:"madvise",32:"dup",33:"dup2",35:"nanosleep",39:"getpid", +41:"socket",42:"connect",43:"accept",44:"sendto",45:"recvfrom",46:"sendmsg", +47:"recvmsg",48:"shutdown",49:"bind",50:"listen",51:"getsockname",52:"getpeername", +53:"socketpair",54:"setsockopt",55:"getsockopt",56:"clone",57:"fork",58:"vfork", +59:"execve",60:"exit",61:"wait4",62:"kill",63:"uname",72:"fcntl",73:"flock", +74:"fsync",75:"fdatasync",78:"getdents",79:"getcwd",80:"chdir",81:"fchdir", +82:"rename",83:"mkdir",84:"rmdir",85:"creat",87:"unlink",89:"readlink", +95:"umask",96:"gettimeofday",97:"getrlimit",98:"getrusage",99:"sysinfo", +102:"getuid",104:"getgid",105:"setuid",106:"setgid",107:"geteuid",108:"getegid", +109:"setpgid",110:"getppid",111:"getpgrp",112:"setsid",115:"getgroups",116:"setgroups", +131:"sigaltstack",157:"prctl",135:"personality",137:"statfs",138:"fstatfs",158:"arch_prctl",186:"gettid", +202:"futex",204:"sched_getaffinity",217:"getdents64",218:"set_tid_address", +228:"clock_gettime",229:"clock_getres",230:"clock_nanosleep",231:"exit_group", +232:"epoll_wait",233:"epoll_ctl",234:"tgkill",257:"openat",262:"newfstatat", +267:"readlinkat",270:"pselect6",271:"ppoll",273:"set_robust_list",281:"epoll_pwait", +288:"accept4",290:"eventfd2",441:"epoll_pwait2",291:"epoll_create1",292:"dup3",293:"pipe2",298:"perf_event_open",299:"recvmmsg", +302:"prlimit64",307:"sendmmsg",318:"getrandom",332:"statx",334:"rseq",435:"clone3", +436:"close_range",439:"faccessat2"} +REP={3,8,9,10,11,12,13,14,15,24,25,28,32,33,60,72,73,158,186,202,218,231,233,234,273,291,292,293,298,334,436} +def ptrace(req,pid,addr=0,data=0): + C.set_errno(0) + r=L.ptrace(req,pid,C.c_void_p(addr),C.c_void_p(data)) + if r==-1 and C.get_errno(): + e=C.get_errno() + raise OSError(e,os.strerror(e)) + return r +def counter(pid): + a=(C.c_uint64*8)() + a[0]=64<<32; a[1]=1; a[5]=(1<<5)|(1<<6) + fd=L.syscall(298,C.byref(a),pid,-1,-1,0) + if fd<0: raise OSError(C.get_errno(),"perf_event_open") + return fd +def ir(fd): + return struct.unpack("Q",os.read(fd,8))[0] +def signed(x): + return C.c_int64(x).value +def main(): + output=sys.argv[1]; command=sys.argv[2:] + if command and command[0]=="--":command=command[1:] + pid=os.fork() + if pid==0: + ptrace(TRACEME,0) + os.kill(os.getpid(),signal.SIGSTOP) + os.execvpe(command[0],command,os.environ) + _,status=os.waitpid(pid,0) + ptrace(SETOPTIONS,pid,0,1|(1<<1)|(1<<2)|(1<<3)|(1<<4)|(1<<20)) + active={pid:{"perf":counter(pid),"entry":None,"mem":None,"diagnostic_fds":set(),"logger_depth":0}} + sequence=0; main_rc=None; failures=[] + out=open(output,"w",buffering=1) + out.write(json.dumps({"kind":"collector","sha256":hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),"command":command})+"\n") + def memory(tid,addr,n): + if n==0:return b"" + if not addr:return None + state=active[tid] + if state["mem"] is None: + try:state["mem"]=os.open(f"/proc/{tid}/mem",os.O_RDONLY) + except OSError:return None + try: + data=os.pread(state["mem"],n,addr) + return data if len(data)==n else None + except (OSError,OverflowError):return None + def string(tid,addr): + if not addr:return None + data=bytearray() + while True: + # Do not cross a page when reading a potentially last-page C string. + n=min(256,4096-((addr+len(data))&4095)) + chunk=memory(tid,addr+len(data),n) + if chunk is None:return {"unreadable":addr+len(data)} + end=chunk.find(b"\0") + if end>=0: + data.extend(chunk[:end]);return data.hex() + data.extend(chunk) + def blob(tid,addr,n): + data=memory(tid,addr,n) + if data is None: + failures.append({"tid":tid,"address":addr,"length":n}) + return {"unreadable":addr,"length":n} + return data.hex() + def iov(tid,addr,count,returned): + raw=memory(tid,addr,count*16) + if raw is None:return {"unreadable_iov":addr} + values=[] + for i in range(count): + base,length=struct.unpack_from("QQ",raw,i*16) + size=min(length,max(0,returned)) + values.append(blob(tid,base,size));returned-=size + return values + breakpoints={}; returns={}; audit_names={}; probe_number=0 + def message(tid,address,returned): + header=memory(tid,address,56) + if header is None:return {"unreadable_msghdr":address} + name,nlen,vec,count,control,clen,flags=struct.unpack("QI4xQQQQi4x",header) + return {"name":blob(tid,name,nlen) if name and nlen else "", + "iov":iov(tid,vec,count,returned),"control":blob(tid,control,clen) if control and clen else "", + "flags":flags} + def put_breakpoint(tid,address,kind): + if address in breakpoints:return + word=ptrace(1,tid,address)&((1<<64)-1) + ptrace(4,tid,address,(word&~255)|0xcc) + breakpoints[address]=(word,kind) + def install_probes(tid): + # Probe a sealed runtime ABI, not a module-name inventory. + exe=os.readlink(f"/proc/{tid}/exe") + symbols={}; audit={} + result=subprocess.run(["nm","-n","--defined-only",exe],capture_output=True,text=True) + if result.returncode:return + for line in result.stdout.splitlines(): + fields=line.split() + if len(fields)==3: + if fields[2].startswith(("js_crypto_","js_webcrypto_","js_vm_")) or fields[2] in ("js_eval_in_scope","js_function_constructor","js_function_new"): + audit.setdefault(int(fields[0],16),[]).append(fields[2]) + if fields[2] in ("js_snap_entry_body","js_run_module_init_guarded"): + symbols[fields[2]]=int(fields[0],16) + if not all(k in symbols for k in ("js_snap_entry_body","js_run_module_init_guarded")):return + with open(exe,"rb") as f:header=f.read(20) + bias=0 + if struct.unpack_from(">16 + if event in (1,2,3): + child=C.c_ulonglong() + ptrace(GETEVENTMSG,tid,0,C.addressof(child)) + out.write(json.dumps({"kind":"new_task","parent":tid,"tid":child.value,"ir":ir(active[tid]["perf"]),"policy":"STOP"})+"\n") + elif event==4: + state=active[tid] + if state["mem"] is not None:os.close(state["mem"]) + state["mem"]=None + breakpoints.clear();returns.clear();audit_names.clear() + install_probes(tid) + elif sig==(signal.SIGTRAP|0x80): + buf=C.create_string_buffer(128) + ptrace(INFO,tid,128,C.addressof(buf)) + raw=buf.raw; op=raw[0]; ip=struct.unpack_from("Q",raw,8)[0] + state=active[tid]; pos=ir(state["perf"]) + if op==1: + nr,*args=struct.unpack_from("7Q",raw,24) + sequence+=1 + entry={"kind":"syscall","seq":sequence,"tid":tid,"ir_enter":pos,"ip":ip,"nr":nr,"name":NAMES.get(nr,"UNKNOWN"),"args":args,"policy":"representation" if nr in REP else "STOP"} + a=args + # Optional raw stack evidence for source audit. These words are not a + # backtrace and never establish an input's eligibility or pointer shape. + if state["logger_depth"]: + entry["policy"]="diagnostic" + if nr not in REP and not state["logger_depth"] and not(nr in (0,1) and a[0] in state["diagnostic_fds"]): + regs=(C.c_ulonglong*27)() + ptrace(12,tid,0,C.addressof(regs)) + sp=int(regs[19]);entry["rsp"]=sp + window=memory(tid,sp,min(2048,4096-(sp&4095))) + entry["stack_window"]=window.hex() if window is not None else None + if nr in (2,4,6,21,80,82,83,84,85,87,89,137):entry["path"]=string(tid,a[0]) + elif nr in (257,262,267,439):entry["path"]=string(tid,a[1]) + if nr==59: + entry["path"]=string(tid,a[0]); argv=[]; index=0 + while True: + item=memory(tid,a[1]+8*index,8) + if item is None:entry["unsupported_payload"]=True;break + ptr=struct.unpack("Q",item)[0] + if not ptr:break + argv.append(string(tid,ptr));index+=1 + entry["argv"]=argv + envp=[];index=0 + while True: + item=memory(tid,a[2]+8*index,8) + if item is None:entry["unsupported_payload"]=True;break + pointer=struct.unpack("Q",item)[0] + if not pointer:break + envp.append(string(tid,pointer));index+=1 + entry["environment"]=envp + if nr==157 and a[0]==0x53564d41:entry["vma_name"]=string(tid,a[4]) + if nr==157 and a[0]==15:entry["input_name"]=string(tid,a[1]) + if nr in (1,18,44):entry["input_bytes"]=blob(tid,a[1],a[2]) + elif nr==20:entry["input_bytes"]=iov(tid,a[1],a[2],1<<63) + elif nr==46:entry["message"]=message(tid,a[1],1<<63) + elif nr in (42,49):entry["sockaddr"]=blob(tid,a[1],a[2]) + elif nr==54:entry["option"]=blob(tid,a[3],a[4]) + if nr==8 and (a[0]&0xffffffff)==0xffffffff and a[2]==0x5322: + entry["kind"]="logger_scope";entry["policy"]="diagnostic" + state["logger_depth"] += 1 if a[1] else -1 + if state["logger_depth"]<0:raise RuntimeError("unbalanced logger scope") + if nr==8 and (a[0]&0xffffffff)==0xffffffff and a[2]==0x5321: + entry["kind"]="runtime_marker";entry["runtime_seq"]=a[1] + if nr in (0,1,8) and a[0] in state["diagnostic_fds"]:entry["policy"]="diagnostic" + state["entry"]=entry + elif op==2 and state["entry"] is not None: + e=state["entry"];state["entry"]=None + ret=struct.unpack_from("q",raw,24)[0] + e["ir_exit"]=pos;e["ret"]=ret;e["is_error"]=bool(raw[32]) + a=e["args"];nr=e["nr"] + if ret>=0: + if nr==298 or (nr in (2,257) and e["policy"]=="diagnostic"):state["diagnostic_fds"].add(ret) + if nr==9 and C.c_int(a[4]).value>=0: + # A file-backed mapping is an input/resource boundary even if its + # contents are first used later. Capture this observation; subsequent + # concurrently changed mapped bytes cannot be certified by a syscall + # tracer and therefore the mapping is a STOP. + try: + mapped_fd=os.open(f"/proc/{tid}/fd/{a[4]}",os.O_RDONLY) + try: + st=os.fstat(mapped_fd);length=min(a[1],max(0,st.st_size-a[5])) + data=os.pread(mapped_fd,length,a[5]) + e["file_mapping"]={"file_size":st.st_size,"offset":a[5],"length":a[1], + "observed_file_bytes":data.hex(),"complete":len(data)==length, + "note":"file observation at mmap; concurrent later mapped changes require memory instrumentation"} + if len(data)!=length:e["complete"]=False + finally:os.close(mapped_fd) + except OSError as error: + e["file_mapping"]={"error":error.errno};e["complete"]=False + e["policy"]="STOP" + if nr in (0,17,45,78,217):e["bytes"]=blob(tid,a[1],ret) + elif nr==19:e["bytes"]=iov(tid,a[1],a[2],ret) + elif nr==318:e["bytes"]=blob(tid,a[0],ret) + elif nr in (79,89):e["bytes"]=blob(tid,a[0] if nr==79 else a[1],ret) + elif nr==267:e["bytes"]=blob(tid,a[2],ret) + elif nr in (4,5,6):e["struct"]=blob(tid,a[1],144) + elif nr==262:e["struct"]=blob(tid,a[2],144) + elif nr==332:e["struct"]=blob(tid,a[4],256) + elif nr in (228,229):e["struct"]=blob(tid,a[1],16) + elif nr==96: + e["struct"]=blob(tid,a[0],16) if a[0] else None + e["timezone"]=blob(tid,a[1],8) if a[1] else None + elif nr==63:e["struct"]=blob(tid,a[0],390) + elif nr==99:e["struct"]=blob(tid,a[0],112) + elif nr in (97,302):e["struct"]=blob(tid,a[1] if nr==97 else a[3],16) if (nr==97 or a[3]) else None + elif nr==98:e["struct"]=blob(tid,a[1],144) + elif nr==204:e["struct"]=blob(tid,a[2],ret) + elif nr==115:e["struct"]=blob(tid,a[1],ret*4) if a[1] else None + elif nr in (13,14): + e["old_signal_state"]=blob(tid,a[2],32 if nr==13 else a[3]) if a[2] else None + elif nr==131:e["old_signal_stack"]=blob(tid,a[1],24) if a[1] else None + elif nr in (137,138):e["struct"]=blob(tid,a[1],120) + elif nr==158: + if a[0] in (0x1003,0x1004):e["tls_pointer"]=blob(tid,a[1],8) + elif nr in (23,270): + length=((a[0]+63)//64)*8 + e["fdsets"]=[blob(tid,address,length) if address else None for address in a[1:4]] + e["timeout"]=blob(tid,a[4],16) if a[4] else None + elif nr==61: + e["wait_status"]=blob(tid,a[1],4) if a[1] else None + e["usage"]=blob(tid,a[3],144) if a[3] else None + elif nr==157: + if a[0]==16:e["thread_name"]=blob(tid,a[1],16) + elif a[0]==40:e["struct"]=blob(tid,a[1],8) + elif a[0] in (2,5,9,11,19,25,37):e["struct"]=blob(tid,a[1],4) + elif a[0] not in (1,3,4,6,7,8,10,12,13,14,15,17,18,20,21,22,23,24,26,27,28,29,30,31,32,33,34,35,36,38,39,41,42,43,44,45,46,47,50,51,52,53,54,55,56,57,58,59,63,64,65,66,67,68,0x53564d41,0x59616d61): + e["unsupported_payload"]=True + elif nr==16: + if a[1] in (0x5413,0x5401,0x541b):e["struct"]=blob(tid,a[2],{0x5413:8,0x5401:36,0x541b:4}[a[1]]) + else:e["unsupported_payload"]=True + elif nr in (7,271): + e["struct"]=blob(tid,a[0],a[1]*8) + if nr==271 and a[2]:e["timeout"]=blob(tid,a[2],16) + elif nr in (232,281,441):e["struct"]=blob(tid,a[1],ret*12) + elif nr in (43,51,52,288) and a[1] and a[2]: + size=memory(tid,a[2],4) + e["sockaddr"]=blob(tid,a[1],struct.unpack("I",size)[0]) if size else {"unreadable_socklen":a[2]} + elif nr==55: + size=memory(tid,a[4],4) + e["option"]=blob(tid,a[3],struct.unpack("I",size)[0]) if size else {"unreadable_socklen":a[4]} + elif nr==53:e["fds"]=blob(tid,a[3],8) + elif nr in (22,293):e["fds"]=blob(tid,a[0],8) + elif nr==47:e["message"]=message(tid,a[1],ret) + elif nr in (299,307): + messages=[] + for index in range(ret): + item=a[1]+index*64 + length=memory(tid,item+56,4) + if length is None:messages.append({"unreadable_mmsghdr":item});continue + messages.append(message(tid,item,struct.unpack("I",length)[0])) + e["messages"]=messages + if ret==-errno.EINTR and nr in (35,230): + address=a[1] if nr==35 else a[3] + e["remaining_time"]=blob(tid,address,16) if address else None + def unreadable(value): + if isinstance(value,dict):return any(str(k).startswith("unreadable") or unreadable(v) for k,v in value.items()) + if isinstance(value,list):return any(unreadable(v) for v in value) + return False + if e["name"]=="UNKNOWN" or e.get("unsupported_payload") or e.get("complete") is False or unreadable(e):e["complete"]=False + else:e["complete"]=True + out.write(json.dumps(e,separators=(",",":"))+"\n") + elif event == 0 and sig==signal.SIGTRAP and handle_probe(tid): + pass + elif event == 0 and sig != signal.SIGSTOP: + information=C.create_string_buffer(128) + ptrace(0x4202,tid,0,C.addressof(information)) + out.write(json.dumps({"kind":"signal","tid":tid,"signal":sig,"ir":ir(active[tid]["perf"]), + "siginfo_hex":information.raw.hex()})+"\n") + ptrace(SYSCALL,tid,0,sig);continue + ptrace(SYSCALL,tid) + out.write(json.dumps({"kind":"summary","main_rc":main_rc,"memory_failures":failures})+"\n") + out.close() + return main_rc if main_rc is not None else 1 +if __name__=="__main__": + sys.exit(main()) +