Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ permissions:

jobs:
coverage:
name: Node 22 coverage
runs-on: ubuntu-latest
name: Node 22 coverage (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
# Platform branches depend on the OS, not the supported Node release.
# Node 22 preserves comparison with the previous coverage lane without
# duplicating the native build on all six test-matrix combinations.
matrix:
os:
- ubuntu-latest
- macos-15
- windows-latest
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -41,7 +51,48 @@ jobs:
run: pnpm native:build

- name: Coverage
run: pnpm test:coverage
run: pnpm test:coverage:collect

- name: Upload coverage input
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ runner.os }}
path: coverage/coverage-final.json
if-no-files-found: error

report:
name: Merged coverage
needs: coverage
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10.34.5
run_install: false

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Download coverage inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
path: coverage-inputs

- name: Merge coverage and enforce thresholds
run: pnpm test:coverage:merge

- name: Summarize coverage
if: always()
Expand All @@ -55,7 +106,7 @@ jobs:
fs.appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
[
"# fs-safe coverage",
"# fs-safe merged coverage",
"",
"| Metric | Percent |",
"|---|---:|",
Expand All @@ -68,9 +119,10 @@ jobs:
);
NODE

- name: Upload coverage results
- name: Upload merged coverage results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fs-safe-coverage-${{ github.run_id }}
path: coverage/
if-no-files-found: error
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Security and Correctness

- Reject NTFS alternate data stream archive entry names on Windows before extraction, keep JavaScript and native TAR/ZIP policy aligned, and fix one-code-unit native rename and hardlink metadata buffers.
- Reject synchronous secret reads when the path is retargeted after the preview check, matching the asynchronous reader's `path-mismatch` contract instead of returning bytes from the replacement file.
- Preserve dangling symlinks when trash moves cross filesystems instead of failing while following their missing targets.
- Reject non-canonical FileStore keys and malformed archive names before filesystem access, keep JavaScript/native TAR and ZIP rejection semantics aligned (including full-width base-256 sizes and empty ZIP files), and add deterministic property-based regression coverage for path aliasing, parser boundaries, collisions, truncation, and extraction limits.
Expand All @@ -14,6 +15,10 @@
- Preserve semantic path and validation codes in synchronous `FileStore` and temp-workspace reads: missing temp leaves now report `not-found`, stable directories report `not-file`, and hardlinks and symlinks report `hardlink` and `symlink`, replacing `path-mismatch` and fabricated raw `ENOENT` respectively to match their asynchronous twins; consumers treating either old result as absence or identity drift should match the specific path-state code instead.
- Report an existing non-directory ancestor as `not-file` from both `assertNoSymlinkParents()` variants, replacing asynchronous success and the synchronous helper's platform-dependent success or raw `ENOTDIR` under default `allowMissing`; callers relying on that acceptance should ensure every existing prefix component is a directory or handle `not-file`.

### Docs and Tooling

- Measure coverage once per operating system and merge the platform reports before enforcing thresholds, so coverage reflects existing cross-platform execution rather than implying new test coverage.

## 0.5.2 - 2026-08-02

### Security and Correctness
Expand Down
4 changes: 2 additions & 2 deletions docs/archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and

## What it defends against

- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`).
- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
- **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
- **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
Expand Down Expand Up @@ -255,7 +255,7 @@ import {
} from "@openclaw/fs-safe/archive";
```

- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths.
- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
- `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
- `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain.
- `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
Expand Down
44 changes: 42 additions & 2 deletions native/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ fn set_rename_information(
) -> NativeResult<()> {
let name = wide_relative(target_path)?;
let name_bytes = std::mem::size_of_val(name.as_slice());
let byte_len = FILE_NAME_OFFSET + name_bytes;
let byte_len = (FILE_NAME_OFFSET + name_bytes).max(size_of::<FileNameInfoHeader>());
let mut buffer = aligned_name_buffer(byte_len);
// SAFETY: the zeroed usize storage is suitably aligned, the fixed fields
// end at offset 20 on the supported Windows x64 ABI, and the allocation is
Expand Down Expand Up @@ -480,7 +480,7 @@ fn set_link_information(
) -> NativeResult<()> {
let name = wide_relative(target_path)?;
let name_bytes = std::mem::size_of_val(name.as_slice());
let byte_len = FILE_NAME_OFFSET + name_bytes;
let byte_len = (FILE_NAME_OFFSET + name_bytes).max(size_of::<FileLinkInfoHeader>());
let mut buffer = aligned_name_buffer(byte_len);
// SAFETY: FILE_LINK_INFORMATION uses the same x64 filename offset.
unsafe {
Expand Down Expand Up @@ -781,6 +781,46 @@ mod tests {
.unwrap();
assert_eq!(fs::read(root.join("target")).unwrap(), b"replacement");
drop(replacement);

for (index, target_name) in ["a", "é"].into_iter().enumerate() {
let source_name = format!("short-source-{index}");
fs::write(root.join(&source_name), target_name.as_bytes()).unwrap();
let source = nt_open_relative(
root_handle.as_raw_handle() as HANDLE,
&source_name,
FILE_READ_ATTRIBUTES | DELETE_ACCESS,
FILE_OPEN,
FILE_NON_DIRECTORY_FILE,
)
.unwrap();
set_rename_information(
source.0,
root_handle.as_raw_handle() as HANDLE,
target_name,
false,
"rename short target",
)
.unwrap();
drop(source);
assert_eq!(
fs::read(root.join(target_name)).unwrap(),
target_name.as_bytes()
);
}

fs::write(root.join("link-source"), b"linked").unwrap();
let link_source = nt_open_relative(
root_handle.as_raw_handle() as HANDLE,
"link-source",
FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES,
FILE_OPEN,
FILE_NON_DIRECTORY_FILE,
)
.unwrap();
set_link_information(link_source.0, root_handle.as_raw_handle() as HANDLE, "l").unwrap();
drop(link_source);
assert_eq!(fs::read(root.join("l")).unwrap(), b"linked");

fs::remove_dir_all(root).unwrap();
}
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@
"prepack": "node scripts/prepack-build.mjs",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:coverage:collect": "vitest run --coverage --coverage.reporter=json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0",
"test:coverage:merge": "node scripts/merge-coverage.mjs",
"test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm docs:check && pnpm test && node scripts/check-pack.mjs",
"docs:check": "node scripts/check-doc-examples.mjs",
Expand All @@ -155,6 +157,9 @@
"@types/node": "^26.1.2",
"@vitest/coverage-v8": "4.1.10",
"fast-check": "^4.9.0",
"istanbul-lib-coverage": "3.2.2",
"istanbul-lib-report": "3.0.1",
"istanbul-reports": "3.2.0",
"sigstore": "5.0.0",
"typescript": "^7.0.2",
"vite": "8.2.0",
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 85 additions & 0 deletions scripts/merge-coverage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import libCoverage from "istanbul-lib-coverage";
import libReport from "istanbul-lib-report";
import reports from "istanbul-reports";
import { loadConfigFromFile } from "vite";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const coverageDir = join(repoRoot, "coverage");
const inputPaths = [
"coverage-inputs/coverage-Linux/coverage-final.json",
"coverage-inputs/coverage-macOS/coverage-final.json",
"coverage-inputs/coverage-Windows/coverage-final.json",
].map((path) => join(repoRoot, path));

const coverageMap = libCoverage.createCoverageMap({});

for (const inputPath of inputPaths) {
if (!existsSync(inputPath)) {
throw new Error(`Missing required coverage input: ${relative(repoRoot, inputPath)}`);
}

const input = JSON.parse(readFileSync(inputPath, "utf8"));
const normalized = {};
for (const [sourcePath, fileCoverage] of Object.entries(input)) {
const portablePath = sourcePath.replaceAll("\\", "/");
const markerIndex = portablePath.lastIndexOf("/src/");
const projectPath = markerIndex >= 0
? portablePath.slice(markerIndex + 1)
: portablePath.startsWith("src/")
? portablePath
: undefined;
if (!projectPath) {
throw new Error(`Coverage input contains a non-project path: ${sourcePath}`);
}

const normalizedPath = join(repoRoot, ...projectPath.split("/"));
if (!existsSync(normalizedPath)) {
throw new Error(`Coverage input references a missing source file: ${projectPath}`);
}
if (normalized[normalizedPath]) {
throw new Error(`Coverage input contains duplicate source file: ${projectPath}`);
}
normalized[normalizedPath] = { ...fileCoverage, path: normalizedPath };
}
coverageMap.merge(normalized);
}

rmSync(coverageDir, { recursive: true, force: true });
const reportContext = libReport.createContext({
dir: coverageDir,
coverageMap,
});
for (const reporter of ["text", "json", "json-summary", "html", "lcov"]) {
reports.create(reporter, { projectRoot: repoRoot }).execute(reportContext);
}

const loadedConfig = await loadConfigFromFile(
{ command: "serve", mode: "test" },
join(repoRoot, "vitest.config.ts"),
);
const thresholds = loadedConfig?.config?.test?.coverage?.thresholds;
if (!thresholds) {
throw new Error("vitest.config.ts does not define coverage thresholds");
}

const summary = coverageMap.getCoverageSummary().toJSON();
let failed = false;
for (const metric of ["lines", "functions", "statements", "branches"]) {
const threshold = thresholds[metric];
if (typeof threshold !== "number") {
throw new Error(`vitest.config.ts does not define a numeric ${metric} threshold`);
}
if (summary[metric].pct < threshold) {
console.error(
`ERROR: Coverage for ${metric} (${summary[metric].pct}%) does not meet threshold (${threshold}%)`,
);
failed = true;
}
}
if (failed) {
process.exitCode = 1;
}
9 changes: 9 additions & 0 deletions src/archive-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export function validateArchiveEntryPath(
);
}
const slashNormalized = normalizeArchiveEntryPath(entryPath);
if (
process.platform === "win32" &&
slashNormalized.split("/").some((segment) => segment.includes(":"))
) {
throw new ArchiveSecurityError(
"entry-path",
`archive entry uses a Windows alternate data stream path: ${formatErrorDetail(entryPath)}`,
);
}
const normalized = path.posix.normalize(slashNormalized);
if (
normalized.split("/").some((segment) =>
Expand Down
Loading
Loading