Skip to content

Commit 3f7b975

Browse files
authored
Add ability to force tar compression (#277)
1 parent d3a732f commit 3f7b975

11 files changed

Lines changed: 129 additions & 26 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- The tar write functions (`tarDirInc`, `tarDirExc`, `tarPack`) now accept a
13+
dictionary destination `{path: str|path, compress?: bool}`, where `compress`
14+
overrides the extension-based gzip inference in either direction — useful
15+
for destinations without a meaningful extension (e.g. `redo`'s `$3` temp files).
16+
1217
- Stream merge redirects `2>&1` (stderr to stdout's destination) and `1>&2`
1318
(stdout to stderr's destination). Each is a single token with no internal
1419
spaces, and works on command lists, pipeline stages, and quotations.

doc/functions.inc.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,10 @@ <h1 id="functions-file-directory">File and Directory <a class="section-link" hre
126126
<tr> <td><code>zipExtract</code></td> <td>Extract an entire archive. Options dict is required; defaults: <code>overwrite=false</code>, <code>skipExisting=false</code> (mutually exclusive), <code>stripComponents=0</code>, <code>pattern=""</code> (glob matched before stripping), <code>preservePermissions=true</code>, <code>maxBytes=0</code> (0 = unlimited; a cap on the total uncompressed bytes written, guarding against decompression bombs). Destination is created if missing.</td> <td><code>(<span class="sig-type sig-type-path">path</span>:zipPath <span class="sig-type sig-type-path">path</span>:destDir <span class="sig-type sig-type-dict">dict</span>:options -- )</code></td> </tr>
127127
<tr> <td><code>zipExtractEntry</code></td> <td>Extract a single entry (file or directory subtree) to a destination path. Options dict is required; defaults: <code>overwrite=false</code>, <code>skipExisting=false</code> (mutually exclusive), <code>preservePermissions=true</code>, <code>mkdirs=true</code> (create parent directories when needed), <code>maxBytes=0</code> (0 = unlimited uncompressed-byte cap).</td> <td><code>(<span class="sig-type sig-type-path">path</span>:zipPath <span class="sig-type sig-type-str">str</span>:entry <span class="sig-type sig-type-path">path</span>:dest <span class="sig-type sig-type-dict">dict</span>:options -- )</code></td> </tr>
128128
<tr> <td><code>zipRead</code></td> <td>Read an entry’s bytes directly into the stack without writing to disk. Returns <code>none</code> when the entry does not exist.</td> <td><code>(<span class="sig-type sig-type-path">path</span>:zipPath <span class="sig-type sig-type-str">str</span>:entry -- <span class="sig-type sig-type-maybe">Maybe</span>[<span class="sig-type sig-type-binary">binary</span>])</code></td> </tr>
129-
<tr> <td colspan="3"><em>Tar functions mirror the <code>zip*</code> surface (same argument order and option dicts). Compression is chosen from the destination extension when writing (<code>.tar.gz</code> / <code>.tgz</code> → gzip, <code>.tar</code> → uncompressed) and auto-detected from the gzip magic bytes when reading. Symlinks are preserved: packed as symlink entries and recreated on extraction (with an escape guard); hard links and device nodes are rejected.</em></td> </tr>
130-
<tr> <td><code>tarDirInc</code></td> <td>Create/overwrite a <code>.tar</code> / <code>.tar.gz</code> from a directory; the archive root contains the directory’s contents (no parent folder).</td> <td><code>(<span class="sig-type sig-type-path">path</span>:sourceDir <span class="sig-type sig-type-path">path</span>:tarPath -- )</code></td> </tr>
131-
<tr> <td><code>tarDirExc</code></td> <td>Create/overwrite a <code>.tar</code> / <code>.tar.gz</code> that includes the source directory itself at the archive root (entries are prefixed with the directory name).</td> <td><code>(<span class="sig-type sig-type-path">path</span>:sourceDir <span class="sig-type sig-type-path">path</span>:tarPath -- )</code></td> </tr>
132-
<tr> <td><code>tarPack</code></td> <td>Create/overwrite a <code>.tar</code> / <code>.tar.gz</code> by packing a list of entries. Each entry is either a bare string/path (the file or directory to add, keeping its base name and mode) or a dictionary requiring <code>path</code>; in the dictionary form <code>archivePath</code> (override the in-archive name) and <code>mode</code> are optional. <code>mode</code> is a Go <a href="https://pkg.go.dev/io/fs#FileMode" target="_blank" rel="noopener noreferrer"><code>os.FileMode</code></a>; write it with an octal literal, e.g. <code>0o644</code> (<code>rw-r--r--</code>), <code>0o755</code> (<code>rwxr-xr-x</code>), <code>0o600</code>. If <code>mode</code> is omitted, the entry keeps the source file’s own mode.</td> <td><code>([<span class="sig-type sig-type-str">str</span>|<span class="sig-type sig-type-path">path</span>|<span class="sig-type sig-type-dict">dict</span>] <span class="sig-type sig-type-path">path</span>:tarPath -- )</code></td> </tr>
129+
<tr> <td colspan="3"><em>Tar functions mirror the <code>zip*</code> surface (same argument order and option dicts). Compression is chosen from the destination extension when writing (<code>.tar.gz</code> / <code>.tgz</code> → gzip, <code>.tar</code> → uncompressed) and auto-detected from the gzip magic bytes when reading. The write destination may also be a dictionary <code>{path: str|path, compress?: bool}</code>, where <code>compress</code> overrides the extension inference in either direction — useful for destinations without a meaningful extension (e.g. <code>redo</code>’s <code>$3</code> temp files). Symlinks are preserved: packed as symlink entries and recreated on extraction (with an escape guard); hard links and device nodes are rejected.</em></td> </tr>
130+
<tr> <td><code>tarDirInc</code></td> <td>Create/overwrite a <code>.tar</code> / <code>.tar.gz</code> from a directory; the archive root contains the directory’s contents (no parent folder). The destination may be a dictionary <code>{path, compress?}</code> to force compression on or off.</td> <td><code>(<span class="sig-type sig-type-path">path</span>:sourceDir <span class="sig-type sig-type-path">path</span>|<span class="sig-type sig-type-dict">dict</span>:dest -- )</code></td> </tr>
131+
<tr> <td><code>tarDirExc</code></td> <td>Create/overwrite a <code>.tar</code> / <code>.tar.gz</code> that includes the source directory itself at the archive root (entries are prefixed with the directory name). The destination may be a dictionary <code>{path, compress?}</code> to force compression on or off.</td> <td><code>(<span class="sig-type sig-type-path">path</span>:sourceDir <span class="sig-type sig-type-path">path</span>|<span class="sig-type sig-type-dict">dict</span>:dest -- )</code></td> </tr>
132+
<tr> <td><code>tarPack</code></td> <td>Create/overwrite a <code>.tar</code> / <code>.tar.gz</code> by packing a list of entries. Each entry is either a bare string/path (the file or directory to add, keeping its base name and mode) or a dictionary requiring <code>path</code>; in the dictionary form <code>archivePath</code> (override the in-archive name) and <code>mode</code> are optional. <code>mode</code> is a Go <a href="https://pkg.go.dev/io/fs#FileMode" target="_blank" rel="noopener noreferrer"><code>os.FileMode</code></a>; write it with an octal literal, e.g. <code>0o644</code> (<code>rw-r--r--</code>), <code>0o755</code> (<code>rwxr-xr-x</code>), <code>0o600</code>. If <code>mode</code> is omitted, the entry keeps the source file’s own mode. The destination may be a dictionary <code>{path, compress?}</code> to force compression on or off.</td> <td><code>([<span class="sig-type sig-type-str">str</span>|<span class="sig-type sig-type-path">path</span>|<span class="sig-type sig-type-dict">dict</span>] <span class="sig-type sig-type-path">path</span>|<span class="sig-type sig-type-dict">dict</span>:dest -- )</code></td> </tr>
133133
<tr> <td><code>tarList</code></td> <td>List archive entries as dictionaries with keys: <code>name</code> (string, forward-slash paths, directories end with <code>/</code>), <code>compressedSize</code> and <code>uncompressedSize</code> (int bytes; equal, since tar has no per-entry compressed size), <code>isDir</code> (bool), <code>perm</code> (int POSIX permission bits), <code>executable</code> (bool), <code>modified</code> (datetime), <code>type</code> (<code>"file"</code>/<code>"dir"</code>/<code>"symlink"</code>), and <code>linkTarget</code> (symlink target, empty otherwise).</td> <td><code>(<span class="sig-type sig-type-path">path</span> -- [<span class="sig-type sig-type-dict">dict</span>])</code></td> </tr>
134134
<tr> <td><code>tarExtract</code></td> <td>Extract an entire archive. Options dict is required; defaults: <code>overwrite=false</code>, <code>skipExisting=false</code> (mutually exclusive), <code>stripComponents=0</code>, <code>pattern=""</code> (glob matched before stripping), <code>preservePermissions=true</code>, <code>maxBytes=0</code> (0 = unlimited; a cap on the total uncompressed bytes written, guarding against decompression bombs). Destination is created if missing.</td> <td><code>(<span class="sig-type sig-type-path">path</span>:tarPath <span class="sig-type sig-type-path">path</span>:destDir <span class="sig-type sig-type-dict">dict</span>:options -- )</code></td> </tr>
135135
<tr> <td><code>tarExtractEntry</code></td> <td>Extract a single entry (file or directory subtree) to a destination path. Options dict is required; defaults: <code>overwrite=false</code>, <code>skipExisting=false</code> (mutually exclusive), <code>preservePermissions=true</code>, <code>mkdirs=true</code> (create parent directories when needed), <code>maxBytes=0</code> (0 = unlimited uncompressed-byte cap).</td> <td><code>(<span class="sig-type sig-type-path">path</span>:tarPath <span class="sig-type sig-type-str">str</span>:entry <span class="sig-type sig-type-path">path</span>:dest <span class="sig-type sig-type-dict">dict</span>:options -- )</code></td> </tr>

doc/mshell.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,11 @@ Two things differ, both driven by the tar format:
14841484
`.tar.gz` or `.tgz` produce a gzip-compressed tarball, `.tar` is uncompressed.
14851485
When reading, the gzip magic bytes are auto-detected, so a gzipped tarball is
14861486
read transparently regardless of its filename.
1487+
The write destination (`tarDirInc`/`tarDirExc`/`tarPack`) may also be a dictionary
1488+
`{path: str|path, compress?: bool}`, where `compress` overrides the extension
1489+
inference in either direction.
1490+
This is useful for destinations without a meaningful extension,
1491+
e.g. `redo`'s `$3` temp files: `` `src` { "path": @dest, "compress": true } tarDirExc ``.
14871492
- Symlinks are preserved: `tarPack`/`tarDir*` store symlinks as symlink entries,
14881493
and `tarExtract`/`tarExtractEntry` recreate them (rejecting any whose target
14891494
would escape the destination directory). Extraction also refuses to write
@@ -1495,8 +1500,8 @@ bytes; `0` = unlimited) to guard against decompression bombs, and packing never
14951500
follows a source symlink, so a symlink loop or a link to `/dev/zero` cannot hang
14961501
or inflate the archive.
14971502

1498-
- `tarDirInc`: Create/overwrite a `.tar`/`.tar.gz` from a directory; the archive root contains the directory's contents (no parent folder). `(path:sourceDir path:tarPath -- )`
1499-
- `tarDirExc`: Create/overwrite a `.tar`/`.tar.gz` that includes the source directory itself at the archive root (entries are prefixed with the directory name). `(path:sourceDir path:tarPath -- )`
1503+
- `tarDirInc`: Create/overwrite a `.tar`/`.tar.gz` from a directory; the archive root contains the directory's contents (no parent folder). `(str | path str | path | {path: str | path, compress?: bool} -- )`
1504+
- `tarDirExc`: Create/overwrite a `.tar`/`.tar.gz` that includes the source directory itself at the archive root (entries are prefixed with the directory name). `(str | path str | path | {path: str | path, compress?: bool} -- )`
15001505
- `tarPack`: Create/overwrite a `.tar`/`.tar.gz` by packing a list of entries.
15011506
Each entry is either a bare string/path (the file or directory to add,
15021507
keeping its base name and mode) or a dictionary.
@@ -1505,7 +1510,7 @@ or inflate the archive.
15051510
`mode` is a Go `os.FileMode`; write it with an octal literal,
15061511
e.g. `0o644` (`rw-r--r--`), `0o755` (`rwxr-xr-x`), `0o600`.
15071512
If `mode` is omitted, the entry keeps the source file's own mode.
1508-
Type: `([str | path | {path: str | path, archivePath?: str | path, mode?: int}] str | path -- )`
1513+
Type: `([str | path | {path: str | path, archivePath?: str | path, mode?: int}] str | path | {path: str | path, compress?: bool} -- )`
15091514
- `tarList`: List archive entries as dictionaries with keys: `name` (string, forward-slash paths, directories end with `/`), `compressedSize` and `uncompressedSize` (int bytes; equal, since tar has no per-entry compressed size), `isDir` (bool), `perm` (int POSIX permission bits), `executable` (bool), `modified` (datetime from the archive entry), `type` (`"file"`/`"dir"`/`"symlink"`), and `linkTarget` (symlink target, empty otherwise). `(path -- [dict])`
15101515
- `tarExtract`: Extract an entire archive. Options dict is required; defaults: `overwrite=false`, `skipExisting=false` (mutually exclusive), `stripComponents=0`, `pattern=""` (glob matched before stripping), `preservePermissions=true`, `maxBytes=0` (0 = unlimited; caps the total uncompressed bytes written to guard against decompression bombs). Destination is created if missing. `(path:tarPath path:destDir dict:options -- )`
15111516
- `tarExtractEntry`: Extract a single entry (file or directory subtree) to a destination path. Options dict is required; defaults: `overwrite=false`, `skipExisting=false` (mutually exclusive), `preservePermissions=true`, `mkdirs=true`, `maxBytes=0` (0 = unlimited uncompressed-byte cap). `(path:tarPath str:entry path:dest dict:options -- )`

mshell/Evaluator.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5279,6 +5279,36 @@ func parseZipExtractEntryOptions(dict *MShellDict) (zipExtractEntryOptions, erro
52795279
return options, nil
52805280
}
52815281

5282+
// parseTarDestination interprets the destination argument of the tar write
5283+
// builtins. A plain string/path infers gzip compression from the extension
5284+
// (isGzipTarget); a dict form {path, compress?} makes the choice explicit,
5285+
// overriding the extension in either direction.
5286+
func parseTarDestination(obj MShellObject) (string, bool, error) {
5287+
if dict, ok := obj.(*MShellDict); ok {
5288+
pathObj, ok := dict.Items["path"]
5289+
if !ok {
5290+
return "", false, fmt.Errorf("destination dict is missing required 'path'")
5291+
}
5292+
tarPath, err := pathObj.CastString()
5293+
if err != nil {
5294+
return "", false, fmt.Errorf("destination 'path' must be a string or path, found %s", pathObj.TypeName())
5295+
}
5296+
compress := isGzipTarget(tarPath)
5297+
if val, ok, err := boolOption(dict, "compress"); err != nil {
5298+
return "", false, err
5299+
} else if ok {
5300+
compress = val
5301+
}
5302+
return tarPath, compress, nil
5303+
}
5304+
5305+
tarPath, err := obj.CastString()
5306+
if err != nil {
5307+
return "", false, fmt.Errorf("Cannot tar into a %s", obj.TypeName())
5308+
}
5309+
return tarPath, isGzipTarget(tarPath), nil
5310+
}
5311+
52825312
func boolOption(dict *MShellDict, key string) (bool, bool, error) {
52835313
item, ok := dict.Items[key]
52845314
if !ok {
@@ -7853,9 +7883,9 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
78537883
return state.FailWithMessage(err.Error())
78547884
}
78557885

7856-
tarPath, err := obj1.CastString()
7886+
tarPath, compress, err := parseTarDestination(obj1)
78577887
if err != nil {
7858-
return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot tar into a %s.\n", t.Line, t.Column, obj1.TypeName()))
7888+
return state.FailWithMessage(fmt.Sprintf("%d:%d: %s: %s\n", t.Line, t.Column, t.Lexeme, err.Error()))
78597889
}
78607890

78617891
sourceDir, err := obj2.CastString()
@@ -7864,7 +7894,7 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
78647894
}
78657895

78667896
preserveRoot := t.Lexeme == "tarDirInc"
7867-
if err := tarDirectory(sourceDir, tarPath, preserveRoot); err != nil {
7897+
if err := tarDirectory(sourceDir, tarPath, preserveRoot, compress); err != nil {
78687898
return state.FailWithMessage(fmt.Sprintf("%d:%d: %s\n", t.Line, t.Column, err.Error()))
78697899
}
78707900
} else if t.Lexeme == "tarPack" {
@@ -7873,9 +7903,9 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
78737903
return state.FailWithMessage(err.Error())
78747904
}
78757905

7876-
tarPath, err := obj1.CastString()
7906+
tarPath, compress, err := parseTarDestination(obj1)
78777907
if err != nil {
7878-
return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot tar into a %s.\n", t.Line, t.Column, obj1.TypeName()))
7908+
return state.FailWithMessage(fmt.Sprintf("%d:%d: tarPack: %s\n", t.Line, t.Column, err.Error()))
78797909
}
78807910

78817911
list, ok := obj2.(*MShellList)
@@ -7928,7 +7958,7 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
79287958
entries = append(entries, packItem)
79297959
}
79307960

7931-
if err := buildTarFromEntries(entries, tarPath); err != nil {
7961+
if err := buildTarFromEntries(entries, tarPath, compress); err != nil {
79327962
return state.FailWithMessage(fmt.Sprintf("%d:%d: %s\n", t.Line, t.Column, err.Error()))
79337963
}
79347964
} else if t.Lexeme == "tarList" {

mshell/Tar.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ func openTarReader(tarPath string) (*tar.Reader, io.Closer, error) {
7373
return tar.NewReader(br), file, nil
7474
}
7575

76-
// createTarWriter creates a tar archive for writing, gzip-compressing when the
77-
// destination extension calls for it. The returned finish function must be
78-
// called (not deferred with a discarded error) to flush and close every layer.
79-
func createTarWriter(tarPath string) (*tar.Writer, func() error, error) {
76+
// createTarWriter creates a tar archive for writing, gzip-compressing when
77+
// compress is true. The returned finish function must be called (not deferred
78+
// with a discarded error) to flush and close every layer.
79+
func createTarWriter(tarPath string, compress bool) (*tar.Writer, func() error, error) {
8080
if err := os.MkdirAll(filepath.Dir(tarPath), 0755); err != nil {
8181
return nil, nil, fmt.Errorf("Error creating parent directory for %s: %w", tarPath, err)
8282
}
@@ -86,7 +86,7 @@ func createTarWriter(tarPath string) (*tar.Writer, func() error, error) {
8686
return nil, nil, fmt.Errorf("Error creating %s: %w", tarPath, err)
8787
}
8888

89-
if isGzipTarget(tarPath) {
89+
if compress {
9090
gz := gzip.NewWriter(output)
9191
tw := tar.NewWriter(gz)
9292
finish := func() error {
@@ -117,7 +117,7 @@ func createTarWriter(tarPath string) (*tar.Writer, func() error, error) {
117117
// tarDirectory packs a single directory into a tarball, mirroring zipDirectory.
118118
// preserveRoot controls whether the directory itself appears at the archive
119119
// root (tarDirExc) or only its contents (tarDirInc).
120-
func tarDirectory(sourceDir, tarPath string, preserveRoot bool) error {
120+
func tarDirectory(sourceDir, tarPath string, preserveRoot bool, compress bool) error {
121121
info, err := os.Stat(sourceDir)
122122
if err != nil {
123123
return fmt.Errorf("Error stating %s: %w", sourceDir, err)
@@ -138,7 +138,7 @@ func tarDirectory(sourceDir, tarPath string, preserveRoot bool) error {
138138
SourcePath: sourceDir,
139139
PreserveRoot: preserveRoot,
140140
}
141-
return buildTarFromEntries([]zipPackItem{packItem}, tarPath)
141+
return buildTarFromEntries([]zipPackItem{packItem}, tarPath, compress)
142142
}
143143

144144
func ensureTarTargetNotInsideSource(sourceAbs string, tarPath string) error {
@@ -155,7 +155,7 @@ func ensureTarTargetNotInsideSource(sourceAbs string, tarPath string) error {
155155

156156
// buildTarFromEntries mirrors buildZipFromEntries, reusing the zipPackItem
157157
// model so the tarPack dispatch parsing is identical to zipPack.
158-
func buildTarFromEntries(items []zipPackItem, tarPath string) error {
158+
func buildTarFromEntries(items []zipPackItem, tarPath string, compress bool) error {
159159
if len(items) == 0 {
160160
return fmt.Errorf("tarPack requires at least one entry")
161161
}
@@ -165,7 +165,7 @@ func buildTarFromEntries(items []zipPackItem, tarPath string) error {
165165
return fmt.Errorf("Error resolving %s: %w", tarPath, err)
166166
}
167167

168-
tw, finish, err := createTarWriter(tarPath)
168+
tw, finish, err := createTarWriter(tarPath, compress)
169169
if err != nil {
170170
return err
171171
}

0 commit comments

Comments
 (0)