mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-09-14 20:16:53 +08:00
a874d1d671
* test: create temp dirs with fs.mkdtemp
Every one of these suites built its temp directory by hand:
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
await fs.mkdir(testDir, { recursive: true });
That check-then-create is what CodeQL's js/insecure-temporary-file flags,
and it accounted for 452 of the repo's 467 dismissed code-scanning alerts.
Dismissing them one by one is a treadmill: every new suite that copies the
idiom mints fresh alerts, and a real finding is easy to lose in that volume.
fs.mkdtemp creates the directory atomically at mode 0700 with a random
suffix, so there is no window to pre-empt and no name to guess. The suites
that already used it are the evidence this silences the rule: 33 of the 34
files calling mkdtemp carry zero alerts. The lone exception, archive.test.ts,
was scanned one commit before its own mkdtemp fix landed and is left to that
change rather than conflicting with it.
The dirs named on Date.now() alone were genuinely predictable; the randomUUID
ones were not, but they trained the same copy-paste. Both are gone now.
Behavior is unchanged: mkdtemp creates the root the old mkdir created, and
no assertion depended on the root being absent. Verified with the full suite
(2196 tests, 111 files).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): override postcss and sharp in the website
Two advisories on the docs site have no Dependabot PR and never will:
Next pins postcss at 8.4.31 as a direct dependency and sharp at ^0.34.5 as
an optional one, so Dependabot cannot raise either without a Next release
that does it first. 16.2.11 does not — it still pins both. Left alone these
sit open indefinitely.
postcss 8.4.31 -> 8.5.22 GHSA-qx2v-qp2m-jg93 (XSS via unescaped </style>)
sharp 0.34.5 -> 0.35.3 GHSA-f88m-g3jw-g9cj (4 libvips CVEs)
A version-ranged selector (`postcss@<8.5.10`) was the first instinct, since it
lapses on its own once Next moves past it. It is the wrong tool: it pins the
override to one advisory's floor, and silently stops applying when the next
advisory raises that floor. GHSA-6g55-p6wh-862q landed while this branch was
open and moved postcss's patched floor to 8.5.12 — under the ranged selector a
dependency pinning 8.5.11 would have resolved to 8.5.11 and stayed vulnerable.
A plain floor cannot under-match, so that is what this uses.
The floor also covers the new advisory: 8.5.22 is past 8.5.12. postcss dedupes
to the single copy the site already had for Tailwind.
These are the last two open Dependabot alerts on the repo. `pnpm audit` on
website/ goes from 2 advisories to "No known vulnerabilities found". The site
builds clean, OG image generation included, and the package set grows by
exactly two platform-gated wasm32 binaries that never install on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(security): audit the docs site too
Both `pnpm audit` steps run at the repo root. The docs site keeps its own
lockfile and is not a workspace member, so neither step could see it — the
root audit passed all the way through while postcss and sharp sat open in
website/. That blind spot is why they needed a manual override to find.
Blocking rule copied from the published-dependency audit: advisory on pull
requests, blocking on the weekly schedule and on pushes to main. An
always-advisory step would only relocate the blind spot — the sweep would stay
green with a live advisory and someone would have to read the log of a passing
run to notice.
`!cancelled()` because the two audits above can fail hard. Without it a root
advisory would skip this step entirely, in exactly the situation where the
site's own state matters most.
Verified against the pre-fix lockfile — the step reports the two advisories it
would have caught:
2 vulnerabilities found
Severity: 1 moderate | 1 high
and reports "No known vulnerabilities found" against the fixed one. Confirmed
it reads website/pnpm-lock.yaml and not the root: with a vulnerable website
lockfile and a clean root, it exits 1; a missing website lockfile is a hard
ERR_PNPM_AUDIT_NO_LOCKFILE rather than a false clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as nodeFs from 'fs';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { FileSystemUtils } from '../../src/utils/file-system.js';
|
|
|
|
describe('FileSystemUtils', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(async () => {
|
|
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('createDirectory', () => {
|
|
it('should create a directory', async () => {
|
|
const dirPath = path.join(testDir, 'new-dir');
|
|
await FileSystemUtils.createDirectory(dirPath);
|
|
|
|
const stats = await fs.stat(dirPath);
|
|
expect(stats.isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('should create nested directories', async () => {
|
|
const dirPath = path.join(testDir, 'nested', 'deep', 'dir');
|
|
await FileSystemUtils.createDirectory(dirPath);
|
|
|
|
const stats = await fs.stat(dirPath);
|
|
expect(stats.isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('should not throw if directory already exists', async () => {
|
|
const dirPath = path.join(testDir, 'existing-dir');
|
|
await fs.mkdir(dirPath);
|
|
|
|
await expect(FileSystemUtils.createDirectory(dirPath)).resolves.not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('fileExists', () => {
|
|
it('should return true for existing file', async () => {
|
|
const filePath = path.join(testDir, 'test.txt');
|
|
await fs.writeFile(filePath, 'test content');
|
|
|
|
const exists = await FileSystemUtils.fileExists(filePath);
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-existing file', async () => {
|
|
const filePath = path.join(testDir, 'non-existent.txt');
|
|
|
|
const exists = await FileSystemUtils.fileExists(filePath);
|
|
expect(exists).toBe(false);
|
|
});
|
|
|
|
it('should return false for directory path', async () => {
|
|
const dirPath = path.join(testDir, 'dir');
|
|
await fs.mkdir(dirPath);
|
|
|
|
const exists = await FileSystemUtils.fileExists(dirPath);
|
|
expect(exists).toBe(true); // fs.access doesn't distinguish between files and directories
|
|
});
|
|
});
|
|
|
|
describe('directoryExists', () => {
|
|
it('should return true for existing directory', async () => {
|
|
const dirPath = path.join(testDir, 'test-dir');
|
|
await fs.mkdir(dirPath);
|
|
|
|
const exists = await FileSystemUtils.directoryExists(dirPath);
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-existing directory', async () => {
|
|
const dirPath = path.join(testDir, 'non-existent-dir');
|
|
|
|
const exists = await FileSystemUtils.directoryExists(dirPath);
|
|
expect(exists).toBe(false);
|
|
});
|
|
|
|
it('should return false for file path', async () => {
|
|
const filePath = path.join(testDir, 'file.txt');
|
|
await fs.writeFile(filePath, 'content');
|
|
|
|
const exists = await FileSystemUtils.directoryExists(filePath);
|
|
expect(exists).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('canonicalizeExistingPath', () => {
|
|
it('should prefer the native realpath resolver when available', async () => {
|
|
const filePath = path.join(testDir, 'canonical.txt');
|
|
await fs.writeFile(filePath, 'content');
|
|
|
|
const nativeSpy = vi.spyOn(nodeFs.realpathSync, 'native');
|
|
|
|
const resolved = FileSystemUtils.canonicalizeExistingPath(filePath);
|
|
|
|
expect(nativeSpy).toHaveBeenCalledWith(filePath);
|
|
expect(resolved).toBe(nodeFs.realpathSync.native(filePath));
|
|
|
|
nativeSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('writeFile', () => {
|
|
it('should write content to file', async () => {
|
|
const filePath = path.join(testDir, 'output.txt');
|
|
const content = 'Hello, World!';
|
|
|
|
await FileSystemUtils.writeFile(filePath, content);
|
|
|
|
const readContent = await fs.readFile(filePath, 'utf-8');
|
|
expect(readContent).toBe(content);
|
|
});
|
|
|
|
it('should create directory if it does not exist', async () => {
|
|
const filePath = path.join(testDir, 'nested', 'dir', 'output.txt');
|
|
const content = 'Nested content';
|
|
|
|
await FileSystemUtils.writeFile(filePath, content);
|
|
|
|
const readContent = await fs.readFile(filePath, 'utf-8');
|
|
expect(readContent).toBe(content);
|
|
});
|
|
|
|
it('should overwrite existing file', async () => {
|
|
const filePath = path.join(testDir, 'existing.txt');
|
|
await fs.writeFile(filePath, 'old content');
|
|
|
|
const newContent = 'new content';
|
|
await FileSystemUtils.writeFile(filePath, newContent);
|
|
|
|
const readContent = await fs.readFile(filePath, 'utf-8');
|
|
expect(readContent).toBe(newContent);
|
|
});
|
|
});
|
|
|
|
describe('readFile', () => {
|
|
it('should read file content', async () => {
|
|
const filePath = path.join(testDir, 'input.txt');
|
|
const content = 'Test content';
|
|
await fs.writeFile(filePath, content);
|
|
|
|
const readContent = await FileSystemUtils.readFile(filePath);
|
|
expect(readContent).toBe(content);
|
|
});
|
|
|
|
it('should throw for non-existing file', async () => {
|
|
const filePath = path.join(testDir, 'non-existent.txt');
|
|
|
|
await expect(FileSystemUtils.readFile(filePath)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('ensureWritePermissions', () => {
|
|
it('should return true for writable directory', async () => {
|
|
const hasPermission = await FileSystemUtils.ensureWritePermissions(testDir);
|
|
expect(hasPermission).toBe(true);
|
|
});
|
|
|
|
it('should return true for non-existing directory with writable parent', async () => {
|
|
const dirPath = path.join(testDir, 'new-dir');
|
|
const hasPermission = await FileSystemUtils.ensureWritePermissions(dirPath);
|
|
expect(hasPermission).toBe(true);
|
|
});
|
|
|
|
it('should handle deeply nested non-existing directories', async () => {
|
|
const dirPath = path.join(testDir, 'a', 'b', 'c', 'd');
|
|
const hasPermission = await FileSystemUtils.ensureWritePermissions(dirPath);
|
|
expect(hasPermission).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('canWriteFile', () => {
|
|
it('should return true for existing writable file', async () => {
|
|
const filePath = path.join(testDir, 'writable.txt');
|
|
await fs.writeFile(filePath, 'content');
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
|
|
it('should return false for existing read-only file', async () => {
|
|
const filePath = path.join(testDir, 'readonly.txt');
|
|
await fs.writeFile(filePath, 'content');
|
|
await fs.chmod(filePath, 0o444); // Read-only
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(false);
|
|
|
|
// Cleanup: restore permissions so afterEach can delete
|
|
await fs.chmod(filePath, 0o644);
|
|
});
|
|
|
|
it('should return true for non-existent file in writable directory', async () => {
|
|
const filePath = path.join(testDir, 'new-file.txt');
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
|
|
it('should return true for non-existent file in non-existent nested directories', async () => {
|
|
const filePath = path.join(testDir, 'deep', 'nested', 'path', 'file.txt');
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
|
|
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
|
|
// Windows uses ACLs which Node.js chmod doesn't control
|
|
it.skipIf(process.platform === 'win32')('should return false for non-existent file in read-only directory', async () => {
|
|
const readOnlyDir = path.join(testDir, 'readonly-dir');
|
|
await fs.mkdir(readOnlyDir);
|
|
await fs.chmod(readOnlyDir, 0o555); // Read-only + execute
|
|
|
|
const filePath = path.join(readOnlyDir, 'file.txt');
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(false);
|
|
|
|
// Cleanup
|
|
await fs.chmod(readOnlyDir, 0o755);
|
|
});
|
|
|
|
it('should return true when path points to existing directory', async () => {
|
|
const dirPath = path.join(testDir, 'some-dir');
|
|
await fs.mkdir(dirPath);
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(dirPath);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')('should return false for directory without search permission', async () => {
|
|
const dirPath = path.join(testDir, 'write-only-dir');
|
|
await fs.mkdir(dirPath);
|
|
await fs.chmod(dirPath, 0o222);
|
|
|
|
let canWrite = false;
|
|
try {
|
|
canWrite = await FileSystemUtils.canWriteFile(dirPath);
|
|
} finally {
|
|
await fs.chmod(dirPath, 0o755);
|
|
}
|
|
|
|
expect(canWrite).toBe(false);
|
|
});
|
|
|
|
it('should traverse multiple non-existent parent directories', async () => {
|
|
const filePath = path.join(testDir, 'a', 'b', 'c', 'd', 'e', 'file.txt');
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
|
|
it('should return false when intermediate path component is a file', async () => {
|
|
// Create a file where a directory should be
|
|
const fileInPath = path.join(testDir, 'blocking-file.txt');
|
|
await fs.writeFile(fileInPath, 'content');
|
|
|
|
// Try to check a path that goes "through" this file
|
|
const filePath = path.join(fileInPath, 'nested', 'file.txt');
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(false);
|
|
});
|
|
|
|
// Skip on Windows: creating symlinks requires elevated privileges or Developer Mode
|
|
it.skipIf(process.platform === 'win32')('should follow symbolic links to files', async () => {
|
|
const realFile = path.join(testDir, 'real-file.txt');
|
|
const linkFile = path.join(testDir, 'link-file.txt');
|
|
await fs.writeFile(realFile, 'content');
|
|
await fs.symlink(realFile, linkFile);
|
|
|
|
const canWrite = await FileSystemUtils.canWriteFile(linkFile);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
|
|
it('should handle platform-specific path separators', async () => {
|
|
const filePath = FileSystemUtils.joinPath(testDir, 'subdir', 'file.txt');
|
|
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
|
expect(canWrite).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('joinPath', () => {
|
|
it('should join POSIX-style paths', () => {
|
|
const result = FileSystemUtils.joinPath(
|
|
'/tmp/project',
|
|
'.claude/commands/openspec/proposal.md'
|
|
);
|
|
expect(result).toBe('/tmp/project/.claude/commands/openspec/proposal.md');
|
|
});
|
|
|
|
it('should join Linux home directory paths', () => {
|
|
const result = FileSystemUtils.joinPath(
|
|
'/home/dev/workspace/openspec',
|
|
'.cursor/commands/install.md'
|
|
);
|
|
expect(result).toBe('/home/dev/workspace/openspec/.cursor/commands/install.md');
|
|
});
|
|
|
|
it('should join Windows drive-letter paths with backslashes', () => {
|
|
const result = FileSystemUtils.joinPath(
|
|
'C:\\Users\\dev\\project',
|
|
'.claude/commands/openspec/proposal.md'
|
|
);
|
|
expect(result).toBe(
|
|
'C:\\Users\\dev\\project\\.claude\\commands\\openspec\\proposal.md'
|
|
);
|
|
});
|
|
|
|
it('should join Windows paths that use forward slashes', () => {
|
|
const result = FileSystemUtils.joinPath(
|
|
'D:/workspace/app',
|
|
'.cursor/commands/openspec-apply.md'
|
|
);
|
|
expect(result).toBe(
|
|
'D:\\workspace\\app\\.cursor\\commands\\openspec-apply.md'
|
|
);
|
|
});
|
|
|
|
it('should join UNC-style Windows paths', () => {
|
|
const result = FileSystemUtils.joinPath(
|
|
'\\server\\share\\repo',
|
|
'.windsurf/workflows/openspec-archive.md'
|
|
);
|
|
expect(result).toBe(
|
|
'\\server\\share\\repo\\.windsurf\\workflows\\openspec-archive.md'
|
|
);
|
|
});
|
|
});
|
|
});
|