Skip to content
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"]
11 changes: 11 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 Down Expand Up @@ -414,6 +424,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
7 changes: 7 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
66 changes: 50 additions & 16 deletions crates/basal-host/src/builtins/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,29 @@
#[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};
Expand All @@ -49,6 +60,13 @@ pub use linux::{
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 +88,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 +99,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 @@ -121,13 +139,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 +184,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 +208,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 +228,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 +258,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 +313,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 +323,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 +350,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 +365,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 +392,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 +454,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 +466,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 +605,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 +616,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 +638,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 +655,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 +668,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 +688,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 +732,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 +851,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 +894,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