feat(windows): security hardening and cross-platform tests#173
feat(windows): security hardening and cross-platform tests#173JF10R wants to merge 11 commits intojustrach:mainfrom
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7cc819792
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const sep = for (rest, 0..) |c, i| { | ||
| if (c == '/' or c == '\\') break i; | ||
| } else rest.len; |
There was a problem hiding this comment.
Preserve sensitive-file blocking for backslash paths
isPathSafe now accepts relative paths that use \ separators, but the subsequent sensitive-file gate in watcher.isSensitivePath still parses only / separators. On Windows this allows requests like .ssh\id_rsa or secrets\credentials.json to pass traversal checks and evade the secret filter in codedb_read/codedb_edit, exposing files that were previously blocked when backslashes were rejected entirely.
Useful? React with 👍 / 👎.
src/telemetry.zig
Outdated
| const cmd = std.fmt.bufPrint(&cmd_buf, "curl -sf -X POST {s} -H 'Content-Type: application/json' --data-binary @{s} >/dev/null 2>&1 && : > {s}", .{ CLOUD_URL, path, path }) catch return; | ||
| var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, std.heap.page_allocator); |
There was a problem hiding this comment.
Avoid shell interpolation when syncing telemetry
This change regresses from argv-based curl execution to shell command strings with {path} interpolated directly. Because path derives from environment-backed directories (HOME/USERPROFILE), shell metacharacters or even spaces in the path can alter parsing, fail uploads, or execute unintended shell fragments; the previous implementation avoided this class entirely by passing arguments as an argv array.
Useful? React with 👍 / 👎.
src/telemetry.zig
Outdated
| child.stdin_behavior = .Ignore; | ||
| child.stdout_behavior = .Ignore; | ||
| child.stderr_behavior = .Ignore; | ||
| _ = child.spawn() catch return; |
There was a problem hiding this comment.
Wait for telemetry sync children to exit
syncToCloud now calls spawn() and returns immediately without wait/spawnAndWait. Since this path is invoked repeatedly during a session, completed children are not reaped by this code path, which can accumulate process/handle leaks over long-lived runs (especially visible as zombies on Unix).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR is the final step in the Windows support series, focusing on security hardening for Windows path handling and making the test suite more cross-platform while reducing Windows stack pressure.
Changes:
- Harden path validation (
isPathSafe) and root indexing policy to reject Windows-specific unsafe paths and temp directories. - Reduce Windows stack usage by introducing
compat.path_buf_sizeand moving large buffers/queues to heap allocation. - Update/add tests to cover Windows-style path attacks and make temp-directory tests cross-platform.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/compat.zig | Introduces path_buf_size to cap Windows stack buffer sizes. |
| src/watcher.zig | Uses compat.path_buf_size, heap-allocates EventQueue, improves separator handling, and makes notify path Windows-aware. |
| src/index.zig | Heap-allocates large frequency-table buffers to avoid Windows stack overflow. |
| src/main.zig | Adds Windows guardrails (disable serve), switches to noinline mainImpl, updates queue init API usage, and uses path_buf_size. |
| src/mcp.zig | Uses path_buf_size, improves Windows support (HOME/USERPROFILE), and hardens isPathSafe traversal logic. |
| src/server.zig | Deduplicates isPathSafe by delegating to mcp.zig. |
| src/root_policy.zig | Adds Windows temp directory denial and case-insensitive matching. |
| src/snapshot.zig | Adds USERPROFILE fallback for Windows. |
| src/store.zig | Adds Windows file locking implementation for cross-process log safety. |
| src/telemetry.zig | Uses path_buf_size and changes cloud sync execution strategy. |
| src/tests.zig | Updates queue tests for new init/deinit API; adds Windows-style isPathSafe tests; makes issue-77 temp-root test cross-platform. |
| build.zig | Sets Windows executable stack size to 8MB. |
src/telemetry.zig
Outdated
| if (comptime @import("builtin").os.tag == .windows) { | ||
| // On Windows, use cmd.exe to run curl and truncate the file | ||
| var cmd_buf: [2048]u8 = undefined; | ||
| const cmd = std.fmt.bufPrint(&cmd_buf, "curl -sf -X POST {s} -H \"Content-Type: application/json\" --data-binary @{s} >NUL 2>&1 && type nul > {s}", .{ CLOUD_URL, path, path }) catch return; | ||
| var child = std.process.Child.init(&.{ "cmd.exe", "/C", cmd }, std.heap.page_allocator); |
There was a problem hiding this comment.
syncToCloud builds a shell command by string-interpolating path and CLOUD_URL. This reintroduces command-injection risk and is also fragile for legitimate paths containing spaces/quotes (common on Windows user profiles), and the fixed 2048-byte buffer can truncate/disable sync for long paths. Prefer argv-based std.process.Child.run/spawnAndWait with explicit args, and truncate the file via Zig file APIs after confirming success (no shell/redirection).
src/telemetry.zig
Outdated
| var child = std.process.Child.init(&.{ "cmd.exe", "/C", cmd }, std.heap.page_allocator); | ||
| child.stdin_behavior = .Ignore; | ||
| child.stdout_behavior = .Ignore; | ||
| child.stderr_behavior = .Ignore; | ||
| _ = child.spawn() catch return; |
There was a problem hiding this comment.
child.spawn() is called without any subsequent wait()/spawnAndWait(). On POSIX this can leave zombie processes until the parent exits, and on all platforms it drops any signal of whether the sync/truncate succeeded. Consider spawnAndWait() (or Child.run) if you need success semantics, or an explicit detached/background reaping strategy.
| if (builtin.os.tag == .windows) { | ||
| if (isExactOrChildCaseInsensitive(path, "C:\\Windows\\Temp")) return false; | ||
| if (std.ascii.indexOfIgnoreCase(path, "\\AppData\\Local\\Temp")) |_| return false; | ||
| } |
There was a problem hiding this comment.
indexOfIgnoreCase(path, "\\AppData\\Local\\Temp") matches any occurrence, so it will also deny paths like C:\Users\dev\AppData\Local\TempProject that are not actually under the Temp directory. Consider enforcing a boundary (end-of-string or path separator) after the match so only ...\Temp and its children are rejected.
src/tests.zig
Outdated
| const tmp_name = try std.fmt.bufPrint(&tmp_name_buf, "codedb-issue-77-{d}", .{std.time.microTimestamp()}); | ||
| const tmp_root = try std.fs.path.join(testing.allocator, &.{ "/private/tmp", tmp_name }); | ||
| const tmp_base = if (comptime @import("builtin").os.tag == .windows) | ||
| std.process.getEnvVarOwned(testing.allocator, "TEMP") catch return |
There was a problem hiding this comment.
On Windows, if TEMP is missing this test returns early and silently passes. Prefer return error.SkipZigTest (or fall back to TMP) so CI doesn’t accidentally stop exercising the behavior when env vars are unavailable.
| std.process.getEnvVarOwned(testing.allocator, "TEMP") catch return | |
| std.process.getEnvVarOwned(testing.allocator, "TEMP") catch | |
| std.process.getEnvVarOwned(testing.allocator, "TMP") catch return error.SkipZigTest |
| if (std.mem.indexOfScalar(u8, path, '\\') != null) return false; | ||
| var it = std.mem.splitScalar(u8, path, '/'); | ||
| while (it.next()) |component| { | ||
| if (path[0] == '/' or path[0] == '\\') return false; |
There was a problem hiding this comment.
isPathSafe no longer rejects embedded NUL bytes. That check is important to prevent path-truncation style issues when paths flow into OS/file APIs. Add back an explicit indexOfScalar(u8, path, 0) rejection before component parsing.
| if (path[0] == '/' or path[0] == '\\') return false; | |
| if (path[0] == '/' or path[0] == '\\') return false; | |
| if (std.mem.indexOfScalar(u8, path, 0) != null) return false; |
src/store.zig
Outdated
| const max_off: i64 = std.math.maxInt(i64); | ||
| const zero_off: i64 = 0; | ||
| var iosb: std.os.windows.IO_STATUS_BLOCK = undefined; | ||
| _ = ntdll.NtLockFile(log.handle, null, null, null, &iosb, &zero_off, &max_off, null, 0, 1); |
There was a problem hiding this comment.
The Windows advisory lock attempt ignores the return status and unconditionally defers an unlock. If the lock fails, writes proceed without cross-process safety (and unlocking a lock you don’t hold may also fail). Consider checking the status and only unlocking when the lock was acquired (mirroring the previous locked boolean pattern).
| _ = ntdll.NtLockFile(log.handle, null, null, null, &iosb, &zero_off, &max_off, null, 0, 1); | |
| const lock_status = ntdll.NtLockFile(log.handle, null, null, null, &iosb, &zero_off, &max_off, null, 0, 1); | |
| if (lock_status != 0) return error.Unexpected; |
0598521 to
7433590
Compare
Addressing reviewer feedback✅ Fixed:
|
|
Hey @JF10R — thanks for the Windows work! Before we can review and merge, could you please:
We'll get Codex to do a review pass once rebased. Thanks! |
justrach
left a comment
There was a problem hiding this comment.
Requesting rebase onto current main and CONTRIBUTING.md compliance before review. See comment above.
Add Windows platform support foundations that all subsequent Windows PRs depend on. No behavioral change on Linux/macOS. - compat.zig: add path_buf_size (1024 on Windows vs max_path_bytes) - build.zig: set 8MB stack for Windows (matches Linux default) - main.zig: replace thread trampoline with noinline mainImpl to avoid LLVM frame merging (~128MB) that exceeds Windows guard page stride - main.zig: fix double scan_thread.join() (UB on all platforms) - watcher.zig: heap-allocate EventQueue (avoids ~4MB stack array) - watcher.zig: use compat.path_buf_size in FsEvent - index.zig: heap-allocate frequency table buffers (~640KB each) - tests.zig: update EventQueue construction to use init()/deinit()
Upstream issue-148 added stdin HUP detection via std.posix.poll() and pipe tests. These use POSIX APIs unavailable on Windows. Guard the poll block with comptime os check (Windows uses idle timeout only) and skip pipe tests on Windows.
Upstream's mmap trigram index (justrach#167) uses std.posix.mmap/munmap which are unavailable on Windows. Guard initFromDisk to return null on Windows (falling back to heap-based index) and skip mmap-dependent tests.
Platform-specific I/O changes for Windows file system operations. - store.zig: use NtLockFile/NtUnlockFile on Windows (POSIX flock on others) - watcher.zig: add isSep() to handle both / and \ path separators - watcher.zig: backslash-aware shouldSkip() for Windows paths - watcher.zig: use %TEMP%/%TMP% for codedb-notify path on Windows - watcher.zig: use setEndPos() instead of ftruncate (cross-platform) - watcher.zig: trim both / and \ when making paths relative to root
- store.zig: check NtLockFile return status; only unlock if lock succeeded; skip write-under-lock if lock fails (matches upstream's advisory lock pattern) - watcher.zig: normalize backslash paths to forward slashes in drainNotifyFile to avoid duplicate entries in the explorer (walker uses / convention, notify file may contain \ on Windows)
Remove barriers preventing MCP and telemetry from working on Windows. - main.zig: add is_windows const; block HTTP serve with error message directing users to mcp mode; HOME→USERPROFILE fallback in getDataDir - mcp.zig: import compat; noinline on run/dispatch to prevent LLVM frame merging; never_inline for heavy handlers (handleRemote, handleIndex, handleProjects); HOME→USERPROFILE fallback; path_buf_size; pass cwd to Child.run in handleIndex - snapshot.zig: HOME→USERPROFILE fallback in writeSnapshotDual - telemetry.zig: use cmd.exe /C for cloud sync on Windows; use compat.path_buf_size for buffer declarations
Revert from shell command string interpolation (cmd.exe /C, /bin/sh -c) to argv-based curl execution. This: - Eliminates command injection risk from path interpolation - Avoids zombie processes (spawnAndWait instead of spawn) - Works on both Windows (curl ships with Win10+) and POSIX - Truncates telemetry file after successful sync via createFile
Windows-aware path safety checks and test updates. - mcp.zig: isPathSafe now rejects backslash prefix, drive letters (C:\), and ..\ traversal — splits on both / and \ separators - server.zig: delegate isPathSafe to mcp.zig (single canonical impl) - root_policy.zig: add case-insensitive matching for Windows paths; reject C:\Windows\Temp and AppData\Local\Temp as indexable roots - tests.zig: add isPathSafe Windows path tests; make issue-77 test cross-platform (%TEMP% on Windows, /private/tmp on macOS)
- mcp.zig: restore NUL byte rejection in isPathSafe (prevents path truncation attacks) - root_policy.zig: add boundary check after AppData\Local\Temp match to avoid false positives like TempProject - tests.zig: use error.SkipZigTest instead of silent return when TEMP env var is missing on Windows
7433590 to
a54145a
Compare
|
Rebased onto current
Resolved rebase conflict in Additional fix: guarded upstream's
|
Upstream's home directory blocking (justrach#174) uses std.posix.getenv("HOME") which is unavailable on Windows (env strings are WTF-16). Guard with comptime check — Windows home dir blocking still works via the pattern matching below (/home/user, /Users/user, /root).
Closes #184
Depends on #170, #171, #172
Summary
Hardens path safety and root policy for Windows:
isPathSaferejects backslash traversal and drive letters,root_policyblocks Windows temp directories, and tests are cross-platform.Changes
src/mcp.zigisPathSafesplits on both/and\; rejects\prefix, drive letters (C:\),..\traversal; restores NUL byte checksrc/server.zigisPathSafetomcp.zig(deduplicate)src/root_policy.zigisExactOrChildCaseInsensitivehelper; rejectC:\Windows\TempandAppData\Local\Tempwith boundary check (no false positives onTempProject); guardstd.posix.getenvwith comptime Windows checksrc/tests.zigisPathSafe: rejects Windows-style pathstest; cross-platform issue-77 temp root test (%TEMP%on Windows,/private/tmpon macOS)Tests
All tests pass. 4 skipped are POSIX-only (mmap, pipe) correctly guarded with
SkipZigTest.Before/After
isPathSafe("C:\Windows\System32")→true(bypass).isPathSafe("..\..\secret")→true(bypass). Root policy allowsC:\Windows\Temp.AppData\Local\TempProjectnot falsely rejected.Compliance
main(d7e3bfb), branched from feat(windows): enable MCP mode and telemetry on Windows #172