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
3 changes: 3 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down Expand Up @@ -109,6 +112,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down Expand Up @@ -139,7 +145,7 @@ jobs:
- name: Test native security and path equivalence
env:
FS_SAFE_PAX_REQUIRE_NATIVE: "1"
run: pnpm test test/native-integration.test.ts test/native-owned-tree.test.ts test/native-write-containment.test.ts test/native-staging-regression.test.ts test/staged-file.test.ts test/staged-file-failures.test.ts test/native-archive-equivalence.test.ts test/native-publish-equivalence.test.ts test/private-directory.test.ts test/archive-pax.test.ts test/archive-pax-security.test.ts test/archive-pax-compressed.test.ts test/archive-tar-strip.test.ts test/archive-tar-framing.test.ts test/archive-tar-framing-compressed.test.ts test/archive-gzip-integrity.test.ts test/temp-workspace-cleanup-ownership.test.ts test/temp-workspace-cleanup-admission.test.ts test/temp-workspace-cleanup-capability.test.ts
run: pnpm test test/native-integration.test.ts test/native-owned-tree.test.ts test/native-write-containment.test.ts test/native-staging-regression.test.ts test/staged-file.test.ts test/staged-file-failures.test.ts test/native-archive-equivalence.test.ts test/native-publish-equivalence.test.ts test/private-directory.test.ts test/archive-unified.test.ts test/archive-wasm-abi.test.ts test/archive-pax.test.ts test/archive-pax-security.test.ts test/archive-pax-compressed.test.ts test/archive-tar-strip.test.ts test/archive-tar-framing.test.ts test/archive-tar-framing-compressed.test.ts test/archive-gzip-integrity.test.ts test/archive-gzip-container.test.ts test/temp-workspace-cleanup-ownership.test.ts test/temp-workspace-cleanup-admission.test.ts test/temp-workspace-cleanup-capability.test.ts

- name: Test native Root publication verification
env:
Expand Down Expand Up @@ -172,7 +178,9 @@ jobs:
- name: Build and test in Alpine
run: |
docker run --rm -v "$GITHUB_WORKSPACE:/work" -w /work node:24-alpine sh -euxc '
apk add --no-cache cargo rust musl-dev build-base python3
apk add --no-cache cargo rust rust-wasm musl-dev build-base python3 tar
# Alpine installs tar in /bin; the bound producer uses /usr/bin/tar.
test -x /usr/bin/tar || ln -s /bin/tar /usr/bin/tar
npm install --global pnpm@11.25.0
pnpm install --frozen-lockfile
cargo test --workspace --locked
Expand All @@ -185,7 +193,7 @@ jobs:
node scripts/native-mode-smoke.mjs off
node scripts/sidecar-contention-proof.mjs require
FS_SAFE_NATIVE_MODE=require pnpm test test/root-create-only-preflight.test.ts test/sidecar-lock-root-admission.test.ts test/sidecar-lock-root-ancestry.test.ts test/sidecar-lock-root-budget.test.ts test/sidecar-lock-root-resolver.test.ts test/sidecar-lock-root-unlink.test.ts test/sidecar-lock-unlink-siblings.test.ts test/file-lock-sync-stale.test.ts test/file-lock-sync-release.test.ts
FS_SAFE_PAX_REQUIRE_NATIVE=1 pnpm test test/native-owned-tree.test.ts test/native-write-containment.test.ts test/native-staging-regression.test.ts test/staged-file.test.ts test/staged-file-failures.test.ts test/native-archive-equivalence.test.ts test/native-publish-equivalence.test.ts test/archive-pax.test.ts test/archive-pax-security.test.ts test/archive-pax-compressed.test.ts test/archive-tar-strip.test.ts test/archive-tar-framing.test.ts test/archive-tar-framing-compressed.test.ts test/archive-gzip-integrity.test.ts
FS_SAFE_PAX_REQUIRE_NATIVE=1 pnpm test test/native-owned-tree.test.ts test/native-write-containment.test.ts test/native-staging-regression.test.ts test/staged-file.test.ts test/staged-file-failures.test.ts test/native-archive-equivalence.test.ts test/native-publish-equivalence.test.ts test/archive-unified.test.ts test/archive-wasm-abi.test.ts test/archive-pax.test.ts test/archive-pax-security.test.ts test/archive-pax-compressed.test.ts test/archive-tar-strip.test.ts test/archive-tar-framing.test.ts test/archive-tar-framing-compressed.test.ts test/archive-gzip-integrity.test.ts test/archive-gzip-container.test.ts
FS_SAFE_NATIVE_MODE=require pnpm test test/root-write-mode.test.ts test/root-write-verification.test.ts test/root-write-lifetime.test.ts test/root-write-exact-identity.test.ts test/secret-write-publication.test.ts test/native-write-mode-ownership.test.ts test/native-created-cleanup.test.ts test/file-mode-facades.test.ts
FS_SAFE_NATIVE_MODE=require pnpm test test/archive-zip-admission.test.ts test/archive-zip-metadata.test.ts test/archive-zip-integrity.test.ts
FS_SAFE_PAX_REQUIRE_NATIVE=1 pnpm test test/archive-filter-paths.test.ts test/archive-filter-compressed.test.ts test/archive-tar-gnu.test.ts test/archive-tar-gnu-meter.test.ts test/archive-tar-ignored.test.ts test/archive-tar-ignored-meter.test.ts test/archive-tar-admission.test.ts test/archive-tar-manifest.test.ts
Expand Down Expand Up @@ -254,6 +262,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/hash-identity-proof.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

