Skip to content

Commit 49bf8f9

Browse files
JamieMageeisaacs
authored andcommitted
feat: add initial zstd support
EDIT(@isaacs): Remove node 20 from ci workflow, since Zstd is not available until node 22. The failure mode is handled in minizlib anyway. PR-URL: #439 Credit: @JamieMagee Close: #439 Reviewed-by: @isaacs
1 parent b35ff94 commit 49bf8f9

File tree

13 files changed

+585
-51
lines changed

13 files changed

+585
-51
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
build:
1414
strategy:
1515
matrix:
16-
node-version: [20.x, 22.x, 24.x]
16+
node-version: [22.x, 24.x]
1717
platform:
1818
- os: ubuntu-latest
1919
shell: bash

package-lock.json

Lines changed: 9 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"yallist": "^5.0.0"
2929
},
3030
"devDependencies": {
31+
"@types/node": "^22.15.29",
3132
"chmodr": "^1.2.0",
3233
"end-of-stream": "^1.4.3",
3334
"events-to-array": "^2.0.3",

src/options.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,31 @@ export interface TarOptions {
115115
*
116116
* If set `false`, then brotli options will not be used.
117117
*
118-
* If both this and the `gzip` option are left `undefined`, then tar will
119-
* attempt to infer the brotli compression status, but can only do so based
120-
* on the filename. If the filename ends in `.tbr` or `.tar.br`, and the
121-
* first 512 bytes are not a valid tar header, then brotli decompression
118+
* If this, the `gzip`, and `zstd` options are left `undefined`, then tar
119+
* will attempt to infer the brotli compression status, but can only do so
120+
* based on the filename. If the filename ends in `.tbr` or `.tar.br`, and
121+
* the first 512 bytes are not a valid tar header, then brotli decompression
122122
* will be attempted.
123123
*/
124124
brotli?: boolean | ZlibOptions
125125

126+
/**
127+
* Set to `true` or an object with settings for `zstd.compress()` to
128+
* create a zstd-compressed archive
129+
*
130+
* When extracting, this will cause the archive to be treated as a
131+
* zstd-compressed file if set to `true` or a ZlibOptions object.
132+
*
133+
* If set `false`, then zstd options will not be used.
134+
*
135+
* If this, the `gzip`, and `brotli` options are left `undefined`, then tar
136+
* will attempt to infer the zstd compression status, but can only do so
137+
* based on the filename. If the filename ends in `.tzst` or `.tar.zst`, and
138+
* the first 512 bytes are not a valid tar header, then zstd decompression
139+
* will be attempted.
140+
*/
141+
zstd?: boolean | ZlibOptions
142+
126143
/**
127144
* A function that is called with `(path, stat)` when creating an archive, or
128145
* `(path, entry)` when extracting. Return true to process the file/entry, or

src/pack.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export class Pack
8181
statCache: Exclude<TarOptions['statCache'], undefined>
8282
file: string
8383
portable: boolean
84-
zip?: zlib.BrotliCompress | zlib.Gzip
84+
zip?: zlib.BrotliCompress | zlib.Gzip | zlib.ZstdCompress
8585
readdirCache: Exclude<TarOptions['readdirCache'], undefined>
8686
noDirRecurse: boolean
8787
follow: boolean
@@ -128,9 +128,9 @@ export class Pack
128128

129129
this.portable = !!opt.portable
130130

131-
if (opt.gzip || opt.brotli) {
132-
if (opt.gzip && opt.brotli) {
133-
throw new TypeError('gzip and brotli are mutually exclusive')
131+
if (opt.gzip || opt.brotli || opt.zstd) {
132+
if ((opt.gzip ? 1 : 0) + (opt.brotli ? 1 : 0) + (opt.zstd ? 1 : 0) > 1) {
133+
throw new TypeError('gzip, brotli, zstd are mutually exclusive')
134134
}
135135
if (opt.gzip) {
136136
if (typeof opt.gzip !== 'object') {
@@ -147,6 +147,12 @@ export class Pack
147147
}
148148
this.zip = new zlib.BrotliCompress(opt.brotli)
149149
}
150+
if (opt.zstd) {
151+
if (typeof opt.zstd !== 'object') {
152+
opt.zstd = {}
153+
}
154+
this.zip = new zlib.ZstdCompress(opt.zstd)
155+
}
150156
/* c8 ignore next */
151157
if (!this.zip) throw new Error('impossible')
152158
const zip = this.zip

src/parse.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
// ignored entries get .resume() called on them straight away
2020

2121
import { EventEmitter as EE } from 'events'
22-
import { BrotliDecompress, Unzip } from 'minizlib'
22+
import { BrotliDecompress, Unzip, ZstdDecompress } from 'minizlib'
2323
import { Header } from './header.js'
2424
import { TarOptions } from './options.js'
2525
import { Pax } from './pax.js'
@@ -32,6 +32,8 @@ import {
3232

3333
const maxMetaEntrySize = 1024 * 1024
3434
const gzipHeader = Buffer.from([0x1f, 0x8b])
35+
const zstdHeader = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
36+
const ZIP_HEADER_LEN = Math.max(gzipHeader.length, zstdHeader.length)
3537

3638
const STATE = Symbol('state')
3739
const WRITEENTRY = Symbol('writeEntry')
@@ -74,6 +76,7 @@ export class Parser extends EE implements Warner {
7476
maxMetaEntrySize: number
7577
filter: Exclude<TarOptions['filter'], undefined>
7678
brotli?: TarOptions['brotli']
79+
zstd?: TarOptions['zstd']
7780

7881
writable: true = true
7982
readable: false = false;
@@ -87,7 +90,7 @@ export class Parser extends EE implements Warner {
8790
[EX]?: Pax;
8891
[GEX]?: Pax;
8992
[ENDED]: boolean = false;
90-
[UNZIP]?: false | Unzip | BrotliDecompress;
93+
[UNZIP]?: false | Unzip | BrotliDecompress | ZstdDecompress;
9194
[ABORTED]: boolean = false;
9295
[SAW_VALID_ENTRY]?: boolean;
9396
[SAW_NULL_BLOCK]: boolean = false;
@@ -135,9 +138,19 @@ export class Parser extends EE implements Warner {
135138
// if it's a tbr file it MIGHT be brotli, but we don't know until
136139
// we look at it and verify it's not a valid tar file.
137140
this.brotli =
138-
!opt.gzip && opt.brotli !== undefined ? opt.brotli
141+
!(opt.gzip || opt.zstd) && opt.brotli !== undefined ? opt.brotli
139142
: isTBR ? undefined
140-
: false
143+
: false
144+
145+
// zstd has magic bytes to identify it, but we also support explicit options
146+
// and file extension detection
147+
const isTZST =
148+
opt.file &&
149+
(opt.file.endsWith('.tar.zst') || opt.file.endsWith('.tzst'))
150+
this.zstd =
151+
!(opt.gzip || opt.brotli) && opt.zstd !== undefined ? opt.zstd
152+
: isTZST ? true
153+
: undefined
141154

142155
// have to set this so that streams are ok piping into it
143156
this.on('end', () => this[CLOSESTREAM]())
@@ -431,7 +444,7 @@ export class Parser extends EE implements Warner {
431444
return false
432445
}
433446

434-
// first write, might be gzipped
447+
// first write, might be gzipped, zstd, or brotli compressed
435448
const needSniff =
436449
this[UNZIP] === undefined ||
437450
(this.brotli === undefined && this[UNZIP] === false)
@@ -440,7 +453,7 @@ export class Parser extends EE implements Warner {
440453
chunk = Buffer.concat([this[BUFFER], chunk])
441454
this[BUFFER] = undefined
442455
}
443-
if (chunk.length < gzipHeader.length) {
456+
if (chunk.length < ZIP_HEADER_LEN) {
444457
this[BUFFER] = chunk
445458
/* c8 ignore next */
446459
cb?.()
@@ -458,7 +471,19 @@ export class Parser extends EE implements Warner {
458471
}
459472
}
460473

461-
const maybeBrotli = this.brotli === undefined
474+
// look for zstd header if gzip header not found
475+
let isZstd = false
476+
if (this[UNZIP] === false && this.zstd !== false) {
477+
isZstd = true
478+
for (let i = 0; i < zstdHeader.length; i++) {
479+
if (chunk[i] !== zstdHeader[i]) {
480+
isZstd = false
481+
break
482+
}
483+
}
484+
}
485+
486+
const maybeBrotli = this.brotli === undefined && !isZstd
462487
if (this[UNZIP] === false && maybeBrotli) {
463488
// read the first header to see if it's a valid tar file. If so,
464489
// we can safely assume that it's not actually brotli, despite the
@@ -487,13 +512,15 @@ export class Parser extends EE implements Warner {
487512

488513
if (
489514
this[UNZIP] === undefined ||
490-
(this[UNZIP] === false && this.brotli)
515+
(this[UNZIP] === false && (this.brotli || isZstd))
491516
) {
492517
const ended = this[ENDED]
493518
this[ENDED] = false
494519
this[UNZIP] =
495520
this[UNZIP] === undefined ?
496521
new Unzip({})
522+
: isZstd ?
523+
new ZstdDecompress({})
497524
: new BrotliDecompress({})
498525
this[UNZIP].on('data', chunk => this[CONSUMECHUNK](chunk))
499526
this[UNZIP].on('error', er => this.abort(er as Error))
@@ -674,7 +701,7 @@ export class Parser extends EE implements Warner {
674701
this[UNZIP].end()
675702
} else {
676703
this[ENDED] = true
677-
if (this.brotli === undefined)
704+
if (this.brotli === undefined || this.zstd === undefined)
678705
chunk = chunk || Buffer.alloc(0)
679706
if (chunk) this.write(chunk)
680707
this[MAYBEEND]()

src/replace.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export const replace = makeCommand(
286286
if (
287287
opt.gzip ||
288288
opt.brotli ||
289+
opt.zstd ||
289290
opt.file.endsWith('.br') ||
290291
opt.file.endsWith('.tbr')
291292
) {

test/extract.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,77 @@ t.test('brotli', async t => {
476476
})
477477
})
478478

479+
t.test('zstd', async t => {
480+
const file = path.resolve(__dirname, 'fixtures/example.tzst')
481+
const dir = path.resolve(__dirname, 'zstd')
482+
483+
t.beforeEach(async () => {
484+
await mkdirp(dir)
485+
})
486+
487+
t.afterEach(async () => {
488+
await rimraf(dir)
489+
})
490+
491+
t.test('succeeds based on magic bytes', async t => {
492+
// copy the file to a new location with a different extension
493+
const unknownExtension = path.resolve(__dirname, 'zstd/example.unknown')
494+
fs.copyFileSync(file, unknownExtension)
495+
496+
x({ sync: true, file: unknownExtension, C: dir })
497+
498+
t.same(fs.readdirSync(dir + '/x').sort(), [
499+
'1',
500+
'10',
501+
'2',
502+
'3',
503+
'4',
504+
'5',
505+
'6',
506+
'7',
507+
'8',
508+
'9',
509+
])
510+
t.end()
511+
})
512+
513+
t.test('succeeds based on file extension', t => {
514+
x({ sync: true, file: file, C: dir })
515+
516+
t.same(fs.readdirSync(dir + '/x').sort(), [
517+
'1',
518+
'10',
519+
'2',
520+
'3',
521+
'4',
522+
'5',
523+
'6',
524+
'7',
525+
'8',
526+
'9',
527+
])
528+
t.end()
529+
})
530+
531+
t.test('succeeds when passed explicit option', t => {
532+
x({ sync: true, file: file, C: dir, brotli: true })
533+
534+
t.same(fs.readdirSync(dir + '/x').sort(), [
535+
'1',
536+
'10',
537+
'2',
538+
'3',
539+
'4',
540+
'5',
541+
'6',
542+
'7',
543+
'8',
544+
'9',
545+
])
546+
t.end()
547+
})
548+
})
549+
479550
t.test('verify long linkname is not a problem', async t => {
480551
// See: https://github.com/isaacs/node-tar/issues/312
481552
const file = path.resolve(__dirname, 'fixtures/long-linkname.tar')

test/fixtures/example.tzst

224 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)