Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3fa116b
mason: implement Windows host fs module and raw-spelling validation
ualtinok Oct 10, 2026
76b9008
mason: add comprehensive Windows fs refusal tests and deterministic t…
ualtinok Oct 10, 2026
3d184fc
mason: suppress unused import warning in fs test module on non-unix
ualtinok Oct 10, 2026
c63f497
Merge: the Windows fs built-in, with Windows refusal tests
ualtinok Oct 10, 2026
0611455
Refuse the superscript COM and LPT device names on Windows
ualtinok Oct 10, 2026
5621897
mason: implement Windows host git module and job containment
ualtinok Oct 10, 2026
7d24263
mason: fix the Windows fs built-in from its security review
ualtinok Oct 10, 2026
51662ad
mason: add basal-launch, the Windows confined worker launcher
ualtinok Oct 10, 2026
1f8d083
mason: clarify basal-launch comments for a reader without context
ualtinok Oct 10, 2026
8c40332
mason: treat a job-killed worker as killed, and give the worker a lon…
ualtinok Oct 10, 2026
02b46b1
Merge commit '7d24263' into alfonso/task/bg_d6985ae075a52784-basal-wi…
ualtinok Oct 10, 2026
9ea2994
Merge: Windows fs built-in fixes from its security review (44 Windows…
ualtinok Oct 10, 2026
651c119
Draw host entropy from BCryptGenRandom on Windows
ualtinok Oct 10, 2026
9cafad6
Run basal-host's tests on their own in the Windows baseline job
ualtinok Oct 10, 2026
0897adc
Re-anchor the random-source control on the shared entropy call
ualtinok Oct 10, 2026
4ada060
Merge: basal-launch, the Windows confined worker launcher (all 18 lau…
ualtinok Oct 10, 2026
0eb28a0
mason: confine Windows git reads and pin their approved directories
ualtinok Oct 10, 2026
c690483
Merge (CI only): Windows git built-in redo
ualtinok Oct 10, 2026
80c4314
mason: accept the host Windows temp directory separator
ualtinok Oct 10, 2026
a3722ea
Merge (CI only): Windows git built-in redo, temp-base fix
ualtinok Oct 10, 2026
4bd6090
mason: observe handle inheritance in the exact Windows git child
ualtinok Oct 10, 2026
39d05bf
Merge (CI only): Windows git built-in redo, exact-child handle check
ualtinok Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
[env]
# Allocation accounting is unused; its global mutex serializes SQLite callers.
LIBSQLITE3_FLAGS = "-DSQLITE_DEFAULT_MEMSTATUS=0"

# Link the C runtime statically on Windows, so no image imports a C runtime
# DLL. The confined worker should load only the system's own DLLs, and the
# dynamic C runtime is a separately installed component.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,16 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-windows-baseline-
- name: Prepare the log directory
run: mkdir -p "$RUNNER_TEMP/windows-baseline"
# The Windows launcher's own tests start real confined children, so
# they can only run here. The deviations feature adds the test-only
# launch variants, one per confinement check. They run first and print
# what the parent read back, so the log shows it even when the
# workspace steps below fail.
- name: Test the Windows launcher with its test variants
id: test-launch
continue-on-error: true
timeout-minutes: 20
run: cargo test -p basal-launch --features deviations --locked -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log"
- name: Check every workspace target
id: check
continue-on-error: true
Expand All @@ -406,6 +416,13 @@ jobs:
continue-on-error: true
timeout-minutes: 40
run: cargo test --workspace --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-no-fail-fast.log"
# The workspace test can't build until every crate compiles on Windows,
# so basal-host's own tests run separately to report its built-ins now.
- name: Test basal-host on its own
id: test-host
continue-on-error: true
timeout-minutes: 30
run: cargo test -p basal-host --lib --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-host.log"
- name: Summarize the baseline
if: always()
run: |
Expand All @@ -414,6 +431,7 @@ jobs:
echo
echo "- cargo check --workspace --all-targets --locked: ${{ steps.check.outcome }}"
echo "- cargo test --workspace --locked: ${{ steps.test.outcome }}"
echo "- cargo test -p basal-launch --features deviations --locked: ${{ steps.test-launch.outcome }}"
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"crates/basal-core",
"crates/basal-testkit",
"crates/basal-module",
"crates/basal-launch",
"crates/basal-rig",
]

Expand All @@ -20,6 +21,7 @@ rust-version = "1.88"
# Dependencies do not require sibling checkouts.
basal-core = { path = "crates/basal-core" }
basal-host = { path = "crates/basal-host" }
basal-launch = { path = "crates/basal-launch" }
basal-proto = { path = "crates/basal-proto" }
basal-testkit = { path = "crates/basal-testkit" }
blake3 = "1.8"
Expand Down
3 changes: 3 additions & 0 deletions crates/basal-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ tokio.workspace = true
tracing.workspace = true
webpki-roots.workspace = true

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] }