- name: Create isolated proof directories
run: |
$root = Join-Path $env:RUNNER_TEMP ('hash-identity-proof-' + [guid]::NewGuid().ToString('N'))
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down Expand Up @@ -158,6 +161,9 @@ jobs:
with:
version: 0.15.2

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down Expand Up @@ -211,6 +217,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down Expand Up @@ -301,6 +310,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down Expand Up @@ -343,6 +355,9 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Add portable TAR parser target
run: rustup target add wasm32-unknown-unknown

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

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Accept bounded all-zero gzip container padding from system-tar stdout after validated member trailers, and reject nonzero data hidden after padding on both native and guarded JavaScript TAR routes.
- Unify native and guarded JavaScript TAR admission on one Rust core, bundle its WASM build, and accept strict UTF-8/newline PAX paths without a runtime `tar` dependency; retain bounded framing, raw-field validation, and guarded publication.
- Standardize malformed TAR mode fields on the native zero fallback while preserving ordinary octal, absent, zero, and safe GNU binary modes.
- Fix `replaceFileAtomic({ dirMode })` rejecting a raw `fs.stat` mode: directory modes are masked to permission bits (`0o7777`) before application and verification, matching chmod semantics; 0.8.0 regressed this input tolerance with `directory final mode could not be verified`.

## 0.8.1 - 2026-09-04
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ described in [SECURITY.md](SECURITY.md).
Use the Node.js and pnpm versions declared by the repository.

```bash
rustup target add wasm32-unknown-unknown
pnpm install --frozen-lockfile
pnpm check
```
Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["native"]
members = ["native", "archive-core", "archive-wasm"]
resolver = "2"

