Skip to content

feat: add initial zstd support #439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
95 changes: 67 additions & 28 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.0.1",
"minizlib": "file:../minizlib/dist/commonjs",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.15.29",
"chmodr": "^1.2.0",
"end-of-stream": "^1.4.3",
"events-to-array": "^2.0.3",
Expand Down
25 changes: 21 additions & 4 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// turn tar(1) style args like `C` into the more verbose things like `cwd`

import { type GzipOptions, type ZlibOptions } from 'minizlib'

Check failure on line 3 in src/options.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 3 in src/options.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 3 in src/options.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 3 in src/options.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 3 in src/options.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 3 in src/options.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.
import { type Stats } from 'node:fs'
import { type ReadEntry } from './read-entry.js'
import { type WarnData } from './warn-method.js'
Expand Down Expand Up @@ -115,14 +115,31 @@
*
* If set `false`, then brotli options will not be used.
*
* If both this and the `gzip` option are left `undefined`, then tar will
* attempt to infer the brotli compression status, but can only do so based
* on the filename. If the filename ends in `.tbr` or `.tar.br`, and the
* first 512 bytes are not a valid tar header, then brotli decompression
* If this, the `gzip`, and `zstd` options are left `undefined`, then tar
* will attempt to infer the brotli compression status, but can only do so
* based on the filename. If the filename ends in `.tbr` or `.tar.br`, and
* the first 512 bytes are not a valid tar header, then brotli decompression
* will be attempted.
*/
brotli?: boolean | ZlibOptions

/**
* Set to `true` or an object with settings for `zstd.compress()` to
* create a zstd-compressed archive
*
* When extracting, this will cause the archive to be treated as a
* zstd-compressed file if set to `true` or a ZlibOptions object.
*
* If set `false`, then zstd options will not be used.
*
* If this, the `gzip`, and `brotli` options are left `undefined`, then tar
* will attempt to infer the zstd compression status, but can only do so
* based on the filename. If the filename ends in `.tzst` or `.tar.zst`, and
* the first 512 bytes are not a valid tar header, then zstd decompression
* will be attempted.
*/
zstd?: boolean | ZlibOptions

/**
* A function that is called with `(path, stat)` when creating an archive, or
* `(path, entry)` when extracting. Return true to process the file/entry, or
Expand Down
14 changes: 10 additions & 4 deletions src/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}

import { Minipass } from 'minipass'
import * as zlib from 'minizlib'

Check failure on line 33 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 33 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 33 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 33 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 33 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 33 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.
import { Yallist } from 'yallist'
import { ReadEntry } from './read-entry.js'
import {
Expand Down Expand Up @@ -81,7 +81,7 @@
statCache: Exclude<TarOptions['statCache'], undefined>
file: string
portable: boolean
zip?: zlib.BrotliCompress | zlib.Gzip
zip?: zlib.BrotliCompress | zlib.Gzip | zlib.ZstdCompress
readdirCache: Exclude<TarOptions['readdirCache'], undefined>
noDirRecurse: boolean
follow: boolean
Expand Down Expand Up @@ -120,9 +120,9 @@

this.portable = !!opt.portable

if (opt.gzip || opt.brotli) {
if (opt.gzip && opt.brotli) {
throw new TypeError('gzip and brotli are mutually exclusive')
if (opt.gzip || opt.brotli || opt.zstd) {
if ((opt.gzip ? 1 : 0) + (opt.brotli ? 1 : 0) + (opt.zstd ? 1 : 0) > 1) {
throw new TypeError('gzip, brotli, zstd are mutually exclusive')
}
if (opt.gzip) {
if (typeof opt.gzip !== 'object') {
Expand All @@ -139,10 +139,16 @@
}
this.zip = new zlib.BrotliCompress(opt.brotli)
}
if (opt.zstd) {
if (typeof opt.zstd !== 'object') {
opt.zstd = {}
}
this.zip = new zlib.ZstdCompress(opt.zstd)
}
/* c8 ignore next */
if (!this.zip) throw new Error('impossible')
const zip = this.zip
zip.on('data', chunk => super.write(chunk as unknown as string))

