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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
arbitrary base (2–36). `fromBase` returns `Maybe[int]`.
- `toHex` / `toOctal` / `toBin` and `parseHex` / `parseOctal` / `parseBin`:
convenience wrappers over `toBase` / `fromBase` for the common bases.
- `tarDirInc` / `tarDirExc` / `tarPack` / `tarList` / `tarExtract` /
`tarExtractEntry` / `tarRead`: create, list, extract, and read `.tar`
archives, mirroring the existing `zip*` functions (same argument order and
option dicts). Compression is chosen from the destination extension when
writing (`.tar.gz` / `.tgz` → gzip, `.tar` → uncompressed) and auto-detected
from the gzip magic bytes when reading, so `.tar.gz` is handled
transparently. Symlinks are preserved on pack and recreated on extract
(with a guard against targets escaping the destination); hard links and
device nodes are rejected.
- Optional `maxBytes` key (int, default `0` = unlimited) on the `zipExtract` /
`zipExtractEntry` / `tarExtract` / `tarExtractEntry` options dict: caps the
total uncompressed bytes written during an extraction to guard against
decompression bombs.
- Archive extraction (both `zip*` and `tar*`) now refuses to write through a
symlink that already exists in the destination directory and points outside
it, closing a path-traversal vector when extracting into a directory that
contains symlinks.
- Archive extraction (both `zip*` and `tar*`) no longer follows a symlink at
the final path component: regular files are created with `O_EXCL`, and in
`overwrite` mode an existing name is unlinked (never dereferenced) before a
fresh file is created. This mirrors GNU tar's behavior and prevents an
`overwrite` extraction from writing through a pre-existing symlink at the
destination name (e.g. `dest/report` -> `/etc/passwd`).
- Archive extraction (both `zip*` and `tar*`) now performs every write through
an `os.Root` anchored at the destination directory. The kernel enforces that
no path can escape the destination via `..` or a symlink component (using
`openat2`/`RESOLVE_BENEATH` on Linux), closing the time-of-check/time-of-use
race that a purely lexical containment check leaves open. Legitimate symlinks
that stay within the destination continue to work.
- Optional fields in dictionary shape types, written `name?: T` (and
`"name"?: T` in `def` signatures). An optional field may be absent from a
value; when present, its value is still type-checked. This lets option-style
Expand Down
12 changes: 10 additions & 2 deletions doc/functions.inc.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,17 @@ <h1 id="functions-file-directory">File and Directory <a class="section-link" hre
<tr> <td><code>zipDirExc</code></td> <td>Create/overwrite a <code>.zip</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>:zipPath -- )</code></td> </tr>
<tr> <td><code>zipPack</code></td> <td>Create/overwrite a <code>.zip</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>. On Linux/macOS these are the POSIX permission bits restored on extraction; on Windows file permissions are synthesized by Go and largely ignored (the executable bit is still preserved for Unix consumers). 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>:zipPath -- )</code></td> </tr>
<tr> <td><code>zipList</code></td> <td>List archive entries as dictionaries with columns: <code>name</code> (string, forward-slash paths, directories end with <code>/</code>), <code>compressedSize</code> (int bytes), <code>uncompressedSize</code> (int bytes), <code>isDir</code> (bool), <code>perm</code> (int POSIX permission bits), <code>executable</code> (bool), and <code>modified</code> (datetime from the archive entry).</td> <td><code>(<span class="sig-type sig-type-path">path</span> -- [<span class="sig-type sig-type-dict">dict</span>])</code></td> </tr>
<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>. 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>
<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).</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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<tr> <td><code>tarRead</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>:tarPath <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>
<tr> <td><code>readFile</code></td> <td>Read a file into a string.</td> <td><code>(<span class="sig-type sig-type-str">str</span> -- <span class="sig-type sig-type-str">str</span>)</code></td> </tr>
<tr> <td><code>readFileBytes</code></td> <td>Read a file into binary data.</td> <td><code>(<span class="sig-type sig-type-str">str</span> -- <span class="sig-type sig-type-binary">binary</span>)</code></td> </tr>
<tr> <td><code>readTsvFile</code></td> <td>Read a TSV file into a list of rows.</td> <td><code>(<span class="sig-type sig-type-str">str</span> -- [[<span class="sig-type sig-type-str">str</span>]])</code></td> </tr>
Expand Down
41 changes: 39 additions & 2 deletions doc/mshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -1415,10 +1415,47 @@ See [Regexp.Expand](https://pkg.go.dev/regexp#Regexp.Expand) for replacement syn
If `mode` is omitted, the entry keeps the source file's own mode.
Type: `([str | path | {path: str | path, archivePath?: str | path, mode?: int}] str | path -- )`
- `zipList`: List archive entries as dictionaries with keys: `name` (string, forward-slash paths, directories end with `/`), `compressedSize` (int bytes), `uncompressedSize` (int bytes), `isDir` (bool), `perm` (int POSIX permission bits), `executable` (bool), and `modified` (datetime from the archive entry). `(path -- [dict])`
- `zipExtract`: Extract an entire archive. Options dict is required; defaults: `overwrite=false`, `skipExisting=false` (mutually exclusive), `stripComponents=0`, `pattern=""` (glob matched before stripping), `preservePermissions=true`. Destination is created if missing. `(path:zipPath path:destDir dict:options -- )`
- `zipExtractEntry`: 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`. `(path:zipPath str:entry path:dest dict:options -- )`
- `zipExtract`: 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:zipPath path:destDir dict:options -- )`
- `zipExtractEntry`: 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:zipPath str:entry path:dest dict:options -- )`
- `zipRead`: Read an entry's bytes directly onto the stack without writing to disk. Returns `none` when the entry does not exist. `(path:zipPath str:entry -- Maybe[binary])`

## Archive (Tar) Functions

The tar functions mirror the `zip*` surface exactly: same names with a `tar`
prefix, same argument order, and the same option dictionaries.
Two things differ, both driven by the tar format:

- Compression is selected by the destination extension when writing:
`.tar.gz` or `.tgz` produce a gzip-compressed tarball, `.tar` is uncompressed.
When reading, the gzip magic bytes are auto-detected, so a gzipped tarball is
read transparently regardless of its filename.
- Symlinks are preserved: `tarPack`/`tarDir*` store symlinks as symlink entries,
and `tarExtract`/`tarExtractEntry` recreate them (rejecting any whose target
would escape the destination directory). Extraction also refuses to write
through a symlink that already exists in the destination and points outside
it. Hard links and device/fifo nodes are rejected with an error.

The extract functions accept an optional `maxBytes` cap (total uncompressed
bytes; `0` = unlimited) to guard against decompression bombs, and packing never
follows a source symlink, so a symlink loop or a link to `/dev/zero` cannot hang
or inflate the archive.

- `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 -- )`
- `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 -- )`
- `tarPack`: Create/overwrite a `.tar`/`.tar.gz` 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.
Each dictionary entry requires `path` (the file or directory to add);
`archivePath` (override the in-archive name) and `mode` are optional.
`mode` is a Go `os.FileMode`; write it with an octal literal,
e.g. `0o644` (`rw-r--r--`), `0o755` (`rwxr-xr-x`), `0o600`.
If `mode` is omitted, the entry keeps the source file's own mode.
Type: `([str | path | {path: str | path, archivePath?: str | path, mode?: int}] str | path -- )`
- `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])`
- `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 -- )`
- `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 -- )`
- `tarRead`: Read an entry's bytes directly onto the stack without writing to disk. Returns `none` when the entry does not exist. `(path:tarPath str:entry -- Maybe[binary])`

## Variables

You can store to several variables in one go by separating the store tokens with commas.
Expand Down
7 changes: 7 additions & 0 deletions mshell/BuiltInList.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ var BuiltInList = map[string]struct{}{
"writeFile": {},
"wsplit": {},
"year": {},
"tarDirExc": {},
"tarDirInc": {},
"tarExtract": {},
"tarExtractEntry": {},
"tarList": {},
"tarPack": {},
"tarRead": {},
"zipDirExc": {},
"zipDirInc": {},
"zipExtract": {},
Expand Down
Loading