[dev-dependencies]
subc-transport.workspace = true
71 changes: 54 additions & 17 deletions crates/basal-host/src/builtins/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,48 @@
#[cfg(test)]
mod tests;

use std::ffi::{CString, OsStr, OsString};
#[cfg(unix)]
use std::ffi::CString;
use std::ffi::{OsStr, OsString};
#[cfg(unix)]
use std::fs::File;
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
use std::fs::OpenOptions;
#[cfg(unix)]
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

#[cfg(unix)]
use std::path::Component;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicU64;
#[cfg(unix)]
use std::sync::atomic::Ordering;

#[cfg(unix)]
use serde_json::{Value, json};

use super::{Denial, codes, expand_home};
#[cfg(unix)]
use super::expand_home;
use super::{Denial, codes};

#[cfg(target_os = "linux")]
pub use linux::{
KernelCapability, ResolvedPath, Target, open_checked, open_checked_with_capability, resolve,
stat,
};

pub mod windows;
#[cfg(windows)]
pub use windows::{
Target, list, open_checked, read, remove_legacy_temps, remove_temp, resolve, stat, write,
write_call,
};

/// `fs.read`'s default cap.
pub const DEFAULT_READ_BYTES: u64 = super::MAX_TEXT_RESULT_BYTES as u64;
/// The largest cap `fs.read` accepts.
Expand All @@ -70,7 +90,7 @@ pub enum Purpose {

/// Where a path resolved to.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
pub enum Target {
/// It exists; this is its real path.
Existing(PathBuf),
Expand All @@ -81,7 +101,7 @@ pub enum Target {

/// The roots, each resolved to its real path. A root that does not exist
/// now grants nothing.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
fn real_roots(roots: &[String]) -> Vec<PathBuf> {
roots
.iter()
Expand Down Expand Up @@ -111,6 +131,7 @@ fn io_denial(path: &Path, e: &std::io::Error) -> Denial {
}

/// The script's path as an absolute path, with `~` expanded.
#[cfg(unix)]
fn absolute(path: &str) -> Result<PathBuf, Denial> {
if path.len() > MAX_PATH_BYTES || path.contains('\0') {
return Err(Denial::invalid(format!(
Expand All @@ -121,13 +142,13 @@ fn absolute(path: &str) -> Result<PathBuf, Denial> {
}

/// Resolves `path` and requires it to lie under one of `roots`.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result<Target, Denial> {
let roots = real_roots(roots);
resolve_real(path, &roots, purpose)
}

#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result<Target, Denial> {
let path = absolute(path)?;
if purpose == Purpose::Read {
Expand Down Expand Up @@ -166,7 +187,7 @@ fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result<Targe
}

/// Where the kernel says an open descriptor's file is.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
fn fd_path(fd: RawFd) -> std::io::Result<PathBuf> {
#[cfg(target_os = "macos")]
{
Expand All @@ -190,7 +211,7 @@ fn fd_path(fd: RawFd) -> std::io::Result<PathBuf> {

/// Requires the file behind `fd` (or, with `name`, the entry `name` in the
/// directory behind `fd`) to lie under one of `roots`.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
fn verify(fd: RawFd, name: Option<&OsStr>, roots: &[PathBuf]) -> Result<(), Denial> {
let mut real = fd_path(fd).map_err(|e| Denial::new(codes::IO, e.to_string()))?;
if let Some(name) = name {
Expand All @@ -210,12 +231,12 @@ fn verify(fd: RawFd, name: Option<&OsStr>, roots: &[PathBuf]) -> Result<(), Deni
/// it was resolved against. A symlink swapped into the last component
/// since resolution fails the open; one swapped in higher up is caught by
/// the check after it.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
pub fn open_checked(resolved: &Path, roots: &[String], directory: bool) -> Result<File, Denial> {
open_checked_real(resolved, &real_roots(roots), directory)
}

#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
fn open_checked_real(resolved: &Path, roots: &[PathBuf], directory: bool) -> Result<File, Denial> {
let mut flags = libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK;
if directory {
Expand All @@ -240,6 +261,7 @@ fn open_checked_real(resolved: &Path, roots: &[PathBuf], directory: bool) -> Res
}

/// `fs.read`: the file's text, refused over `max_bytes` or when not UTF-8.
#[cfg(unix)]
pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result<Value, Denial> {
#[cfg(not(target_os = "linux"))]
let roots = real_roots(roots);
Expand Down Expand Up @@ -294,6 +316,7 @@ pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result<Value, Denia
Ok(json!({ "text": text }))
}

#[cfg(unix)]
fn kind_of(mode: libc::mode_t) -> &'static str {
match mode & libc::S_IFMT {
libc::S_IFREG => "file",
Expand All @@ -303,12 +326,14 @@ fn kind_of(mode: libc::mode_t) -> &'static str {
}
}

#[cfg(unix)]
fn c_name(name: &OsStr) -> Result<CString, Denial> {
CString::new(name.as_bytes()).map_err(|_| Denial::invalid("a name with a NUL byte"))
}

/// `lstat` of `name` in the directory behind `dir`; `None` when it does
/// not exist.
#[cfg(unix)]
fn stat_at(dir: RawFd, name: &OsStr) -> Result<Option<libc::stat>, Denial> {
let c = c_name(name)?;
let mut st = std::mem::MaybeUninit::<libc::stat>::uninit();
Expand All @@ -328,7 +353,7 @@ fn stat_at(dir: RawFd, name: &OsStr) -> Result<Option<libc::stat>, Denial> {
}

/// Opens the parent of an entry and checks the entry's real path.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
fn open_parent(parent: &Path, name: &OsStr, roots: &[PathBuf]) -> Result<File, Denial> {
let file = OpenOptions::new()
.read(true)
Expand All @@ -343,7 +368,7 @@ fn open_parent(parent: &Path, name: &OsStr, roots: &[PathBuf]) -> Result<File, D
/// answers `{exists: false}` when its parent lies under a root.
/// Existing symlinks are followed by resolution; only a dangling symlink is
/// reported as `kind: "symlink"` by the descriptor-relative metadata lookup.
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
pub fn stat(path: &str, roots: &[String]) -> Result<Value, Denial> {
let roots = real_roots(roots);
let (parent, name) = match resolve_real(path, &roots, Purpose::Read)? {
Expand All @@ -370,6 +395,7 @@ pub fn stat(path: &str, roots: &[String]) -> Result<Value, Denial> {

/// `fs.list`: the directory's entries, sorted by name, refused over
/// [`MAX_LIST_ENTRIES`] or when a name is not UTF-8.
#[cfg(unix)]
pub fn list(path: &str, roots: &[String]) -> Result<Value, Denial> {
#[cfg(not(target_os = "linux"))]
let roots = real_roots(roots);
Expand Down Expand Up @@ -431,6 +457,7 @@ pub fn list(path: &str, roots: &[String]) -> Result<Value, Denial> {

/// The entries of the directory behind `fd` (not `.` or `..`) with their
/// `d_type`, reading at most one more than [`MAX_LIST_ENTRIES`].
#[cfg(unix)]
fn read_dir_fd(fd: RawFd) -> std::io::Result<Vec<(OsString, u8)>> {
let mut out = Vec::new();
scan_dir(fd, |name, d_type| {
Expand All @@ -442,6 +469,7 @@ fn read_dir_fd(fd: RawFd) -> std::io::Result<Vec<(OsString, u8)>> {

/// Hands each entry of the directory behind `fd` (not `.` or `..`) with its
/// `d_type` to `visit`, until `visit` answers false.
#[cfg(unix)]
fn scan_dir(fd: RawFd, mut visit: impl FnMut(OsString, u8) -> bool) -> std::io::Result<()> {
// fdopendir takes ownership of the descriptor it is given, so it gets
// a duplicate and the caller keeps its own.
Expand Down Expand Up @@ -580,6 +608,7 @@ pub fn is_legacy_temp_name(name: &OsStr) -> bool {
}

/// What [`unlink_regular`] found under a name.
#[cfg(unix)]
enum Unlinked {
Removed,
Absent,
Expand All @@ -590,6 +619,7 @@ enum Unlinked {
/// Removes `name` from the directory behind `dir` only if it is a regular
/// file. The name is examined without following a symlink, and unlinkat
/// removes the entry itself, so a symlink's target is never touched.
#[cfg(unix)]
fn unlink_regular(dir: RawFd, name: &OsStr) -> Result<Unlinked, Denial> {
match stat_at(dir, name)? {
None => return Ok(Unlinked::Absent),
Expand All @@ -611,6 +641,7 @@ fn unlink_regular(dir: RawFd, name: &OsStr) -> Result<Unlinked, Denial> {
}
}

#[cfg(unix)]
fn replacement_mode(mode: libc::mode_t) -> libc::mode_t {
mode & 0o777
}
Expand All @@ -627,6 +658,7 @@ pub(super) fn check_write_size(bytes: usize) -> Result<(), Denial> {
}

/// `fs.write` outside a journaled call, under a key of its own.
#[cfg(unix)]
pub fn write(path: &str, roots: &[String], text: &str) -> Result<Value, Denial> {
let key = format!(
"local-{}-{}",
Expand All @@ -639,6 +671,7 @@ pub fn write(path: &str, roots: &[String], text: &str) -> Result<Value, Denial>
/// Resolves `path` for a write and opens its parent directory, checked
/// against `roots`: the directory's real path, the name in it, and the
/// open directory.
#[cfg(unix)]
fn open_write_parent(path: &str, roots: &[String]) -> Result<(PathBuf, OsString, File), Denial> {
#[cfg(not(target_os = "linux"))]
let real = real_roots(roots);
Expand All @@ -658,6 +691,7 @@ fn open_write_parent(path: &str, roots: &[String]) -> Result<(PathBuf, OsString,

/// Creates the temporary file `name` (`c` is the same name) in the
/// directory behind `dir`, never following a symlink.
#[cfg(unix)]
fn create_temp(dir: RawFd, name: &OsStr, c: &CString, mode: libc::mode_t) -> std::io::Result<File> {
let mut replaced = false;
loop {
Expand Down Expand Up @@ -701,6 +735,7 @@ fn create_temp(dir: RawFd, name: &OsStr, c: &CString, mode: libc::mode_t) -> std
/// `ledger`, the file is recorded there before it is created and the record
/// is cleared only once the file is gone and that is durable, so however
/// the call ends, crash included, a file it left behind is on record.
#[cfg(unix)]
pub fn write_call(
path: &str,
roots: &[String],
Expand Down Expand Up @@ -819,6 +854,7 @@ pub enum TempRemoval {
/// following a symlink, and nothing else is ever removed. Removing a file
/// that is already gone is not an error, so this may run any number of
/// times. `Err` is a failure that may pass: keep the record and try again.
#[cfg(unix)]
pub fn remove_temp(lease: &TempLease) -> Result<TempRemoval, Denial> {
if !is_temp_name(&lease.temp) && !is_legacy_temp_name(&lease.temp) {
return Ok(TempRemoval::Refused(Denial::invalid(
Expand Down Expand Up @@ -861,6 +897,7 @@ pub fn remove_temp(lease: &TempLease) -> Result<TempRemoval, Denial> {
/// is opened and checked against `roots` as a write to `path` would be;
/// symlinks are never followed and nothing else is removed. Returns how
/// many files were removed.
#[cfg(unix)]
pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result<usize, Denial> {
let (parent, _, dir) = open_write_parent(path, roots)?;
let mut names = Vec::new();
Expand Down
2 changes: 2 additions & 0 deletions crates/basal-host/src/builtins/fs/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#[cfg(unix)]
use super::*;

#[cfg(unix)]
#[test]
fn replacement_mode_never_carries_setuid_or_setgid() {
assert_eq!(replacement_mode(0o6755), 0o755);
Expand Down
Loading
Loading