diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f5a3121db9..9135945bfd 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -16,6 +16,7 @@ */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; +import { writeSecureFile, mkdirSecure } from './file-permissions'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; @@ -267,10 +268,10 @@ export class BrowserManager { const fs = require('fs'); const path = require('path'); const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); - fs.mkdirSync(gstackDir, { recursive: true }); + mkdirSecure(gstackDir); const authFile = path.join(gstackDir, '.auth.json'); try { - fs.writeFileSync(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }), { mode: 0o600 }); + writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 })); } catch (err: any) { console.warn(`[browse] Could not write .auth.json: ${err.message}`); } diff --git a/browse/src/browser-skill-write.ts b/browse/src/browser-skill-write.ts index 55ffd9e2c4..81599b419b 100644 --- a/browse/src/browser-skill-write.ts +++ b/browse/src/browser-skill-write.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { mkdirSecure } from './file-permissions'; import { isPathWithin } from './platform'; import type { TierPaths } from './browser-skills'; import { defaultTierPaths } from './browser-skills'; @@ -74,8 +75,8 @@ export function stageSkill(opts: StageSkillOptions): string { const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`); const stagedDir = path.join(wrapperDir, opts.name); - fs.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 }); - fs.mkdirSync(stagedDir, { recursive: true, mode: 0o700 }); + mkdirSecure(wrapperDir); + mkdirSecure(stagedDir); for (const [relPath, contents] of opts.files) { if (relPath.startsWith('/') || relPath.includes('..')) { diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 9c4881a259..86d56cf892 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling'; +import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; const config = resolveConfig(); @@ -729,7 +730,7 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise:(F)` to remove + * any inherited ACLs and replace the ACL with a single entry granting the + * current user full control. + */ +export function restrictFilePermissions(filePath: string): void { + if (process.platform === 'win32') { + try { + const user = os.userInfo().username; + execFileSync( + 'icacls', + [filePath, '/inheritance:r', '/grant:r', `${user}:(F)`], + { stdio: 'ignore' }, + ); + } catch (err) { + warnIcaclsFailure(filePath, err); + } + return; + } + try { fs.chmodSync(filePath, 0o600); } catch { /* best-effort */ } +} + +/** + * Restrict a directory to owner-only access (POSIX 0o700 equivalent), + * with new children inheriting the restricted ACL. + * + * POSIX: `fs.chmodSync(path, 0o700)`. Idempotent if the dir was already + * created with `{ mode: 0o700 }`. + * + * Windows: `icacls /inheritance:r /grant:r :(OI)(CI)(F)`. The + * `(OI)(CI)` flags make new files (OI = object inherit) and subdirs + * (CI = container inherit) inherit the single-user-full ACL — important + * because child creations in `fs.writeFileSync(...)` without explicit + * `restrictFilePermissions` still end up owner-only. + */ +export function restrictDirectoryPermissions(dirPath: string): void { + if (process.platform === 'win32') { + try { + const user = os.userInfo().username; + execFileSync( + 'icacls', + [dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`], + { stdio: 'ignore' }, + ); + } catch (err) { + warnIcaclsFailure(dirPath, err); + } + return; + } + try { fs.chmodSync(dirPath, 0o700); } catch { /* best-effort */ } +} + +/** + * Write a file and restrict it to owner-only access, cross-platform. + * Replaces `fs.writeFileSync(path, data, { mode: 0o600 })` + Windows ACL. + */ +export function writeSecureFile( + filePath: string, + data: string | NodeJS.ArrayBufferView, +): void { + fs.writeFileSync(filePath, data, { mode: 0o600 }); + restrictFilePermissions(filePath); +} + +/** + * Append to a file with owner-only permissions, cross-platform. + * Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL. + * + * ACL is applied only on first write — subsequent appends are fire-and-forget + * (no need to re-run icacls on every log line). + */ +export function appendSecureFile( + filePath: string, + data: string | NodeJS.ArrayBufferView, +): void { + const existed = fs.existsSync(filePath); + fs.appendFileSync(filePath, data, { mode: 0o600 }); + if (!existed) restrictFilePermissions(filePath); +} + +/** + * `mkdir -p` with owner-only directory permissions, cross-platform. + * Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL. + * Safe to call on an existing directory — re-applies the ACL idempotently. + */ +export function mkdirSecure(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + restrictDirectoryPermissions(dirPath); +} + +/** + * Reset the once-per-process warning gate. Test-only. + */ +export function __resetWarnedForTests(): void { + warnedOnce = false; +} diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 543185bf2e..c505d4cf41 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -16,6 +16,7 @@ export { validateOutputPath, escapeRegExp } from './path-security'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; +import { writeSecureFile, mkdirSecure } from './file-permissions'; import { TEMP_DIR } from './platform'; import { resolveConfig } from './config'; import type { Frame } from 'playwright'; @@ -917,7 +918,7 @@ export async function handleMetaCommand( const config = resolveConfig(); const stateDir = path.join(config.stateDir, 'browse-states'); - fs.mkdirSync(stateDir, { recursive: true }); + mkdirSecure(stateDir); const statePath = path.join(stateDir, `${name}.json`); if (action === 'save') { @@ -929,7 +930,7 @@ export async function handleMetaCommand( cookies: state.cookies, pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })), }; - fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 }); + writeSecureFile(statePath, JSON.stringify(saveData, null, 2)); return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`; } diff --git a/browse/src/security-classifier.ts b/browse/src/security-classifier.ts index d631df506e..5cd852bb29 100644 --- a/browse/src/security-classifier.ts +++ b/browse/src/security-classifier.ts @@ -29,6 +29,7 @@ import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { mkdirSecure } from './file-permissions'; import { THRESHOLDS, type LayerSignal } from './security'; import { resolveClaudeCommand } from './claude-bin'; @@ -156,7 +157,7 @@ async function downloadFile(url: string, dest: string): Promise { } async function ensureTestsavantStaged(onProgress?: (msg: string) => void): Promise { - fs.mkdirSync(path.join(TESTSAVANT_DIR, 'onnx'), { recursive: true, mode: 0o700 }); + mkdirSecure(path.join(TESTSAVANT_DIR, 'onnx')); // Small config/tokenizer files for (const f of TESTSAVANT_FILES) { @@ -301,7 +302,7 @@ export async function scanPageContent(text: string): Promise { // ─── L4c: DeBERTa-v3 ensemble (opt-in) ─────────────────────── async function ensureDebertaStaged(onProgress?: (msg: string) => void): Promise { - fs.mkdirSync(path.join(DEBERTA_DIR, 'onnx'), { recursive: true, mode: 0o700 }); + mkdirSecure(path.join(DEBERTA_DIR, 'onnx')); for (const f of DEBERTA_FILES) { const dst = path.join(DEBERTA_DIR, f); if (fs.existsSync(dst)) continue; diff --git a/browse/src/security.ts b/browse/src/security.ts index 22009e0c36..e3dc217291 100644 --- a/browse/src/security.ts +++ b/browse/src/security.ts @@ -24,6 +24,7 @@ import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { writeSecureFile, appendSecureFile, mkdirSecure } from './file-permissions'; // ─── Thresholds + verdict types ────────────────────────────── @@ -344,11 +345,11 @@ function getDeviceSalt(): string { // fall through to generate } try { - fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 }); + mkdirSecure(SECURITY_DIR); } catch {} cachedSalt = randomBytes(16).toString('hex'); try { - fs.writeFileSync(SALT_FILE, cachedSalt, { mode: 0o600 }); + writeSecureFile(SALT_FILE, cachedSalt); } catch { // Can't persist (read-only fs, disk full). Keep the in-memory salt // for this process so cross-log correlation still works within a @@ -456,10 +457,10 @@ export function logAttempt(record: AttemptRecord): boolean { // the event reported (it goes to a different directory anyway). reportAttemptTelemetry(record); try { - fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 }); + mkdirSecure(SECURITY_DIR); rotateIfNeeded(); const line = JSON.stringify(record) + '\n'; - fs.appendFileSync(ATTEMPTS_LOG, line, { mode: 0o600 }); + appendSecureFile(ATTEMPTS_LOG, line); return true; } catch (err) { // Non-fatal. Log to stderr for debugging but don't block. @@ -489,9 +490,9 @@ export interface SessionState { */ export function writeSessionState(state: SessionState): void { try { - fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 }); + mkdirSecure(SECURITY_DIR); const tmp = `${STATE_FILE}.tmp.${process.pid}`; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); + writeSecureFile(tmp, JSON.stringify(state, null, 2)); fs.renameSync(tmp, STATE_FILE); } catch (err) { console.error('[security] writeSessionState failed:', (err as Error).message); @@ -532,10 +533,10 @@ export interface DecisionRecord { export function writeDecision(record: DecisionRecord): void { try { - fs.mkdirSync(DECISIONS_DIR, { recursive: true, mode: 0o700 }); + mkdirSecure(DECISIONS_DIR); const file = decisionFileForTab(record.tabId); const tmp = `${file}.tmp.${process.pid}`; - fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 }); + writeSecureFile(tmp, JSON.stringify(record)); fs.renameSync(tmp, file); } catch (err) { console.error('[security] writeDecision failed:', (err as Error).message); diff --git a/browse/src/server.ts b/browse/src/server.ts index 042616e75b..ffa9debd29 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -26,6 +26,7 @@ import { markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers, } from './content-security'; import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security'; +import { writeSecureFile, mkdirSecure } from './file-permissions'; import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; import { initRegistry, validateToken as validateScopedToken, checkScope, checkDomain, @@ -1477,7 +1478,7 @@ async function start() { const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8')); stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() }; const tmpState = config.stateFile + '.tmp'; - fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 }); + writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2)); fs.renameSync(tmpState, config.stateFile); return new Response(JSON.stringify({ url: tunnelUrl }), { @@ -2000,7 +2001,7 @@ async function start() { mode: browserManager.getConnectionMode(), }; const tmpFile = config.stateFile + '.tmp'; - fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); + writeSecureFile(tmpFile, JSON.stringify(state, null, 2)); fs.renameSync(tmpFile, config.stateFile); browserManager.serverPort = port; @@ -2081,7 +2082,7 @@ async function start() { const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8')); stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() }; const tmpState = config.stateFile + '.tmp'; - fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 }); + writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2)); fs.renameSync(tmpState, config.stateFile); } catch (err: any) { console.error(`[browse] Failed to start tunnel: ${err.message}`); @@ -2111,7 +2112,7 @@ async function start() { const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8')); stateContent.tunnelLocalPort = tunnelPort; const tmpState = config.stateFile + '.tmp'; - fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 }); + writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2)); fs.renameSync(tmpState, config.stateFile); } catch (err: any) { console.error(`[browse] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`); @@ -2125,8 +2126,8 @@ start().catch((err) => { // stderr because the server is launched with detached: true, stdio: 'ignore'. try { const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log'); - fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`, { mode: 0o600 }); + mkdirSecure(config.stateDir); + writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`); } catch { // stateDir may not exist — nothing more we can do } diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 9ebc8cbbf2..064ed91ea9 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -23,6 +23,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; +import { writeSecureFile, mkdirSecure } from './file-permissions'; import { safeUnlink } from './error-handling'; const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); @@ -83,7 +84,7 @@ function findClaude(): string | null { /** Probe + persist claude availability for the bootstrap card. */ function writeClaudeAvailable(): void { const stateDir = path.dirname(STATE_FILE); - try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {} + try { mkdirSecure(stateDir); } catch {} const found = findClaude(); const status = { available: !!found, @@ -94,7 +95,7 @@ function writeClaudeAvailable(): void { const target = path.join(stateDir, 'claude-available.json'); const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`); try { - fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 }); + writeSecureFile(tmp, JSON.stringify(status, null, 2)); fs.renameSync(tmp, target); } catch { safeUnlink(tmp); @@ -422,7 +423,7 @@ function handleTabState(msg: { reason?: string; }): void { const stateDir = path.dirname(STATE_FILE); - try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {} + try { mkdirSecure(stateDir); } catch {} // tabs.json — full list if (Array.isArray(msg.tabs)) { @@ -442,7 +443,7 @@ function handleTabState(msg: { const target = path.join(stateDir, 'tabs.json'); const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`); try { - fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 }); + writeSecureFile(tmp, JSON.stringify(payload, null, 2)); fs.renameSync(tmp, target); } catch { safeUnlink(tmp); @@ -457,11 +458,11 @@ function handleTabState(msg: { const ctxFile = path.join(stateDir, 'active-tab.json'); const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`); try { - fs.writeFileSync(tmp, JSON.stringify({ + writeSecureFile(tmp, JSON.stringify({ tabId: active.tabId ?? null, url: active.url, title: active.title ?? '', - }), { mode: 0o600 }); + })); fs.renameSync(tmp, ctxFile); } catch { safeUnlink(tmp); @@ -477,11 +478,11 @@ function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): const ctxFile = path.join(stateDir, 'active-tab.json'); const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`); try { - fs.writeFileSync(tmp, JSON.stringify({ + writeSecureFile(tmp, JSON.stringify({ tabId: msg.tabId ?? null, url, title: msg.title ?? '', - }), { mode: 0o600 }); + })); fs.renameSync(tmp, ctxFile); } catch { safeUnlink(tmp); @@ -524,9 +525,9 @@ function main() { // Write port file atomically so the parent server can pick it up. const dir = path.dirname(PORT_FILE); - try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {} + try { mkdirSecure(dir); } catch {} const tmp = `${PORT_FILE}.tmp-${process.pid}`; - fs.writeFileSync(tmp, String(port), { mode: 0o600 }); + writeSecureFile(tmp, String(port)); fs.renameSync(tmp, PORT_FILE); // Hand the parent the internal token so it can call /internal/grant. @@ -549,8 +550,8 @@ function main() { // to a state file the parent reads. This avoids env-passing races. See main(). const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token'); try { - fs.mkdirSync(path.dirname(INTERNAL_TOKEN_FILE), { recursive: true, mode: 0o700 }); - fs.writeFileSync(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN, { mode: 0o600 }); + mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE)); + writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN); } catch {} main(); diff --git a/browse/src/tunnel-denial-log.ts b/browse/src/tunnel-denial-log.ts index 2676594078..82b9c34a5e 100644 --- a/browse/src/tunnel-denial-log.ts +++ b/browse/src/tunnel-denial-log.ts @@ -18,6 +18,7 @@ import { promises as fsp } from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { mkdirSecure } from './file-permissions'; const LOG_DIR = path.join(os.homedir(), '.gstack', 'security'); const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl'); @@ -31,7 +32,10 @@ let dirEnsured = false; async function ensureDir(): Promise { if (dirEnsured) return; try { - await fsp.mkdir(LOG_DIR, { recursive: true, mode: 0o700 }); + // Sync mkdir is fine here — runs once per process at first denial. The + // (OI)(CI) inheritance set on Windows means subsequent fsp.appendFile + // writes pick up the owner-only ACL automatically. + mkdirSecure(LOG_DIR); dirEnsured = true; } catch { // Swallow — log writes are best-effort. Failure to mkdir just means diff --git a/browse/test/file-permissions.test.ts b/browse/test/file-permissions.test.ts new file mode 100644 index 0000000000..e073b9945c --- /dev/null +++ b/browse/test/file-permissions.test.ts @@ -0,0 +1,148 @@ +/** + * Unit tests for browse/src/file-permissions.ts + * + * Strategy: + * - POSIX assertions check fs.statSync.mode bits directly (cheap, reliable, + * runs on every CI config). + * - Windows assertions don't check ACLs (that'd require parsing icacls + * output, which is brittle across Windows versions / locales). Instead + * we verify the helper doesn't throw and the file ends up accessible + * to the current user — the "doesn't crash, file still usable" + * contract the callers rely on. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + restrictFilePermissions, + restrictDirectoryPermissions, + writeSecureFile, + appendSecureFile, + mkdirSecure, + __resetWarnedForTests, +} from '../src/file-permissions'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-perms-')); + __resetWarnedForTests(); +}); + +afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } +}); + +describe('restrictFilePermissions', () => { + test('on POSIX, sets file mode to 0o600', () => { + if (process.platform === 'win32') return; + const p = path.join(tmpDir, 'secret'); + fs.writeFileSync(p, 'token'); + fs.chmodSync(p, 0o644); // start world-readable to prove the call mutates it + restrictFilePermissions(p); + expect(fs.statSync(p).mode & 0o777).toBe(0o600); + }); + + test('on Windows, does not throw on an existing file', () => { + if (process.platform !== 'win32') return; + const p = path.join(tmpDir, 'secret'); + fs.writeFileSync(p, 'token'); + expect(() => restrictFilePermissions(p)).not.toThrow(); + // File remains readable by the caller — core contract. + expect(fs.readFileSync(p, 'utf8')).toBe('token'); + }); + + test('on Windows, does not throw when icacls fails (bad path)', () => { + if (process.platform !== 'win32') return; + // icacls emits an error for a nonexistent path; helper must swallow. + expect(() => restrictFilePermissions(path.join(tmpDir, 'nonexistent'))).not.toThrow(); + }); +}); + +describe('restrictDirectoryPermissions', () => { + test('on POSIX, sets directory mode to 0o700', () => { + if (process.platform === 'win32') return; + const d = path.join(tmpDir, 'subdir'); + fs.mkdirSync(d, { mode: 0o755 }); + restrictDirectoryPermissions(d); + expect(fs.statSync(d).mode & 0o777).toBe(0o700); + }); + + test('on Windows, does not throw on an existing directory', () => { + if (process.platform !== 'win32') return; + const d = path.join(tmpDir, 'subdir'); + fs.mkdirSync(d); + expect(() => restrictDirectoryPermissions(d)).not.toThrow(); + }); +}); + +describe('writeSecureFile', () => { + test('writes the payload and restricts permissions atomically', () => { + const p = path.join(tmpDir, 'data'); + writeSecureFile(p, 'hello'); + expect(fs.readFileSync(p, 'utf8')).toBe('hello'); + if (process.platform !== 'win32') { + expect(fs.statSync(p).mode & 0o777).toBe(0o600); + } + }); + + test('accepts Buffer payloads', () => { + const p = path.join(tmpDir, 'buffer'); + writeSecureFile(p, Buffer.from([0xde, 0xad, 0xbe, 0xef])); + const out = fs.readFileSync(p); + expect(out.length).toBe(4); + expect(out[0]).toBe(0xde); + }); + + test('overwrites existing file', () => { + const p = path.join(tmpDir, 'existing'); + fs.writeFileSync(p, 'old', { mode: 0o644 }); + writeSecureFile(p, 'new'); + expect(fs.readFileSync(p, 'utf8')).toBe('new'); + }); +}); + +describe('appendSecureFile', () => { + test('appends to a new file and sets owner-only permissions', () => { + const p = path.join(tmpDir, 'log'); + appendSecureFile(p, 'line1\n'); + expect(fs.readFileSync(p, 'utf8')).toBe('line1\n'); + if (process.platform !== 'win32') { + expect(fs.statSync(p).mode & 0o777).toBe(0o600); + } + }); + + test('appends without re-applying ACL on subsequent writes', () => { + const p = path.join(tmpDir, 'log'); + appendSecureFile(p, 'line1\n'); + appendSecureFile(p, 'line2\n'); + expect(fs.readFileSync(p, 'utf8')).toBe('line1\nline2\n'); + }); +}); + +describe('mkdirSecure', () => { + test('creates directory with owner-only mode (POSIX)', () => { + if (process.platform === 'win32') return; + const d = path.join(tmpDir, 'nested', 'deep'); + mkdirSecure(d); + expect(fs.statSync(d).isDirectory()).toBe(true); + expect(fs.statSync(d).mode & 0o777).toBe(0o700); + }); + + test('is idempotent — safe to call on existing directory', () => { + const d = path.join(tmpDir, 'dir'); + mkdirSecure(d); + expect(() => mkdirSecure(d)).not.toThrow(); + }); + + test('recursive behavior: creates intermediate directories', () => { + const d = path.join(tmpDir, 'a', 'b', 'c'); + mkdirSecure(d); + expect(fs.existsSync(path.join(tmpDir, 'a'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true); + expect(fs.existsSync(d)).toBe(true); + }); +});