Skip to content

Commit b1bba5e

Browse files
committed
demo: windows-smoke catches garrytan#1121 with test only (src file missing)
1 parent e23ff28 commit b1bba5e

2 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Windows Smoke CI — Phase 1 of the phased rollout in docs/designs/WINDOWS_CI.md
2+
#
3+
# Answers one question per run: "does the code path through a Windows-critical
4+
# module actually run on Windows." That's deliberately a lower bar than "does
5+
# every test pass" — it catches the class of bugs where Linux/macOS CI runs
6+
# green but a Windows user immediately hits ENOENT / "browse binary not found"
7+
# / silent mislocations of ~/.gstack/ state.
8+
#
9+
# Coverage catch list (see RFC for full reasoning):
10+
# - Build fails to produce .exe on Windows (catches #1013 / #1024)
11+
# - Binary-resolution probes wrong filename (catches #1118 / #1094)
12+
# - Shebang bash script spawn fails (catches #1119)
13+
# - Sensitive files written without ACL restriction (catches #1121)
14+
# - { mode: 0o600 } silently ignored on Windows (catches Pre-#1121 state)
15+
#
16+
# Miss: #1120-style home-directory fallback — no direct unit test. RFC
17+
# proposes adding one as a follow-on.
18+
name: windows-smoke
19+
on:
20+
pull_request:
21+
branches: [main]
22+
paths:
23+
- 'browse/**'
24+
- 'make-pdf/**'
25+
- 'design/**'
26+
- 'scripts/**'
27+
- 'bin/**'
28+
- 'package.json'
29+
- 'bun.lockb'
30+
- '.github/workflows/windows-smoke.yml'
31+
push:
32+
branches: [main]
33+
paths:
34+
- 'browse/**'
35+
- 'make-pdf/**'
36+
- 'design/**'
37+
- 'scripts/**'
38+
- 'bin/**'
39+
- 'package.json'
40+
- 'bun.lockb'
41+
workflow_dispatch:
42+
43+
concurrency:
44+
group: windows-smoke-${{ github.head_ref || github.ref }}
45+
cancel-in-progress: true
46+
47+
jobs:
48+
smoke:
49+
runs-on: windows-latest
50+
timeout-minutes: 10
51+
steps:
52+
- uses: actions/checkout@v4
53+
54+
- uses: oven-sh/setup-bun@v2
55+
with:
56+
bun-version: latest
57+
58+
- name: Install dependencies
59+
run: bun install --frozen-lockfile
60+
61+
- name: Build binaries
62+
run: bun run build
63+
64+
- name: Assert Windows binary layout
65+
shell: pwsh
66+
run: |
67+
$missing = @()
68+
foreach ($p in @(
69+
'browse/dist/browse.exe',
70+
'browse/dist/find-browse.exe',
71+
'browse/dist/server-node.mjs',
72+
'make-pdf/dist/pdf.exe',
73+
'design/dist/design.exe'
74+
)) { if (-not (Test-Path $p)) { $missing += $p } }
75+
if ($missing.Count -gt 0) {
76+
Write-Error "Missing build artifacts: $($missing -join ', ')"
77+
exit 1
78+
}
79+
80+
- name: Smoke-test binaries
81+
run: |
82+
./browse/dist/browse.exe --version
83+
./make-pdf/dist/pdf.exe --version
84+
./design/dist/design.exe --version
85+
86+
- name: Windows-specific unit tests
87+
run: |
88+
bun test browse/test/security.test.ts
89+
bun test browse/test/file-permissions.test.ts
90+
bun test make-pdf/test/browseClient.test.ts
91+
bun test make-pdf/test/pdftotext.test.ts
92+
93+
- name: make-pdf render smoke
94+
run: bun test make-pdf/test/render.test.ts
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Unit tests for browse/src/file-permissions.ts
3+
*
4+
* Strategy:
5+
* - POSIX assertions check fs.statSync.mode bits directly (cheap, reliable,
6+
* runs on every CI config).
7+
* - Windows assertions don't check ACLs (that'd require parsing icacls
8+
* output, which is brittle across Windows versions / locales). Instead
9+
* we verify the helper doesn't throw and the file ends up accessible
10+
* to the current user — the "doesn't crash, file still usable"
11+
* contract the callers rely on.
12+
*/
13+
14+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15+
import * as fs from 'fs';
16+
import * as os from 'os';
17+
import * as path from 'path';
18+
19+
import {
20+
restrictFilePermissions,
21+
restrictDirectoryPermissions,
22+
writeSecureFile,
23+
appendSecureFile,
24+
mkdirSecure,
25+
__resetWarnedForTests,
26+
} from '../src/file-permissions';
27+
28+
let tmpDir: string;
29+
30+
beforeEach(() => {
31+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-perms-'));
32+
__resetWarnedForTests();
33+
});
34+
35+
afterEach(() => {
36+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
37+
});
38+
39+
describe('restrictFilePermissions', () => {
40+
test('on POSIX, sets file mode to 0o600', () => {
41+
if (process.platform === 'win32') return;
42+
const p = path.join(tmpDir, 'secret');
43+
fs.writeFileSync(p, 'token');
44+
fs.chmodSync(p, 0o644); // start world-readable to prove the call mutates it
45+
restrictFilePermissions(p);
46+
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
47+
});
48+
49+
test('on Windows, does not throw on an existing file', () => {
50+
if (process.platform !== 'win32') return;
51+
const p = path.join(tmpDir, 'secret');
52+
fs.writeFileSync(p, 'token');
53+
expect(() => restrictFilePermissions(p)).not.toThrow();
54+
// File remains readable by the caller — core contract.
55+
expect(fs.readFileSync(p, 'utf8')).toBe('token');
56+
});
57+
58+
test('on Windows, does not throw when icacls fails (bad path)', () => {
59+
if (process.platform !== 'win32') return;
60+
// icacls emits an error for a nonexistent path; helper must swallow.
61+
expect(() => restrictFilePermissions(path.join(tmpDir, 'nonexistent'))).not.toThrow();
62+
});
63+
});
64+
65+
describe('restrictDirectoryPermissions', () => {
66+
test('on POSIX, sets directory mode to 0o700', () => {
67+
if (process.platform === 'win32') return;
68+
const d = path.join(tmpDir, 'subdir');
69+
fs.mkdirSync(d, { mode: 0o755 });
70+
restrictDirectoryPermissions(d);
71+
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
72+
});
73+
74+
test('on Windows, does not throw on an existing directory', () => {
75+
if (process.platform !== 'win32') return;
76+
const d = path.join(tmpDir, 'subdir');
77+
fs.mkdirSync(d);
78+
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
79+
});
80+
});
81+
82+
describe('writeSecureFile', () => {
83+
test('writes the payload and restricts permissions atomically', () => {
84+
const p = path.join(tmpDir, 'data');
85+
writeSecureFile(p, 'hello');
86+
expect(fs.readFileSync(p, 'utf8')).toBe('hello');
87+
if (process.platform !== 'win32') {
88+
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
89+
}
90+
});
91+
92+
test('accepts Buffer payloads', () => {
93+
const p = path.join(tmpDir, 'buffer');
94+
writeSecureFile(p, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
95+
const out = fs.readFileSync(p);
96+
expect(out.length).toBe(4);
97+
expect(out[0]).toBe(0xde);
98+
});
99+
100+
test('overwrites existing file', () => {
101+
const p = path.join(tmpDir, 'existing');
102+
fs.writeFileSync(p, 'old', { mode: 0o644 });
103+
writeSecureFile(p, 'new');
104+
expect(fs.readFileSync(p, 'utf8')).toBe('new');
105+
});
106+
});
107+
108+
describe('appendSecureFile', () => {
109+
test('appends to a new file and sets owner-only permissions', () => {
110+
const p = path.join(tmpDir, 'log');
111+
appendSecureFile(p, 'line1\n');
112+
expect(fs.readFileSync(p, 'utf8')).toBe('line1\n');
113+
if (process.platform !== 'win32') {
114+
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
115+
}
116+
});
117+
118+
test('appends without re-applying ACL on subsequent writes', () => {
119+
const p = path.join(tmpDir, 'log');
120+
appendSecureFile(p, 'line1\n');
121+
appendSecureFile(p, 'line2\n');
122+
expect(fs.readFileSync(p, 'utf8')).toBe('line1\nline2\n');
123+
});
124+
});
125+
126+
describe('mkdirSecure', () => {
127+
test('creates directory with owner-only mode (POSIX)', () => {
128+
if (process.platform === 'win32') return;
129+
const d = path.join(tmpDir, 'nested', 'deep');
130+
mkdirSecure(d);
131+
expect(fs.statSync(d).isDirectory()).toBe(true);
132+
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
133+
});
134+
135+
test('is idempotent — safe to call on existing directory', () => {
136+
const d = path.join(tmpDir, 'dir');
137+
mkdirSecure(d);
138+
expect(() => mkdirSecure(d)).not.toThrow();
139+
});
140+
141+
test('recursive behavior: creates intermediate directories', () => {
142+
const d = path.join(tmpDir, 'a', 'b', 'c');
143+
mkdirSecure(d);
144+
expect(fs.existsSync(path.join(tmpDir, 'a'))).toBe(true);
145+
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
146+
expect(fs.existsSync(d)).toBe(true);
147+
});
148+
});

0 commit comments

Comments
 (0)