[profile.release]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ This is a **library-level guardrail**, not OS-level isolation. It does not repla
pnpm add @openclaw/fs-safe
```

Node 22 or newer. Core root/path/json/temp helpers avoid framework dependencies. With all optional dependencies omitted, public subpaths remain safe to import and non-archive fallback-capable operations work in `auto` or `off`. Native-only features remain unavailable, and operations needing the binding in `require` mode fail with `helper-unavailable`. JavaScript ZIP/TAR fallback also needs the optional `jszip`/`tar` codecs. See the [0.6 migration guide](docs/migrating-to-0.6.md).
Node 22 or newer. Core root/path/json/temp helpers avoid framework dependencies. With all optional dependencies omitted, public subpaths remain safe to import and non-archive fallback-capable operations work in `auto` or `off`. Native-only features remain unavailable, and operations needing the binding in `require` mode fail with `helper-unavailable`. TAR/gzip fallback uses the bundled WASM build of the same Rust parser as native and works with optional dependencies omitted. ZIP fallback still needs optional `jszip`. See the [0.6 migration guide](docs/migrating-to-0.6.md).

The package installs one prebuilt native binding for the current supported target. It
supplies fd-relative and atomic no-replace primitives that Node does not expose
Expand Down
13 changes: 13 additions & 0 deletions archive-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "fs-safe-archive-core"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
license = "MIT"

[dependencies]
unicode-normalization = "0.1.25"

[dev-dependencies]
tar = { version = "0.4.46", default-features = false }
6 changes: 6 additions & 0 deletions archive-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub mod tar_meter;
mod tar_pax;
pub mod tar_path;
mod tar_mode;

pub use tar_meter::{TarMetadataMeter, TarMeterLimits, TarMember};
96 changes: 68 additions & 28 deletions native/src/tar_meter.rs → archive-core/src/tar_meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub fn charge_manifest_path(total: &mut u64, path: &str, limit: u64) -> io::Resu

#[derive(Clone, Copy)]
pub struct TarMeterLimits {
pub windows_paths: bool,
pub max_entries: usize,
pub max_meta_entry_bytes: u64,
pub max_decoded_bytes: u64,
Expand All @@ -42,7 +43,29 @@ enum MeterState {
},
}

// Keep raw framing aligned with src/archive-tar-meta.ts, before either parser.
/// One admitted identity and decoded payload range, independent of executor.
#[derive(Debug, Clone)]
pub struct TarMember {
pub path: String,
pub entry_type: u8,
pub size: u64,
pub mode: u32,
pub offset: u64,
}

impl TarMember {
pub fn kind(&self) -> &'static str {
match self.entry_type {
0 | b'0' | b'7' => "file",
b'5' | b'D' => "directory",
b'1' => "hardlink", b'2' => "symlink",
b'3' | b'4' | b'6' => "blocked",
_ => "other",
}
}
}

// A push consumes at most one framing boundary and emits at most one member.
pub struct TarMetadataMeter<R> {
inner: R,
limits: TarMeterLimits,
Expand All @@ -56,6 +79,8 @@ pub struct TarMetadataMeter<R> {
pending_gnu_path: Option<String>,
manifest_bytes: u64,
zero_blocks: u8,
offset: u64,
member: Option<TarMember>,
}

impl<R> TarMetadataMeter<R> {
Expand All @@ -73,6 +98,8 @@ impl<R> TarMetadataMeter<R> {
pending_gnu_path: None,
manifest_bytes: 0,
zero_blocks: 0,
offset: 0,
member: None,
}
}

Expand All @@ -87,15 +114,15 @@ impl<R> TarMetadataMeter<R> {
io::Error::new(io::ErrorKind::InvalidData, META_LIMIT)
}

fn validate_gnu_body(body: &[u8], kind: u8) -> io::Result<&str> {
fn validate_gnu_body(body: &[u8], kind: u8, windows: bool) -> io::Result<&str> {
let value = body.strip_suffix(&[0]).unwrap_or(body);
if value.is_empty() || value.contains(&0) {
return Err(Self::invalid("empty GNU name or embedded NUL"));
}
let name = std::str::from_utf8(value)
.map_err(|_| Self::invalid("GNU name is not valid UTF-8"))?;
if kind == b'L' {
crate::tar_path::validate_path(name)
crate::tar_path::validate_path(name, windows)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, INVALID_GNU_PATH))?;
}
Ok(name)
Expand Down Expand Up @@ -274,11 +301,16 @@ impl<R> TarMetadataMeter<R> {
{
return Err(Self::invalid("GNU effective non-directory path ends with a separator"));
}
let raw_path = crate::tar_path::validate_member(&self.block)?;
let raw_path = crate::tar_path::validate_member(&self.block, self.limits.windows_paths)?;
let path = self.pending_pax.as_ref().and_then(|pax| pax.path.as_deref())
.or(self.pending_gnu_path.as_deref()).unwrap_or(&raw_path);
crate::tar_path::validate_path(path)?;
crate::tar_path::validate_path(path, self.limits.windows_paths)?;
charge_manifest_path(&mut self.manifest_bytes, path, self.limits.max_manifest_bytes)?;
self.member = Some(TarMember {
path: path.to_owned(), entry_type, size,
mode: crate::tar_mode::manifest_mode(&self.block, matches!(entry_type, b'5' | b'D')),
offset: self.offset,
});
self.pending_pax = None;
self.pending_gnu = [false; 2];
self.pending_gnu_path = None;
Expand Down Expand Up @@ -332,7 +364,7 @@ impl<R> TarMetadataMeter<R> {
if kind == b'x' {
self.pending_pax = Some(parse_local_pax(body)?);
} else {
let name = Self::validate_gnu_body(body, kind)?;
let name = Self::validate_gnu_body(body, kind, self.limits.windows_paths)?;
if kind == b'L' { self.pending_gnu_path = Some(name.to_owned()); }
}
self.state = if padding == 0 {
Expand Down Expand Up @@ -380,7 +412,7 @@ impl<R> TarMetadataMeter<R> {
Ok(())
}

fn check_eof(&self) -> io::Result<()> {
pub fn finish(&self) -> io::Result<()> {
if self.pending_pax.is_some() {
return Err(Self::invalid("dangling PAX metadata"));
}
Expand All @@ -398,35 +430,43 @@ impl<R> TarMetadataMeter<R> {
}
}

impl<R: Read> Read for TarMetadataMeter<R> {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
if output.is_empty() {
return Ok(0);
}
// Never ask the decoder for body bytes until its header is admitted.
impl<R> TarMetadataMeter<R> {
pub fn take_member(&mut self) -> Option<TarMember> { self.member.take() }

pub fn boundary(&self) -> usize {
let boundary = match &self.state {
MeterState::Header | MeterState::SparseHeader { .. } => 512 - self.block_len,
MeterState::Metadata { body, used, .. } => body.len() - used,
MeterState::Data { remaining } => (*remaining).min(output.len() as u64) as usize,
MeterState::Eof => output.len(),
MeterState::Data { remaining } => (*remaining).min(65536) as usize,
MeterState::Eof => 65536,
};
// At the ceiling, read at most one byte to distinguish EOF from overflow.
let decoded_boundary = self.remaining_decoded_bytes.max(1).min(output.len() as u64) as usize;
let length = output.len().min(boundary).min(decoded_boundary);
let read = self.inner.read(&mut output[..length])?;
if read == 0 {
self.check_eof()?;
return Ok(0);
}
if read as u64 > self.remaining_decoded_bytes {
if matches!(self.state, MeterState::Eof) && output[0] != 0 {
boundary.min(65536).min(self.remaining_decoded_bytes.clamp(1, 65536) as usize)
}

/// Consume only the next boundary. The caller drains the event before pushing again.
pub fn push(&mut self, bytes: &[u8]) -> io::Result<usize> {
let length = bytes.len().min(self.boundary());
let bytes = &bytes[..length];
if length as u64 > self.remaining_decoded_bytes {
if matches!(self.state, MeterState::Eof) && bytes[0] != 0 {
return Err(Self::invalid("nonzero data after TAR EOF"));
}
return Err(io::Error::other(DECODED_LIMIT));
}
self.meter(&output[..read])?;
self.remaining_decoded_bytes -= read as u64;
Ok(read)
self.offset += length as u64;
self.meter(bytes)?;
self.remaining_decoded_bytes -= length as u64;
Ok(length)
}
}

impl<R: Read> Read for TarMetadataMeter<R> {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
if output.is_empty() { return Ok(0); }
let length = output.len().min(self.boundary());
let read = self.inner.read(&mut output[..length])?;
if read == 0 { self.finish()?; return Ok(0); }
self.push(&output[..read])
}
}

Expand Down
Loading