Check failure on line 151 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 151 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macos-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 151 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 151 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macos-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 151 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macos-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 151 in src/pack.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.
zip.on('end', () => super.end())
zip.on('drain', () => this[ONDRAIN]())
this.on('resume', () => zip.resume())
Expand Down
44 changes: 35 additions & 9 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// ignored entries get .resume() called on them straight away

import { EventEmitter as EE } from 'events'
import { BrotliDecompress, Unzip } from 'minizlib'
import { BrotliDecompress, Unzip, ZstdDecompress } from 'minizlib'

Check failure on line 22 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 22 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 22 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 22 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 22 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macos-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.

Check failure on line 22 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest, bash)

Cannot find module 'minizlib' or its corresponding type declarations.
import { Yallist } from 'yallist'
import { Header } from './header.js'
import { TarOptions } from './options.js'
Expand All @@ -33,6 +33,7 @@

const maxMetaEntrySize = 1024 * 1024
const gzipHeader = Buffer.from([0x1f, 0x8b])
const zstdHeader = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])

const STATE = Symbol('state')
const WRITEENTRY = Symbol('writeEntry')
Expand Down Expand Up @@ -75,6 +76,7 @@
maxMetaEntrySize: number
filter: Exclude<TarOptions['filter'], undefined>
brotli?: TarOptions['brotli']
zstd?: TarOptions['zstd']

writable: true = true
readable: false = false;
Expand All @@ -89,7 +91,7 @@
[EX]?: Pax;
[GEX]?: Pax;
[ENDED]: boolean = false;
[UNZIP]?: false | Unzip | BrotliDecompress;
[UNZIP]?: false | Unzip | BrotliDecompress | ZstdDecompress;
[ABORTED]: boolean = false;
[SAW_VALID_ENTRY]?: boolean;
[SAW_NULL_BLOCK]: boolean = false;
Expand Down Expand Up @@ -137,9 +139,19 @@
// if it's a tbr file it MIGHT be brotli, but we don't know until
// we look at it and verify it's not a valid tar file.
this.brotli =
!opt.gzip && opt.brotli !== undefined ? opt.brotli
!(opt.gzip || opt.zstd) && opt.brotli !== undefined ? opt.brotli
: isTBR ? undefined
: false
: false

// zstd has magic bytes to identify it, but we also support explicit options
// and file extension detection
const isTZST =
opt.file &&
(opt.file.endsWith('.tar.zst') || opt.file.endsWith('.tzst'))
this.zstd =
!(opt.gzip || opt.brotli) && opt.zstd !== undefined ? opt.zstd
: isTZST ? true
: undefined

// have to set this so that streams are ok piping into it
this.on('end', () => this[CLOSESTREAM]())
Expand Down Expand Up @@ -433,7 +445,7 @@
return false
}

// first write, might be gzipped
// first write, might be gzipped, zstd, or brotli compressed
const needSniff =
this[UNZIP] === undefined ||
(this.brotli === undefined && this[UNZIP] === false)
Expand All @@ -442,7 +454,7 @@
chunk = Buffer.concat([this[BUFFER], chunk])
this[BUFFER] = undefined
}
if (chunk.length < gzipHeader.length) {
if (chunk.length < Math.max(gzipHeader.length, zstdHeader.length)) {
this[BUFFER] = chunk
/* c8 ignore next */
cb?.()
Expand All @@ -460,7 +472,19 @@
}
}

const maybeBrotli = this.brotli === undefined
// look for zstd header if gzip header not found
let isZstd = false
if (this[UNZIP] === false && this.zstd !== false) {
isZstd = true
for (let i = 0; i < zstdHeader.length; i++) {
if (chunk[i] !== zstdHeader[i]) {
isZstd = false
break
}
}
}

