fix(windows): land the sealed Windows Native isolation and ACL fixes

Hosted Windows failed 92 tests on 3a788990 and 14b4d2025. 88 came from the
LPAC isolation probe: `childProcess.spawn(..., stdio: 'pipe')` asks libuv
for named pipes the AppContainer cannot open (`spawn EPERM`). The probe now
captures the fixed descendant receipt in an own-data file and passes
inherited handles, with the same 4096-byte bound, timeout and exact
receipt check. The rebuilt host bundle is included.

Also from the sealed Windows candidate: read stored DACLs through the
kernel descriptor so a normalized view is never written back, reject
reserved DOS basenames (CON, PRN, AUX, NUL, COM1-9, LPT1-9) in atomic
writes, release the test Fleet owner before workspace replacement, and add
the missing win32-arm64 DSH fixture metadata.

Only the Windows files of that candidate are taken. Its Computer Use
consent routing and agent-shell receipt changes are left out of this
slice: they change approval behavior and need their own review. Where the
branch had moved since af5fa5c, `windows.rs` keeps HEAD's validated
`CapabilitySid::registry_read` (one-SID check at construction) and
compiled-host.test.mjs keeps HEAD's equivalent execArgv assertion.

Source: Astra's sealed 24-path candidate
(artifacts/windows-vm-release-20261004/landing-final-review, all 24
snapshot hashes verified against SOURCE_EXPECTED.json), merged per file
against af5fa5c.

Validation: npm --prefix crates/tui/extension-host test: 405 tests,
398 passed, 0 failed, 7 skipped. Fresh `node build.mjs` main host bundle is
byte-identical to the committed dist (the optional builtin/mcp bundle
cannot build here: local node_modules lacks @modelcontextprotocol/client).
cargo check -p codewhale-tui --lib --tests --all-features --locked
--target x86_64-pc-windows-gnu: Finished, no errors. Native Windows tests
are not claimed; hosted Windows CI is the proof.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-04 14:49:59 -07:00
1 parent 3e424a0720
commit 2680ddc663
10 files changed
+815 -245

No files matched your search