const maybeBrotli = this.brotli === undefined && !isZstd
if (this[UNZIP] === false && maybeBrotli) {
// read the first header to see if it's a valid tar file. If so,
// we can safely assume that it's not actually brotli, despite the
Expand Down Expand Up @@ -489,16 +513,18 @@

if (
this[UNZIP] === undefined ||
(this[UNZIP] === false && this.brotli)
(this[UNZIP] === false && (this.brotli || isZstd))
) {
const ended = this[ENDED]
this[ENDED] = false
this[UNZIP] =
this[UNZIP] === undefined ?
new Unzip({})
: isZstd ?
new ZstdDecompress({})
: new BrotliDecompress({})
this[UNZIP].on('data', chunk => this[CONSUMECHUNK](chunk))

Check failure on line 526 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 526 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macos-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 526 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 526 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macos-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 526 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macos-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.

Check failure on line 526 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest, bash)

Parameter 'chunk' implicitly has an 'any' type.
this[UNZIP].on('error', er => this.abort(er as Error))

Check failure on line 527 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest, bash)

Parameter 'er' implicitly has an 'any' type.

Check failure on line 527 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macos-latest, bash)

Parameter 'er' implicitly has an 'any' type.

Check failure on line 527 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest, bash)

Parameter 'er' implicitly has an 'any' type.

Check failure on line 527 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macos-latest, bash)

Parameter 'er' implicitly has an 'any' type.

Check failure on line 527 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macos-latest, bash)

Parameter 'er' implicitly has an 'any' type.

Check failure on line 527 in src/parse.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest, bash)

Parameter 'er' implicitly has an 'any' type.
this[UNZIP].on('end', () => {
this[ENDED] = true
this[CONSUMECHUNK]()
Expand Down Expand Up @@ -676,7 +702,7 @@
this[UNZIP].end()
} else {
this[ENDED] = true
if (this.brotli === undefined)
if (this.brotli === undefined || this.zstd === undefined)
chunk = chunk || Buffer.alloc(0)
if (chunk) this.write(chunk)
this[MAYBEEND]()
Expand Down
1 change: 1 addition & 0 deletions src/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export const replace = makeCommand(
if (
opt.gzip ||
opt.brotli ||
opt.zstd ||
opt.file.endsWith('.br') ||
opt.file.endsWith('.tbr')
) {
Expand Down
71 changes: 71 additions & 0 deletions test/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,77 @@ t.test('brotli', async t => {
})
})

t.test('zstd', async t => {
const file = path.resolve(__dirname, 'fixtures/example.tzst')
const dir = path.resolve(__dirname, 'zstd')

t.beforeEach(async () => {
await mkdirp(dir)
})

t.afterEach(async () => {
await rimraf(dir)
})

t.test('succeeds based on magic bytes', async t => {
// copy the file to a new location with a different extension
const unknownExtension = path.resolve(__dirname, 'zstd/example.unknown')
fs.copyFileSync(file, unknownExtension)

x({ sync: true, file: unknownExtension, C: dir })

t.same(fs.readdirSync(dir + '/x').sort(), [
'1',
'10',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
])
t.end()
})

t.test('succeeds based on file extension', t => {
x({ sync: true, file: file, C: dir })

t.same(fs.readdirSync(dir + '/x').sort(), [
'1',
'10',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
])
t.end()
})

t.test('succeeds when passed explicit option', t => {
x({ sync: true, file: file, C: dir, brotli: true })

t.same(fs.readdirSync(dir + '/x').sort(), [
'1',
'10',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
])
t.end()
})
})

t.test('verify long linkname is not a problem', async t => {
// See: https://github.com/isaacs/node-tar/issues/312
const file = path.resolve(__dirname, 'fixtures/long-linkname.tar')
Expand Down
Binary file added test/fixtures/example.tzst
Binary file not shown.
Loading
Loading