+70 -29
View File
@@ -16931,40 +16931,81 @@ async function windowsSandboxProbe(env = process.env) {
if (env.CODEWHALE_WINDOWS_PROBE_DESCENDANT !== "1") {
const args = JSON.parse(env.CODEWHALE_WINDOWS_PROBE_CHILD_ARGS ?? "null");
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) throw new Error("missing Core child probe argv");
await new Promise((resolve5, reject) => {
const child = childProcess.spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: {
...env,
CODEWHALE_WINDOWS_PROBE_DESCENDANT: "1",
CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`
} });
let output = "", bytes = 0;
const finish = (error) => {
clearTimeout(timer);
if (error) {
child.kill();
reject(error);
} else resolve5();
};
const timer = setTimeout(() => finish(new Error("sandbox descendant probe timed out")), 6e3);
child.stdout.on("data", (chunk) => {
bytes += chunk.length;
if (bytes > 4096) finish(new Error("sandbox descendant output exceeds 4096 bytes"));
else output += chunk.toString();
});
child.stderr.on("data", (chunk) => {
bytes += chunk.length;
if (bytes > 4096) finish(new Error("sandbox descendant output exceeds 4096 bytes"));
});
child.once("error", (error) => finish(error));
child.once("close", (code, signal) => {
const outputPath = `${inside}.receipt`;
const fd = fs.openSync(outputPath, "wx+");
try {
await new Promise((resolve5, reject) => {
let child, timer, poll, closeTimer, failure, settled = false;
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearInterval(poll);
clearTimeout(closeTimer);
if (error) reject(error);
else resolve5();
};
const fail2 = (error) => {
if (settled || failure) return;
failure = error;
clearTimeout(timer);
clearInterval(poll);
closeTimer = setTimeout(() => finish(failure), 1e3);
try {
child.kill();
} catch {
}
};
try {
if (code !== 0 || signal || JSON.stringify(JSON.parse(output)) !== JSON.stringify(receipt)) throw new Error("sandbox descendant has no exact denial receipt");
finish();
child = childProcess.spawn(process.execPath, args, { stdio: [0, fd, fd], windowsHide: true, env: {
...env,
CODEWHALE_WINDOWS_PROBE_DESCENDANT: "1",
CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`
} });
} catch (error) {
finish(error);
return;
}
timer = setTimeout(() => fail2(new Error("sandbox descendant probe timed out")), 6e3);
poll = setInterval(() => {
try {
if (fs.fstatSync(fd).size > 4096) fail2(new Error("sandbox descendant output exceeds 4096 bytes"));
} catch (error) {
fail2(error);
}
}, 25);
child.once("error", fail2);
child.once("close", (code, signal) => {
if (settled) return;
if (failure) {
finish(failure);
return;
}
try {
if (code !== 0 || signal) throw new Error("sandbox descendant has no exact denial receipt");
if (fs.fstatSync(fd).size > 4096) throw new Error("sandbox descendant output exceeds 4096 bytes");
const output = Buffer.alloc(4097);
let bytes = 0;
while (bytes < output.length) {
const read = fs.readSync(fd, output, bytes, output.length - bytes, bytes);
if (read === 0) break;
bytes += read;
}
if (bytes > 4096) throw new Error("sandbox descendant output exceeds 4096 bytes");
if (JSON.stringify(JSON.parse(output.subarray(0, bytes).toString())) !== JSON.stringify(receipt)) throw new Error("sandbox descendant has no exact denial receipt");
finish();
} catch (error) {
finish(error);
}
});
});
});
} finally {
try {
fs.closeSync(fd);
} finally {
fs.unlinkSync(outputPath);
}
}
}
return receipt;
}
@@ -41,31 +41,68 @@ export async function windowsSandboxProbe(env = process.env) {
if (env.CODEWHALE_WINDOWS_PROBE_DESCENDANT !== '1') {
const args = JSON.parse(env.CODEWHALE_WINDOWS_PROBE_CHILD_ARGS ?? 'null')
if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new Error('missing Core child probe argv')
await new Promise((resolve, reject) => {
const child = childProcess.spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: {
...env, CODEWHALE_WINDOWS_PROBE_DESCENDANT: '1', CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`,
} })
let output = '', bytes = 0
const finish = (error) => { clearTimeout(timer); if (error) { child.kill(); reject(error) } else resolve() }
const timer = setTimeout(() => finish(new Error('sandbox descendant probe timed out')), 6000)
child.stdout.on('data', (chunk) => {
bytes += chunk.length
if (bytes > 4096) finish(new Error('sandbox descendant output exceeds 4096 bytes'))
else output += chunk.toString()
})
// Drain stderr without retaining arbitrary output.
child.stderr.on('data', (chunk) => {
bytes += chunk.length
if (bytes > 4096) finish(new Error('sandbox descendant output exceeds 4096 bytes'))
})
child.once('error', (error) => finish(error))
child.once('close', (code, signal) => {
// LPAC cannot open NUL or libuv's global named pipes. Core already gave
// us an EOF stdin handle; capture the fixed child receipt in own-data and
// inherit all three handles instead of asking the runtime to create any.
const outputPath = `${inside}.receipt`
const fd = fs.openSync(outputPath, 'wx+')
try {
await new Promise((resolve, reject) => {
let child, timer, poll, closeTimer, failure, settled = false
const finish = (error) => {
if (settled) return
settled = true
clearTimeout(timer); clearInterval(poll); clearTimeout(closeTimer)
if (error) reject(error)
else resolve()
}
const fail = (error) => {
if (settled || failure) return
failure = error
clearTimeout(timer); clearInterval(poll)
// Wait for inherited handles to close before cleanup. Core's outer
// job deadline still kills the whole tree if termination stalls.
closeTimer = setTimeout(() => finish(failure), 1000)
try { child.kill() } catch {}
}
try {
if (code !== 0 || signal || JSON.stringify(JSON.parse(output)) !== JSON.stringify(receipt)) throw new Error('sandbox descendant has no exact denial receipt')
finish()
} catch (error) { finish(error) }
child = childProcess.spawn(process.execPath, args, { stdio: [0, fd, fd], windowsHide: true, env: {
...env, CODEWHALE_WINDOWS_PROBE_DESCENDANT: '1', CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`,
} })
} catch (error) { finish(error); return }
timer = setTimeout(() => fail(new Error('sandbox descendant probe timed out')), 6000)
// This stops excess output promptly; it bounds neither a single write
// nor disk growth between polls. The fixed diagnostic has no plugin code.
poll = setInterval(() => {
try {
if (fs.fstatSync(fd).size > 4096) fail(new Error('sandbox descendant output exceeds 4096 bytes'))
} catch (error) { fail(error) }
}, 25)
child.once('error', fail)
child.once('close', (code, signal) => {
if (settled) return
if (failure) { finish(failure); return }
try {
if (code !== 0 || signal) throw new Error('sandbox descendant has no exact denial receipt')
if (fs.fstatSync(fd).size > 4096) throw new Error('sandbox descendant output exceeds 4096 bytes')
const output = Buffer.alloc(4097)
let bytes = 0
while (bytes < output.length) {
// Inherited file handles share their offset; always read from
// the beginning explicitly, including after a short read.
const read = fs.readSync(fd, output, bytes, output.length - bytes, bytes)
if (read === 0) break
bytes += read
}
if (bytes > 4096) throw new Error('sandbox descendant output exceeds 4096 bytes')
if (JSON.stringify(JSON.parse(output.subarray(0, bytes).toString())) !== JSON.stringify(receipt)) throw new Error('sandbox descendant has no exact denial receipt')
finish()
} catch (error) { finish(error) }
})
})
})
} finally {
try { fs.closeSync(fd) } finally { fs.unlinkSync(outputPath) }
}
}
return receipt
}
@@ -5,7 +5,6 @@ import fs from 'node:fs'
import net from 'node:net'
import cp from 'node:child_process'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { windowsSandboxProbe } from '../src/windows-sandbox-probe.mjs'
@@ -18,8 +17,10 @@ const env = {
}
function controls(t, options = {}) {
let value
const originals = [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, net.connect, cp.spawn]
t.after(() => { [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, net.connect, cp.spawn] = originals })
const outputPath = `${env.CODEWHALE_WINDOWS_PROBE_INSIDE}.receipt`
const state = { created: false, closed: false, removed: false, killed: 0, events: [], positions: [], output: Buffer.from(options.output ?? JSON.stringify(receipt)) }
const originals = [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, fs.openSync, fs.fstatSync, fs.readSync, fs.closeSync, net.connect, cp.spawn]
t.after(() => { [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, fs.openSync, fs.fstatSync, fs.readSync, fs.closeSync, net.connect, cp.spawn] = originals })
const denied = (code) => Object.assign(new Error(code), { code })
fs.writeFileSync = (path, content) => {
if (path === env.CODEWHALE_WINDOWS_PROBE_INSIDE) { value = content; return }
@@ -30,7 +31,32 @@ function controls(t, options = {}) {
if (path === env.CODEWHALE_WINDOWS_PROBE_INSIDE) return value
throw denied(options.readError ?? 'EACCES')
}
fs.unlinkSync = () => { value = undefined }
fs.unlinkSync = (path) => {
if (path !== outputPath) { value = undefined; return }
assert.equal(state.closed, true, 'close the owned descriptor before removing its file')
state.removed = true; state.events.push('unlink')
}
fs.openSync = (path, flags) => {
assert.equal(path, outputPath)
assert.equal(flags, 'wx+')
if (options.openError) throw denied(options.openError)
state.created = true
return 71
}
fs.fstatSync = (fd) => {
assert.equal(fd, 71); assert.equal(state.closed, false)
return { size: options.statSize ?? state.output.length }
}
fs.readSync = (fd, buffer, offset, length, position) => {
assert.equal(fd, 71); assert.equal(state.closed, false)
assert.ok(length <= 4097)
state.positions.push(position)
return state.output.copy(buffer, offset, position, Math.min(position + length, position + (options.shortRead ?? length)))
}
fs.closeSync = (fd) => {
assert.equal(fd, 71); assert.equal(state.created, true); assert.equal(state.closed, false)
state.closed = true; state.events.push('file-close')
}
net.connect = () => {
const socket = new EventEmitter()
socket.destroy = () => {}
@@ -40,20 +66,81 @@ function controls(t, options = {}) {
})
return socket
}
cp.spawn = (program, args, options) => {
cp.spawn = (program, args, launch) => {
assert.equal(program, process.execPath)
assert.deepEqual(args, ['fixed-probe'])
assert.equal(options.env.CODEWHALE_WINDOWS_PROBE_DESCENDANT, '1')
assert.equal(launch.env.CODEWHALE_WINDOWS_PROBE_DESCENDANT, '1')
assert.equal(launch.env.CODEWHALE_WINDOWS_PROBE_INSIDE, `${env.CODEWHALE_WINDOWS_PROBE_INSIDE}.child`)
assert.deepEqual(launch.stdio, [0, 71, 71], 'no NUL open or runtime-created pipes')
if (options.spawnThrow) throw denied('EPERM')
const child = new EventEmitter()
child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = () => true
process.nextTick(() => { child.stdout.write(JSON.stringify(receipt)); child.emit('close', 0, null) })
state.child = child
const close = (code, signal) => { state.events.push('child-close'); child.emit('close', code, signal) }
child.kill = () => {
state.killed += 1; state.events.push('kill')
if (!options.hangAfterKill) process.nextTick(() => close(null, 'SIGTERM'))
return true
}
if (options.spawnError) process.nextTick(() => child.emit('error', denied('EPERM')))
else if (!options.hang) process.nextTick(() => close(options.exitCode ?? 0, options.signal ?? null))
return child
}
return state
}
test('fixed probe requires exact own-data and parent/descendant denial receipt', async (t) => {
controls(t)
const state = controls(t, { shortRead: 7 })
assert.deepEqual(await windowsSandboxProbe(env), receipt)
assert.equal(state.positions[0], 0)
assert.ok(state.positions.length > 2, 'short reads must be completed with explicit positions')
assert.deepEqual(state.events, ['child-close', 'file-close', 'unlink'])
})
test('preexisting receipt is preserved when exclusive creation fails', async (t) => {
const state = controls(t, { openError: 'EEXIST' })
await assert.rejects(windowsSandboxProbe(env), /EEXIST/)
assert.deepEqual(state.events, [])
assert.equal(state.removed, false)
})
test('synchronous spawn failure cleans up only the newly created receipt', async (t) => {
const state = controls(t, { spawnThrow: true })
await assert.rejects(windowsSandboxProbe(env), /EPERM/)
assert.deepEqual(state.events, ['file-close', 'unlink'])
})
test('asynchronous spawn failure waits for child closure before cleanup', async (t) => {
const state = controls(t, { spawnError: true })
await assert.rejects(windowsSandboxProbe(env), /EPERM/)
assert.deepEqual(state.events, ['kill', 'child-close', 'file-close', 'unlink'])
})
for (const [name, options] of [
['nonzero exit', { exitCode: 1 }],
['signal exit', { signal: 'SIGTERM' }],
['missing receipt', { output: '' }],
['malformed receipt', { output: '{' }],
['incomplete denial receipt', { output: JSON.stringify({ ...receipt, descendant_denied: false }) }],
['stderr mixed into receipt', { output: `${JSON.stringify(receipt)}\nunexpected warning` }],
['oversized receipt', { output: 'x'.repeat(4097) }],
['receipt growth after stat', { output: 'x'.repeat(4097), statSize: 10 }],
]) {
test(`descendant ${name} fails closed and cleans up`, async (t) => {
const state = controls(t, options)
await assert.rejects(windowsSandboxProbe(env))
assert.deepEqual(state.events, ['child-close', 'file-close', 'unlink'])
})
}
test('in-flight excess output terminates the child before its deadline', async (t) => {
const state = controls(t, { hang: true, output: 'x'.repeat(4097) })
await assert.rejects(windowsSandboxProbe(env), /exceeds 4096 bytes/)
assert.deepEqual(state.events, ['kill', 'child-close', 'file-close', 'unlink'])
})
test('descendant timeout and missing close stay bounded; late close cannot reread a cleaned descriptor', async (t) => {
const state = controls(t, { hang: true, hangAfterKill: true })
const timeout = globalThis.setTimeout
t.after(() => { globalThis.setTimeout = timeout })
globalThis.setTimeout = (callback, after, ...args) => timeout(callback, after === 6000 || after === 1000 ? 1 : after, ...args)
await assert.rejects(windowsSandboxProbe(env), /descendant probe timed out/)
assert.deepEqual(state.events, ['kill', 'file-close', 'unlink'])
state.child.emit('close', 0, null)
assert.deepEqual(state.positions, [])
})
test('outside write success is an admission failure', async (t) => {
controls(t, { outsideWrite: true })
@@ -85,6 +172,39 @@ test('network timeout fails admission instead of passing a denial', async (t) =>
test('invalid Core projection fails before side effects', async () => {
await assert.rejects(windowsSandboxProbe({}), /invalid Core sandbox probe projection/)
})
test('actual runtime inherits the receipt descriptor and leaves no file; transport evidence only', async (t) => {
const root = fs.mkdtempSync(join(tmpdir(), 'cw-win-probe-stdio-'))
const inside = join(root, 'inside'), outside = join(root, 'outside')
const reads = ['codex', 'dsh', 'builtin'].map((name) => join(root, name))
const writeFile = fs.writeFileSync, readFile = fs.readFileSync, connect = net.connect
t.after(() => {
fs.writeFileSync = writeFile; fs.readFileSync = readFile; net.connect = connect
fs.rmSync(root, { recursive: true, force: true })
})
const denied = () => Object.assign(new Error('synthetic access denial'), { code: 'EACCES' })
fs.writeFileSync = (path, ...args) => {
if (path === outside) throw denied()
return writeFile(path, ...args)
}
fs.readFileSync = (path, ...args) => {
if (reads.includes(path)) throw denied()
return readFile(path, ...args)
}
net.connect = () => {
const socket = new EventEmitter()
socket.destroy = () => {}
process.nextTick(() => socket.emit('error', denied()))
return socket
}
// Only this transport fixture substitutes the child body. Production still
// receives Core's same full probe argv; these synthetic denials are not LPAC proof.
const args = ['-e', `process.stdout.write(${JSON.stringify(JSON.stringify(receipt) + '\n')})`]
assert.deepEqual(await windowsSandboxProbe({ ...env,
CODEWHALE_WINDOWS_PROBE_INSIDE: inside, CODEWHALE_WINDOWS_PROBE_OUTSIDE: outside,
CODEWHALE_WINDOWS_PROBE_READS: JSON.stringify(reads), CODEWHALE_WINDOWS_PROBE_CHILD_ARGS: JSON.stringify(args),
}), receipt)
assert.deepEqual(fs.readdirSync(root), [])
})
test('actual unrestricted Node is rejected by the fixed write control', async () => {
const root = fs.mkdtempSync(join(tmpdir(), 'cw-win-probe-negative-'))
const inside = join(root, 'inside'), outside = join(root, 'outside')
+274 -159
View File
@@ -23,9 +23,7 @@ use windows_sys::Win32::Foundation::{
ERROR_PIPE_CONNECTED, GetLastError, INVALID_HANDLE_VALUE, LocalFree, WAIT_OBJECT_0,
WAIT_TIMEOUT,
};
use windows_sys::Win32::Security::Authorization::{
GRANT_ACCESS, GetSecurityInfo, REVOKE_ACCESS, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::Authorization::{GRANT_ACCESS, REVOKE_ACCESS};
use windows_sys::Win32::Security::Isolation::{
CreateAppContainerProfile, DeleteAppContainerProfile,
};
@@ -63,7 +61,7 @@ const PROBE_DEADLINE: Duration = Duration::from_secs(15);
const PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT: u32 = 0x1;
const MAX_GRANT_ENTRIES: usize = 65_536;
const MAX_GRANT_SCOPES: usize = 1024;
/// Empty Bun config in the granted runtime-copy directory (see spawn_inner).
/// Empty Bun config in the granted runtime-copy directory (see sandbox_args).
const EMPTY_BUN_CONFIG: &str = "empty-bunfig.toml";
// Serialize Core's read/merge/write ACL operations across old-profile cleanup
// and a new host admission. Never overwrite a concurrently admitted profile.
@@ -585,6 +583,8 @@ impl NativeSandbox {
format!("{PROBE_SOURCE}\nconsole.log(JSON.stringify(await windowsSandboxProbe()))"),
]);
}
// Serialize the same sandbox-safe argv used to launch the parent.
let args = sandbox_args(&args, self._assets.path());
let child_args = (
"CODEWHALE_WINDOWS_PROBE_CHILD_ARGS".to_string(),
serde_json::to_string(&args)?,
@@ -640,7 +640,8 @@ impl NativeSandbox {
env: &[(OsString, OsString)],
memory_cap: u64,
) -> io::Result<Spawned> {
self.spawn_inner(args, env, memory_cap, true)
let args = sandbox_args(args, self._assets.path());
self.spawn_inner(&args, env, memory_cap, true)
}
fn spawn_inner(
@@ -655,8 +656,9 @@ impl NativeSandbox {
let (stderr, child_stderr) = pipe(true, overlapped)?;
let mut attrs = Attributes::new(4)?;
let registry_read = CapabilitySid::registry_read()?;
let capability_sid = registry_read.sid();
let mut capability = SID_AND_ATTRIBUTES {
Sid: registry_read.sid(),
Sid: capability_sid,
Attributes: windows_sys::Win32::System::SystemServices::SE_GROUP_ENABLED as u32,
};
let capabilities = SECURITY_CAPABILITIES {
@@ -691,23 +693,7 @@ impl NativeSandbox {
startup.StartupInfo.hStdError = child_stderr.as_raw_handle();
startup.lpAttributeList = attrs.ptr();
let application = wide(self.program.as_os_str())?;
// An LPAC cannot open the NUL device, so a Bun host reads its empty
// config from the granted runtime-copy directory instead.
let empty_config = format!(
"--config={}",
self._assets.path().join(EMPTY_BUN_CONFIG).display()
);
let args: Vec<String> = args
.iter()
.map(|arg| {
if arg == "--config=NUL" {
empty_config.clone()
} else {
arg.clone()
}
})
.collect();
let mut command = command_line(self.program.as_os_str(), &args)?;
let mut command = command_line(self.program.as_os_str(), args)?;
let directory = wide(self.data.as_os_str())?;
let mut env = env.to_vec();
let temp = self.data.join("tmp").into_os_string();
@@ -747,7 +733,7 @@ impl NativeSandbox {
&process,
)?);
tree.limit_process_memory(memory_cap)?;
verify_token(&process, self.profile.sid, registry_read.sid())?;
verify_token(&process, self.profile.sid, capability_sid)?;
// No Native byte executes until Job, memory and actual LPAC token
// identity/capabilities have all been checked by Rust.
if unsafe { ResumeThread(thread.as_raw_handle()) } != 1 {
@@ -1162,14 +1148,11 @@ impl Drop for CapabilitySid {
fn set_acl(file: &File, sid: PSID, access: u32, inheritance: u32) -> io::Result<()> {
edit_acl(file, sid, access, inheritance, GRANT_ACCESS)
}
/// Edit exactly one SID's explicit allow entry and write the resulting DACL
/// through the kernel on this pinned handle. `SetSecurityInfo` re-derives
/// inheritance: it turned a file's inherited ACEs into explicit copies after a
/// grant/retire cycle, and it would propagate to children. Writing the exact
/// DACL with `SetKernelObjectSecurity` keeps every other ACE byte-for-byte,
/// including inherited ones, and never touches a child or reparse target.
/// Microsoft advises against this API for files precisely because it does not
/// propagate; here that is the required property.
/// Edit exactly one SID's allow entries on this pinned handle. Read the stored
/// descriptor with GetKernelObjectSecurity: GetSecurityInfo can normalize a
/// child's inherited ACEs/control while its parent has a grant. Writing that
/// view back freezes the normalized state. The exact kernel reader/writer pair
/// preserves every other ACE and never propagates changes to child objects.
fn edit_acl(
file: &File,
sid: PSID,
@@ -1179,9 +1162,9 @@ fn edit_acl(
) -> io::Result<()> {
use windows_sys::Win32::Security::{
ACCESS_ALLOWED_ACE, ACE_HEADER, AddAccessAllowedAceEx, AddAce, GetAce, GetLengthSid,
GetSecurityDescriptorControl, INHERITED_ACE, InitializeAcl, InitializeSecurityDescriptor,
SE_DACL_AUTO_INHERIT_REQ, SE_DACL_AUTO_INHERITED, SE_DACL_PROTECTED, SECURITY_DESCRIPTOR,
SetKernelObjectSecurity, SetSecurityDescriptorControl, SetSecurityDescriptorDacl,
INHERITED_ACE, InitializeAcl, InitializeSecurityDescriptor, SE_DACL_AUTO_INHERIT_REQ,
SE_DACL_AUTO_INHERITED, SE_DACL_PROTECTED, SECURITY_DESCRIPTOR, SetKernelObjectSecurity,
SetSecurityDescriptorControl, SetSecurityDescriptorDacl,
};
use windows_sys::Win32::System::SystemServices::{
ACCESS_ALLOWED_ACE_TYPE, SECURITY_DESCRIPTOR_REVISION,
@@ -1192,43 +1175,13 @@ fn edit_acl(
if !grant && mode != REVOKE_ACCESS {
return Err(io::Error::other("unsupported ACL edit mode"));
}
let mut old_acl: *mut ACL = null_mut();
let mut descriptor = null_mut();
let result = unsafe {
GetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
&mut old_acl,
null_mut(),
&mut descriptor,
)
};
if result != 0 {
return Err(io::Error::from_raw_os_error(result as i32));
}
let descriptor = LocalAllocation(descriptor);
if old_acl.is_null() {
return Err(io::Error::other("refusing to replace an unrestricted DACL"));
}
let mut control = 0_u16;
let mut revision = 0_u32;
if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 {
return Err(io::Error::last_os_error());
}
let preserved = control & (SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED);
// An unprotected file has no children, so SetSecurityInfo cannot
// propagate from it; with UNPROTECTED it re-derives the inherited entries
// from the parent. Writing a file's inherited entries back (either API)
// turned them into explicit ones on hosted Windows. Directories and
// protected DACLs keep the exact kernel write, which never propagates.
let derive_inherited = preserved & SE_DACL_PROTECTED == 0 && !file.metadata()?.is_dir();
let descriptor = RawDacl::read(file)?;
let old_acl = descriptor.acl();
let preserved = descriptor.control & (SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED);
let old = std::ptr::NonNull::new(old_acl)
.ok_or_else(|| io::Error::other("refusing to replace an unrestricted DACL"))?;
let (acl_revision, acl_size, ace_count) = {
// SAFETY: GetSecurityInfo returned this DACL inside `descriptor`.
// SAFETY: RawDacl validated this DACL inside its owned descriptor.
let acl = unsafe { old.as_ref() };
(
u32::from(acl.AclRevision),
@@ -1286,10 +1239,6 @@ fn edit_acl(
merged |= unsafe { ace.cast::<ACCESS_ALLOWED_ACE>().as_ref().Mask };
continue;
}
if derive_inherited && inherited {
// Windows re-derives these from the parent on write.
continue;
}
if ours && !grant {
// Retirement removes explicit entries and the stale inherited
// copies left after the parent's grant was revoked first.
@@ -1322,66 +1271,42 @@ fn edit_acl(
{
return Err(io::Error::last_os_error());
}
if derive_inherited {
use windows_sys::Win32::Security::Authorization::SetSecurityInfo;
use windows_sys::Win32::Security::UNPROTECTED_DACL_SECURITY_INFORMATION;
// SAFETY: the pinned handle and the explicit-only ACL outlive the call.
let result = unsafe {
SetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
new_acl,
null(),
)
let mut security: SECURITY_DESCRIPTOR = unsafe { zeroed() };
let security_ptr = (&mut security as *mut SECURITY_DESCRIPTOR).cast();
// NTFS stores this descriptor as given, with no inheritance merge. The
// kernel keeps SE_DACL_AUTO_INHERITED only when SE_DACL_AUTO_INHERIT_REQ
// accompanies it, so request it exactly when the object already had it;
// otherwise the object would silently become a legacy DACL.
let requested = preserved
| if preserved & SE_DACL_AUTO_INHERITED != 0 {
SE_DACL_AUTO_INHERIT_REQ
} else {
0
};
if result != 0 {
return Err(io::Error::from_raw_os_error(result as i32));
}
} else {
let mut security: SECURITY_DESCRIPTOR = unsafe { zeroed() };
let security_ptr = (&mut security as *mut SECURITY_DESCRIPTOR).cast();
// NTFS stores this descriptor as given, with no inheritance merge. The
// kernel keeps SE_DACL_AUTO_INHERITED only when SE_DACL_AUTO_INHERIT_REQ
// accompanies it, so request it exactly when the object already had it;
// otherwise the object would silently become a legacy DACL.
let requested = preserved
| if preserved & SE_DACL_AUTO_INHERITED != 0 {
SE_DACL_AUTO_INHERIT_REQ
} else {
0
};
if unsafe { InitializeSecurityDescriptor(security_ptr, SECURITY_DESCRIPTOR_REVISION) } == 0
|| unsafe { SetSecurityDescriptorDacl(security_ptr, 1, new_acl, 0) } == 0
|| unsafe {
SetSecurityDescriptorControl(
security_ptr,
SE_DACL_AUTO_INHERIT_REQ | SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED,
requested,
)
} == 0
|| unsafe {
SetKernelObjectSecurity(
file.as_raw_handle(),
DACL_SECURITY_INFORMATION,
security_ptr,
)
} == 0
{
return Err(io::Error::last_os_error());
}
if unsafe { InitializeSecurityDescriptor(security_ptr, SECURITY_DESCRIPTOR_REVISION) } == 0
|| unsafe { SetSecurityDescriptorDacl(security_ptr, 1, new_acl, 0) } == 0
|| unsafe {
SetSecurityDescriptorControl(
security_ptr,
SE_DACL_AUTO_INHERIT_REQ | SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED,
requested,
)
} == 0
|| unsafe {
SetKernelObjectSecurity(
file.as_raw_handle(),
DACL_SECURITY_INFORMATION,
security_ptr,
)
} == 0
{
return Err(io::Error::last_os_error());
}
drop(descriptor);
let (still_granted, control) = read_back_dacl(file, sid)?;
// A re-derived file DACL is auto-inherited by construction; it must stay
// unprotected. A kernel-written DACL keeps both bits exactly.
let checked = if derive_inherited {
SE_DACL_PROTECTED
} else {
SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED
};
// The raw descriptor and exact kernel write preserve both bits on files
// and directories, without re-deriving or propagating inherited entries.
let checked = SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED;
if control & checked != preserved & checked {
return Err(io::Error::other(
"edited DACL changed its inheritance control bits",
@@ -1398,37 +1323,14 @@ fn edit_acl(
/// Read back the object's DACL: whether any allow entry names `sid`, and the
/// descriptor's control bits.
fn read_back_dacl(file: &File, sid: PSID) -> io::Result<(bool, u16)> {
use windows_sys::Win32::Security::{
ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce, GetSecurityDescriptorControl,
};
use windows_sys::Win32::Security::{ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce};
use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE;
let mut acl: *mut ACL = null_mut();
let mut descriptor = null_mut();
let result = unsafe {
GetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
&mut acl,
null_mut(),
&mut descriptor,
)
};
if result != 0 {
return Err(io::Error::from_raw_os_error(result as i32));
}
let descriptor = LocalAllocation(descriptor);
let mut control = 0_u16;
let mut revision = 0_u32;
if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 {
return Err(io::Error::last_os_error());
}
let Some(dacl) = std::ptr::NonNull::new(acl) else {
return Ok((false, control));
};
// SAFETY: GetSecurityInfo returned this DACL inside `descriptor`.
let descriptor = RawDacl::read(file)?;
let acl = descriptor.acl();
let control = descriptor.control;
let dacl = std::ptr::NonNull::new(acl)
.ok_or_else(|| io::Error::other("refusing to inspect an unrestricted DACL"))?;
// SAFETY: RawDacl validated this DACL inside its owned descriptor.
for index in 0..u32::from(unsafe { dacl.as_ref().AceCount }) {
let mut ace = null_mut();
if unsafe { GetAce(acl, index, &mut ace) } == 0 {
@@ -1454,7 +1356,205 @@ fn read_back_dacl(file: &File, sid: PSID) -> io::Result<(bool, u16)> {
Ok((false, control))
}
/// Owned DWORD-aligned storage for a bounded self-relative kernel descriptor.
/// The ACL offset is validated once and never outlives this allocation.
struct RawDacl {
storage: Vec<u32>,
acl_offset: usize,
control: u16,
}
impl RawDacl {
fn read(file: &File) -> io::Result<Self> {
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
use windows_sys::Win32::Security::{GetKernelObjectSecurity, SECURITY_DESCRIPTOR_RELATIVE};
let mut required = 0_u32;
// SAFETY: a zero-length size query writes only `required`.
if unsafe {
GetKernelObjectSecurity(
file.as_raw_handle(),
DACL_SECURITY_INFORMATION,
null_mut(),
0,
&mut required,
)
} == 0
{
let error = io::Error::last_os_error();
if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) {
return Err(error);
}
}
// An external ACL writer can change the required size between calls.
// Bound both the allocation and the number of attempts; fail closed.
for _ in 0..3 {
if !(size_of::<SECURITY_DESCRIPTOR_RELATIVE>()..=1024 * 1024)
.contains(&(required as usize))
{
return Err(io::Error::other(
"kernel security descriptor exceeds size bounds",
));
}
let capacity = required;
let mut storage = vec![0_u32; (capacity as usize).div_ceil(size_of::<u32>())];
// SAFETY: storage is aligned, owned, and at least capacity bytes.
if unsafe {
GetKernelObjectSecurity(
file.as_raw_handle(),
DACL_SECURITY_INFORMATION,
storage.as_mut_ptr().cast(),
capacity,
&mut required,
)
} == 0
{
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(ERROR_INSUFFICIENT_BUFFER as i32)
&& required > capacity
{
continue;
}
return Err(error);
}
if required > capacity {
return Err(io::Error::other(
"kernel security descriptor exceeds its buffer",
));
}
return Self::from_storage(storage, required as usize);
}
Err(io::Error::other(
"kernel security descriptor repeatedly changed size",
))
}
fn from_storage(mut storage: Vec<u32>, length: usize) -> io::Result<Self> {
use windows_sys::Win32::Security::{
ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce, GetSecurityDescriptorControl,
GetSecurityDescriptorDacl, IsValidAcl, IsValidSid, SE_SELF_RELATIVE,
SECURITY_DESCRIPTOR_RELATIVE, SID,
};
use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE;
if length < size_of::<SECURITY_DESCRIPTOR_RELATIVE>()
|| length > storage.len() * size_of::<u32>()
{
return Err(io::Error::other("kernel security descriptor is truncated"));
}
let descriptor = storage.as_mut_ptr().cast();
let mut control = 0_u16;
let mut revision = 0_u32;
// SAFETY: the aligned allocation contains the complete descriptor header.
if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0 {
return Err(io::Error::last_os_error());
}
if control & SE_SELF_RELATIVE == 0 {
return Err(io::Error::other(
"kernel security descriptor is not self-relative",
));
}
let mut present = 0;
let mut defaulted = 0;
let mut acl: *mut ACL = null_mut();
// SAFETY: only the validated self-relative header is read here. Bound
// the returned DACL pointer before dereferencing any of its bytes.
if unsafe { GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) }
== 0
{
return Err(io::Error::last_os_error());
}
if present == 0 || acl.is_null() {
return Err(io::Error::other("refusing an absent or unrestricted DACL"));
}
let acl_offset = (acl as usize)
.checked_sub(descriptor as usize)
.filter(|offset| {
*offset >= size_of::<SECURITY_DESCRIPTOR_RELATIVE>()
&& *offset % size_of::<u32>() == 0
&& offset
.checked_add(size_of::<ACL>())
.is_some_and(|end| end <= length)
})
.ok_or_else(|| io::Error::other("DACL header is outside its descriptor"))?;
// SAFETY: the aligned DACL header lies completely inside storage.
let acl_size = usize::from(unsafe { (*acl).AclSize });
if acl_size < size_of::<ACL>()
|| acl_offset
.checked_add(acl_size)
.is_none_or(|end| end > length)
{
return Err(io::Error::other("DACL is outside its descriptor"));
}
// SAFETY: the entire claimed ACL extent is bounded by owned storage.
// IsValidAcl checks revision and whether its ACEs fit that extent.
if unsafe { IsValidAcl(acl) } == 0 {
return Err(io::Error::other(
"kernel descriptor contains an invalid ACL",
));
}
for index in 0..u32::from(unsafe { (*acl).AceCount }) {
let mut entry = null_mut();
if unsafe { GetAce(acl, index, &mut entry) } == 0 {
return Err(io::Error::last_os_error());
}
let entry_offset = (entry as usize)
.checked_sub(acl as usize)
.filter(|offset| {
*offset >= size_of::<ACL>()
&& *offset % size_of::<u32>() == 0
&& offset
.checked_add(size_of::<ACE_HEADER>())
.is_some_and(|end| end <= acl_size)
})
.ok_or_else(|| io::Error::other("ACE header is outside its DACL"))?;
// SAFETY: the complete, aligned ACE header is inside the DACL.
let header = unsafe { &*entry.cast::<ACE_HEADER>() };
let entry_size = usize::from(header.AceSize);
if entry_size < size_of::<ACE_HEADER>() || entry_offset + entry_size > acl_size {
return Err(io::Error::other("ACE is outside its DACL"));
}
if u32::from(header.AceType) == ACCESS_ALLOWED_ACE_TYPE {
// IsValidAcl does not validate SIDs. Bound the whole SID before
// IsValidSid or either caller's EqualSid can inspect it.
let sid_offset = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart);
let sid_header = std::mem::offset_of!(SID, SubAuthority);
if entry_size < sid_offset + sid_header {
return Err(io::Error::other("allow ACE has a truncated SID header"));
}
// SAFETY: the complete SID header is inside this ACE.
let sid = unsafe { entry.cast::<u8>().add(sid_offset) };
let count = usize::from(unsafe { *sid.add(1) });
if sid_header + count * size_of::<u32>() > entry_size - sid_offset
|| unsafe { IsValidSid(sid.cast()) } == 0
{
return Err(io::Error::other(
"allow ACE has an invalid or truncated SID",
));
}
}
}
Ok(Self {
storage,
acl_offset,
control,
})
}
fn acl(&self) -> *mut ACL {
// SAFETY: from_storage validated the offset, alignment, and ACL extent;
// storage stays owned and unchanged while the returned pointer is used.
unsafe {
self.storage
.as_ptr()
.cast::<u8>()
.add(self.acl_offset)
.cast_mut()
.cast()
}
}
}
#[cfg(test)]
struct LocalAllocation(*mut core::ffi::c_void);
#[cfg(test)]
impl Drop for LocalAllocation {
fn drop(&mut self) {
unsafe {
@@ -1604,6 +1704,21 @@ fn wide(value: &OsStr) -> io::Result<Vec<u16>> {
Ok(value)
}
fn sandbox_args(args: &[String], runtime_dir: &Path) -> Vec<String> {
// An LPAC cannot open the NUL device. Both the host and its probe
// descendant read the empty config in the already-granted runtime copy.
let empty_config = format!("--config={}", runtime_dir.join(EMPTY_BUN_CONFIG).display());
args.iter()
.map(|arg| {
if arg == "--config=NUL" {
empty_config.clone()
} else {
arg.clone()
}
})
.collect()
}
fn command_line(program: &OsStr, args: &[String]) -> io::Result<Vec<u16>> {
// The documented CommandLineToArgvW/MS CRT quote+backslash rules. An
// explicit application path means PATH/first-token parsing is never authority.
+205 -22
View File
@@ -209,6 +209,36 @@ fn windows_native_profiles_are_distinct_and_acl_grant_refuses_junctions() {
assert_no_profile_grant(&outside_file, sandbox.profile.sid);
}
#[test]
fn windows_sandbox_args_use_granted_bun_config_without_changing_other_values() {
let runtime_dir = Path::new("runtime copy 鲸鱼");
let args = vec![
"--no-addons".into(),
"--config=NUL".into(),
"--input-type=module".into(),
"-e".into(),
"console.log('--config=NUL')".into(),
String::new(),
];
let mapped = sandbox_args(&args, runtime_dir);
let mut expected = args.clone();
expected[1] = format!("--config={}", runtime_dir.join(EMPTY_BUN_CONFIG).display());
assert_eq!(mapped, expected);
assert_eq!(sandbox_args(&mapped, runtime_dir), mapped);
}
#[test]
fn windows_sandbox_args_preserve_node_and_compiled_arguments() {
for args in [
vec!["--no-addons".into(), "--input-type=module".into()],
vec!["--tier=plugin".into(), "--windows-sandbox-probe".into()],
vec!["--config=NUL.toml".into(), "--config=custom.toml".into()],
Vec::new(),
] {
assert_eq!(sandbox_args(&args, Path::new("runtime copy")), args);
}
}
#[test]
fn windows_argv_and_environment_keep_exact_values_and_reject_nul() {
let args = vec![
@@ -248,13 +278,19 @@ fn assert_no_profile_grant(path: &Path, profile: PSID) {
);
}
fn acl_snapshot(path: &Path, exclude: Option<PSID>) -> Vec<Vec<u8>> {
use windows_sys::Win32::Security::{ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce};
acl_snapshot_view(path, exclude, false).1
}
fn acl_snapshot_view(path: &Path, exclude: Option<PSID>, raw: bool) -> (u16, Vec<Vec<u8>>) {
use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
use windows_sys::Win32::Security::{
ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce, GetSecurityDescriptorControl,
};
use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE;
let directory;
let file;
let handle = if path.is_dir() {
let opened = if path.is_dir() {
directory = WindowsDirectory::open(path).unwrap();
directory.acl_handle().unwrap().as_raw_handle()
directory.acl_handle().unwrap()
} else {
file = fs::OpenOptions::new()
.access_mode(READ_CONTROL | FILE_READ_ATTRIBUTES)
@@ -262,24 +298,37 @@ fn acl_snapshot(path: &Path, exclude: Option<PSID>) -> Vec<Vec<u8>> {
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
.unwrap();
file.as_raw_handle()
&file
};
let raw_descriptor = raw.then(|| RawDacl::read(opened).unwrap());
let mut _descriptor = None;
let mut control = 0_u16;
let mut dacl = null_mut();
let mut descriptor = null_mut();
let error = unsafe {
GetSecurityInfo(
handle,
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
&mut dacl,
null_mut(),
&mut descriptor,
)
};
assert_eq!(error, 0, "cannot inspect control ACL");
let _descriptor = LocalAllocation(descriptor);
if let Some(raw) = &raw_descriptor {
dacl = raw.acl();
control = raw.control;
} else {
let mut descriptor = null_mut();
let error = unsafe {
GetSecurityInfo(
opened.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
&mut dacl,
null_mut(),
&mut descriptor,
)
};
assert_eq!(error, 0, "cannot inspect control ACL");
_descriptor = Some(LocalAllocation(descriptor));
let mut revision = 0;
assert_ne!(
unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) },
0
);
}
let dacl = std::ptr::NonNull::new(dacl).expect("control must have an actual DACL");
let mut result = Vec::new();
for index in 0..unsafe { dacl.as_ref().AceCount as u32 } {
@@ -304,7 +353,118 @@ fn acl_snapshot(path: &Path, exclude: Option<PSID>) -> Vec<Vec<u8>> {
.to_vec(),
);
}
result
(control, result)
}
#[test]
fn windows_raw_dacl_rejects_truncated_unrestricted_and_malformed_descriptors() {
use windows_sys::Win32::Security::{
ACL_REVISION, InitializeAcl, SE_DACL_PRESENT, SE_SELF_RELATIVE,
SECURITY_DESCRIPTOR_RELATIVE,
};
fn fixture() -> Vec<u32> {
let length = size_of::<SECURITY_DESCRIPTOR_RELATIVE>() + size_of::<ACL>();
let mut storage = vec![0_u32; length.div_ceil(size_of::<u32>())];
let descriptor = storage.as_mut_ptr().cast::<SECURITY_DESCRIPTOR_RELATIVE>();
// SAFETY: the aligned allocation contains both complete structures.
unsafe {
(*descriptor).Revision = 1;
(*descriptor).Control = SE_SELF_RELATIVE | SE_DACL_PRESENT;
(*descriptor).Dacl = size_of::<SECURITY_DESCRIPTOR_RELATIVE>() as u32;
let acl = storage
.as_mut_ptr()
.cast::<u8>()
.add(size_of::<SECURITY_DESCRIPTOR_RELATIVE>())
.cast::<ACL>();
assert_ne!(InitializeAcl(acl, size_of::<ACL>() as u32, ACL_REVISION), 0);
}
storage
}
let valid = fixture();
let length = valid.len() * size_of::<u32>();
let descriptor = RawDacl::from_storage(valid.clone(), length).unwrap();
assert_eq!(descriptor.control, SE_SELF_RELATIVE | SE_DACL_PRESENT);
assert_eq!(
unsafe { (*descriptor.acl()).AceCount },
0,
"an empty restrictive ACL is valid"
);
assert!(RawDacl::from_storage(Vec::new(), 0).is_err());
assert!(RawDacl::from_storage(valid.clone(), length + 1).is_err());
assert!(
RawDacl::from_storage(valid.clone(), size_of::<SECURITY_DESCRIPTOR_RELATIVE>() - 1)
.is_err()
);
for (control, offset) in [
(
SE_DACL_PRESENT,
size_of::<SECURITY_DESCRIPTOR_RELATIVE>() as u32,
),
(SE_SELF_RELATIVE, 0),
(SE_SELF_RELATIVE | SE_DACL_PRESENT, 0),
(SE_SELF_RELATIVE | SE_DACL_PRESENT, 4),
(SE_SELF_RELATIVE | SE_DACL_PRESENT, 21),
(SE_SELF_RELATIVE | SE_DACL_PRESENT, length as u32),
] {
let mut storage = valid.clone();
let header = storage.as_mut_ptr().cast::<SECURITY_DESCRIPTOR_RELATIVE>();
unsafe {
(*header).Control = control;
(*header).Dacl = offset;
}
assert!(
RawDacl::from_storage(storage, length).is_err(),
"control={control:#x}, offset={offset}"
);
}
for (size, count) in [(0, 0), (u16::MAX, 0), (size_of::<ACL>() as u16, 1)] {
let mut storage = valid.clone();
let acl = unsafe {
storage
.as_mut_ptr()
.cast::<u8>()
.add(size_of::<SECURITY_DESCRIPTOR_RELATIVE>())
.cast::<ACL>()
};
unsafe {
(*acl).AclSize = size;
(*acl).AceCount = count;
}
assert!(
RawDacl::from_storage(storage, length).is_err(),
"size={size}, count={count}"
);
}
// A complete allow ACE with S-1-5-32 provides the positive control. A
// bounded ACL alone must not authorize EqualSid to read past that ACE.
let mut allowed = fixture();
let entry_offset = length;
allowed.resize(allowed.len() + 5, 0);
let allowed_length = allowed.len() * size_of::<u32>();
unsafe {
let bytes =
std::slice::from_raw_parts_mut(allowed.as_mut_ptr().cast::<u8>(), allowed_length);
bytes[size_of::<SECURITY_DESCRIPTOR_RELATIVE>() + 2..][..2]
.copy_from_slice(&((size_of::<ACL>() + 20) as u16).to_le_bytes());
bytes[size_of::<SECURITY_DESCRIPTOR_RELATIVE>() + 4] = 1; // AceCount
bytes[entry_offset + 2..][..2].copy_from_slice(&20_u16.to_le_bytes());
bytes[entry_offset + 4] = 1; // Mask
bytes[entry_offset + 8..][..12].copy_from_slice(&[1, 1, 0, 0, 0, 0, 0, 5, 32, 0, 0, 0]);
}
assert!(RawDacl::from_storage(allowed.clone(), allowed_length).is_ok());
for (offset, value) in [(2, 12), (8, 2), (9, 2)] {
let mut malformed = allowed.clone();
unsafe {
*malformed
.as_mut_ptr()
.cast::<u8>()
.add(entry_offset + offset) = value;
}
assert!(
RawDacl::from_storage(malformed, allowed_length).is_err(),
"malformed allow ACE at byte {offset}"
);
}
}
#[test]
@@ -315,6 +475,8 @@ fn windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_rest
fs::write(&original_file, b"retained user state").unwrap();
let original_root_acl = acl_snapshot(data.path(), None);
let original_file_acl = acl_snapshot(&original_file, None);
let original_raw_root_acl = acl_snapshot_view(data.path(), None, true);
let original_raw_file_acl = acl_snapshot_view(&original_file, None, true);
let make = || NativeSandbox {
profile: Arc::new(Profile::create().unwrap()),
_assets: Arc::new(tempfile::tempdir().unwrap()),
@@ -324,6 +486,19 @@ fn windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_rest
// A concurrent/new profile's exact grants must survive old-profile cleanup.
let other = make();
other.grant_tree(data.path(), true).unwrap();
// Check stored ACE bytes and all control bits before taking a later
// baseline. GetSecurityInfo can temporarily normalize the child's view
// while its parent carries a grant; no flag masking is allowed here.
assert_eq!(
acl_snapshot_view(data.path(), Some(other.profile.sid), true),
original_raw_root_acl,
"first profile grant changed pre-existing directory ACEs"
);
assert_eq!(
acl_snapshot_view(&original_file, Some(other.profile.sid), true),
original_raw_file_acl,
"first profile grant changed pre-existing file ACEs"
);
for turn in 0..8 {
let current = make();
current.grant_tree(data.path(), true).unwrap();
@@ -366,8 +541,16 @@ fn windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_rest
assert_eq!(fs::read(&original_file).unwrap(), b"retained user state");
}
drop(other);
assert_eq!(acl_snapshot(data.path(), None), original_root_acl);
assert_eq!(acl_snapshot(&original_file, None), original_file_acl);
assert_eq!(
acl_snapshot(data.path(), None),
original_root_acl,
"final profile retirement changed pre-existing directory ACEs"
);
assert_eq!(
acl_snapshot(&original_file, None),
original_file_acl,
"final profile retirement changed pre-existing file ACEs"
);
}
#[test]
@@ -1196,9 +1196,26 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
&scopes.admit(selected_workspace.clone()).await?,
&selected_scope
));
let retired_workspace = root.join("retired-workspace");
#[cfg(windows)]
{
// This fixture still owns a Fleet ledger whose ancestor pins deny
// deletion. Check that protection before releasing the test owner.
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("a live Fleet ledger must prevent workspace replacement");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
assert!(!retired_workspace.exists());
assert!(Arc::ptr_eq(
&scopes.admit(selected_workspace.clone()).await?,
&selected_scope
));
}
drop(selected_fleet);
// File identity, not a stable pathname, binds the cache. Replacing the
// selected directory cannot borrow its admitted LSP/MCP authority.
fs::rename(&selected_workspace, root.join("retired-workspace"))?;
// selected directory after the Fleet pins close cannot borrow its admitted
// LSP/MCP authority. Keep this check on Windows as well as Unix.
fs::rename(&selected_workspace, &retired_workspace)
.context("replace selected workspace after releasing the test Fleet manager")?;
fs::create_dir(&selected_workspace)?;
assert!(scopes.admit(selected_workspace.clone()).await.is_err());
assert_eq!(
+46 -1
View File
@@ -485,6 +485,28 @@ fn windows_atomic_target_name(path: &Path) -> std::io::Result<std::ffi::OsString
)
};
let name = path.file_name().ok_or_else(invalid)?;
// Path normalization alone can leave a reserved DOS basename unchanged.
// Reject them explicitly, including extensions and the documented
// superscript port digits, before the native rename can create one.
let stem = name
.to_string_lossy()
.split('.')
.next()
.unwrap_or_default()
.trim_end_matches(' ')
.to_ascii_uppercase();
let reserved_port = stem
.strip_prefix("COM")
.or_else(|| stem.strip_prefix("LPT"))
.is_some_and(|port| {
matches!(
port,
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
});
if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || reserved_port {
return Err(invalid());
}
let absolute = std::path::absolute(path)?;
if absolute.file_name() != Some(name)
|| absolute.as_os_str().to_string_lossy().starts_with(r"\\.\")
@@ -1290,12 +1312,35 @@ mod atomic_write_tests {
assert_eq!(fs::read(&created).expect("read created"), b"new");
write_atomic(&created, b"replaced").expect("replace beside pins");
assert_eq!(fs::read(&created).expect("read replaced"), b"replaced");
for name in ["trailing.", "trailing ", "CON"] {
for name in [
"trailing.",
"trailing ",
"CON",
"con.txt",
"PRN",
"AUX.log",
"nul.tar.gz",
"COM1",
"com9.cfg",
"LPT1",
"lpt9.log",
"COM¹",
"COM².log",
"COM³",
"LPT¹",
"LPT².log",
"LPT³",
] {
assert!(
write_atomic(&workspace.path().join(name), b"x").is_err(),
"{name}"
);
}
for name in ["console.json", "CON-file", "COM10.txt", "LPT10.txt"] {
let path = workspace.path().join(name);
write_atomic(&path, b"ordinary").expect("write ordinary name");
assert_eq!(fs::read(&path).expect("read ordinary name"), b"ordinary");
}
let strays: Vec<_> = fs::read_dir(workspace.path())
.expect("read_dir")
.filter_map(Result::ok)
@@ -0,0 +1 @@
fixture interpreter placeholder
@@ -0,0 +1,10 @@
{
"desktopVersion": "0.0.0-codewhale-fixture",
"platform": "win32",
"arch": "arm64",
"python": "3.12.8",
"pythonPackages": {
"numpy": "2.1.0",
"pandas": "2.2.3"
}
}