archive
js/archive.ts
fino:archive — zip, tar, and tar.gz archive helpers implemented in JS.
This module reads, edits, creates, lists, and extracts common archive files without shelling out to platform tools. It is intended for application-level packaging workflows: bundling generated files, accepting uploaded archives, unpacking fixture data, or producing portable build artifacts from Fino code.
Supported formats:
- zip: local file headers plus a central directory, with stored or raw
DEFLATE-compressed file entries.
- tar: POSIX ustar-style 512-byte records with regular files and
directories.
- tar.gz: tar content wrapped in gzip compression via fino:compress.
Format detection is based on the destination path extension unless
ArchiveOpenOptions.format is supplied. Writable archive handles keep an
in-memory entry table and write the whole archive atomically through a
temporary file on save() or close(). Read-only helpers such as
listArchive() and extractArchive() open the archive, perform the single
operation, and close it for you. These are whole-archive operations, not
streaming reader or writer APIs; callers should avoid using them for archives
that are too large to hold comfortably in memory.
This release baseline intentionally supports a small, predictable archive subset. ZIP64 records, ZIP data descriptors, tar PAX headers, GNU long-name records, symlink restoration, and hardlink restoration are not supported. Unsupported ZIP and tar extensions are rejected or skipped before extraction writes file contents.
Supported subset and validation map
- ZIP entries use local headers plus central-directory records. Opening rejects truncated records, ZIP64 markers, data-descriptor entries, and central/local disagreement for flags, method, CRC, sizes, and name.
- ZIP file data is accepted only for stored and raw DEFLATE entries. Reads and extraction verify CRC-32 and enforce the decompressed entry-size limit.
- Tar parsing accepts regular files, directories, and POSIX ustar names. Header checksums, octal numeric fields, and payload lengths are validated.
- Tar symlink and hardlink typeflags are ignored during extraction; PAX and GNU long-name extensions are rejected because this module does not merge extension metadata into following entries.
- Archive paths are normalized to slash-separated relative names. Extraction rejects absolute paths and parent-directory escapes before touching output.
ArchiveExtractOptions.maxEntriesandmaxTotalBytesadd caller-supplied extraction limits on top of the built-in per-entry decompressed-size limit.tar.gzrelies onfino:compressgzip member validation for the wrapping stream, then applies the same tar validation as plain.tararchives.
Safety model
Extraction rejects absolute paths and parent-directory escapes before writing to disk, removes pre-existing symlinks at output paths, and ignores tar hardlink/symlink entries. ZIP and tar parsing also enforce a maximum decompressed entry size to reduce zip-bomb style expansion risks. Archives are still untrusted input: callers should extract into a dedicated directory and apply their own file-count, total-size, and business-policy limits.
Examples
import { Archive, extractArchive, listArchive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', '# Project\n');
await archive.addFile('dist/app.js', 'app.js');
await archive.close();
const entries = await listArchive('bundle.zip');
await extractArchive('bundle.zip', 'unpacked');
import { createArchive } from 'fino:archive';
const archive = await createArchive('release.tar.gz');
await archive.addDirectory('dist', 'package');
await archive.save();
await archive.close();
Useful references: - ZIP APPNOTE: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT - POSIX pax/tar format: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html - gzip file format: https://www.rfc-editor.org/rfc/rfc1952
Types
type ArchiveFormat = 'zip' | 'tar' | 'tar.gz'
Archive container format supported by Archive.
zip supports mutation and per-entry compression. tar and tar.gz use
tar headers, with tar.gz applying gzip compression to the whole archive.
type ArchiveKind = 'file' | 'directory'
Entry kind stored in archive metadata.
File entries carry payload bytes. Directory entries carry no payload and read back as empty byte arrays.
type ZipCompression = 'store' | 'deflate'
ZIP per-entry compression mode.
deflate compresses file data and is the default. store writes the bytes
unchanged and is useful for already-compressed assets.
type ArchiveInput = string | Uint8Array | ArrayBuffer
Binary or text input accepted by archive write helpers.
Strings are encoded as UTF-8. Uint8Array and ArrayBuffer inputs are
copied into the archive entry payload.
Interfaces
interface ArchiveOpenOptions {
Options for opening or creating an archive.
Format detection normally follows the archive path extension. readOnly
applies to opened handles and prevents mutating methods from writing.
import { Archive, type ArchiveOpenOptions } from 'fino:archive';
const options: ArchiveOpenOptions = {
format: 'tar.gz',
readOnly: true,
};
const archive = await Archive.open('release.artifact', options);
await archive.extract('release');
await archive.close();
Properties
format?: ArchiveFormat
Override format detection from the archive file extension.
Use this when the path does not end with .zip, .tar, .tar.gz, or
.tgz. Unsupported values are rejected by TypeScript and should not be
supplied dynamically.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.data', { format: 'zip' });
await archive.write('README.md', 'hello');
await archive.close();
readOnly?: boolean
Prevent write operations on the opened archive.
Read-only handles can list, read, and extract entries. Mutating methods
such as write(), remove(), rename(), and save() throw.
import { Archive } from 'fino:archive';
const archive = await Archive.open('bundle.zip', { readOnly: true });
const entries = await archive.entries();
await archive.close();
interface ArchiveExtractOptions extends ArchiveOpenOptions {
Optional extraction policy limits.
Limits are opt-in so existing extraction behavior remains unchanged unless a
caller supplies an explicit cap. maxEntries counts regular file entries
written to disk. maxTotalBytes counts uncompressed file payload bytes.
Properties
maxEntries?: number
Maximum number of regular file entries to extract.
maxTotalBytes?: number
Maximum total uncompressed bytes to extract across regular files.
interface ArchiveWriteOptions {
Metadata used when creating or replacing an archive entry.
Omitted fields use archive defaults: file entries default to mode 0o644,
directory entries default to 0o755, timestamps default to the current time,
and ZIP entries default to deflate compression.
import { Archive, type ArchiveWriteOptions } from 'fino:archive';
const options: ArchiveWriteOptions = {
compression: 'store',
mode: 0o600,
};
const archive = await Archive.create('secrets.zip');
await archive.write('token.txt', new Uint8Array([1, 2, 3]), options);
await archive.close();
Properties
kind?: ArchiveKind
Whether the entry should be stored as a file or directory.
When omitted, names ending with / are directories and all other entries
are files. Directory entries read back as empty byte arrays.
import { Archive } from 'fino:archive';
const archive = await Archive.create('site.tar');
await archive.write('assets/', new Uint8Array(), { kind: 'directory' });
await archive.close();
compression?: ZipCompression
ZIP compression mode.
Tar archives ignore this option. deflate is the default for file entries;
store writes uncompressed ZIP file data.
import { Archive } from 'fino:archive';
const archive = await Archive.create('assets.zip');
await archive.write('image.bin', new Uint8Array([1, 2, 3]), { compression: 'store' });
await archive.close();
mtime?: Date | number
Modification timestamp stored in the archive entry.
A Date is used directly; a number is interpreted by Date as
milliseconds since the Unix epoch. ZIP timestamps are stored in DOS date
form and may lose precision.
import { Archive } from 'fino:archive';
const archive = await Archive.create('snapshot.zip');
await archive.write('README.md', 'hello', {
mtime: new Date('2026-01-01T00:00:00Z'),
});
await archive.close();
mode?: number
POSIX file mode stored in tar or ZIP metadata.
Only metadata is stored; extraction currently writes file contents and directories but does not apply every archived mode bit.
import { Archive } from 'fino:archive';
const archive = await Archive.create('tools.tar');
await archive.write('bin/run.sh', '#!/bin/sh\n', { mode: 0o755 });
await archive.close();
interface ArchiveEntryInfo {
Normalized metadata for one archive entry.
Returned by listing APIs and entry handles. Paths are normalized to archive form with forward slashes and no leading slash.
import { listArchive, type ArchiveEntryInfo } from 'fino:archive';
const entries: ArchiveEntryInfo[] = await listArchive('bundle.zip');
const files = entries.filter((entry) => entry.kind === 'file');
const firstFile = files[0];
Properties
name: string
Normalized archive path.
Names use / separators and omit leading slashes. Empty names are not
valid for writable entries.
import { listArchive } from 'fino:archive';
const readme = (await listArchive('bundle.zip'))
.find((entry) => entry.name === 'README.md');
kind: ArchiveKind
Entry kind.
Directory entries do not carry file payloads and read as empty byte arrays.
import { listArchive } from 'fino:archive';
const directories = (await listArchive('bundle.tar'))
.filter((entry) => entry.kind === 'directory');
size: number
Uncompressed entry size in bytes.
Directory entries report 0. ZIP entries may load data lazily, but this
value is available from archive metadata.
import { listArchive } from 'fino:archive';
const largeFiles = (await listArchive('bundle.zip'))
.filter((entry) => entry.kind === 'file' && entry.size > 1_000_000);
compressedSize: number
Compressed size in bytes.
For tar entries this is the same as size. For ZIP entries it reflects
the stored compressed payload size.
import { listArchive } from 'fino:archive';
const compressedFiles = (await listArchive('bundle.zip'))
.filter((entry) => entry.compressedSize < entry.size);
mtime: Date | null
Modification time stored in the archive, when available.
Some archive entries do not carry a timestamp and return null.
import { listArchive } from 'fino:archive';
const cutoff = new Date('2026-01-01T00:00:00Z');
const recentEntries = (await listArchive('bundle.zip'))
.filter((entry) => entry.mtime !== null && entry.mtime >= cutoff);
mode: number | null
POSIX mode stored in archive metadata, when available.
The value may be null when the source archive did not provide usable mode
metadata.
import { listArchive } from 'fino:archive';
const executableEntries = (await listArchive('tools.tar'))
.filter((entry) => entry.mode !== null && (entry.mode & 0o111) !== 0);
interface ExtractResult {
Result returned by extraction helpers.
Counts regular file entries written to disk. Directory entries are created as needed but are not included in the count.
import { extractArchive, type ExtractResult } from 'fino:archive';
const result: ExtractResult = await extractArchive('bundle.zip', 'out');
if (result.entries === 0) throw new Error('archive did not contain files');
Properties
entries: number
Number of file entries written to disk.
Directory entries are not counted. If extraction throws partway through,
no ExtractResult is returned and previously written files remain on disk.
import { extractArchive } from 'fino:archive';
const result = await extractArchive('bundle.zip', 'out');
if (result.entries > 1000) throw new Error('unexpectedly large archive');
Classes
class ArchiveEntryHandle {
Handle for reading or mutating one archive entry.
Handles are returned by Archive.entry(). Metadata getters read the current
archive entry state and throw if the archive is closed or the entry has been
removed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
const entry = await archive.entry('README.md');
console.log(entry?.name);
await archive.close();
Constructors
constructor(archive: Archive, name: string)
Create an entry handle bound to an archive and normalized entry name.
Application code normally obtains handles through Archive.entry().
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'ok');
const handle = await archive.entry('file.txt');
console.log(handle?.name);
await archive.close();
Getters
get name(): string
Normalized archive entry name.
Names use forward slashes and have no leading slash.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('docs/readme.txt', 'ok');
console.log((await archive.entry('docs/readme.txt'))?.name);
await archive.close();
get kind(): ArchiveKind
Current entry kind.
Returns 'file' or 'directory'. Throws if the backing archive is closed
or the entry no longer exists.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('docs/', new Uint8Array(), { kind: 'directory' });
console.log((await archive.entry('docs'))?.kind);
await archive.close();
get size(): number
Current uncompressed entry size in bytes.
Directories report 0. Throws if the archive is closed or the entry has
been removed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'hello');
console.log((await archive.entry('file.txt'))?.size);
await archive.close();
get compressedSize(): number
Current compressed size in bytes.
For tar entries this matches size; for ZIP entries it reflects the
compressed payload size when known.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'hello');
console.log((await archive.entry('file.txt'))?.compressedSize);
await archive.close();
get mtime(): Date | null
Current entry modification time, when available.
Returns null when the archive entry has no timestamp metadata.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'hello', { mtime: new Date() });
console.log((await archive.entry('file.txt'))?.mtime);
await archive.close();
get mode(): number | null
Current entry mode, when available.
Returns POSIX mode metadata or null. Extraction does not guarantee every
archived mode bit is applied on disk.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'hello', { mode: 0o600 });
console.log((await archive.entry('file.txt'))?.mode);
await archive.close();
Methods
async bytes(): Promise<Uint8Array>
Read this entry as bytes.
Directory entries return an empty byte array. Missing entries or closed archives throw.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', new Uint8Array([1, 2]));
console.log((await archive.entry('file.txt')) && await (await archive.entry('file.txt'))!.bytes());
await archive.close();
async text(): Promise<string>
Read this entry as UTF-8 text.
Directory entries decode as an empty string. Invalid UTF-8 is handled by
TextDecoder replacement behavior.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
console.log(await (await archive.entry('README.md'))!.text());
await archive.close();
async write(data: ArchiveInput, options?: ArchiveWriteOptions): Promise<void>
Replace this entry's data and metadata.
This delegates to Archive.write() using the handle's current name.
Read-only or closed archives throw.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'old');
await (await archive.entry('file.txt'))!.write('new');
await archive.close();
async remove(): Promise<void>
Remove this entry from the archive.
Removing a missing entry is a no-op when called through Archive.remove(),
but this handle can throw if the archive has been closed. Changes are
persisted on save() or writable close().
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('file.txt', 'hello');
await (await archive.entry('file.txt'))!.remove();
await archive.close();
class Archive {
Mutable archive reader/writer for zip, tar, and tar.gz files.
Archives created with Archive.create() are written when save() or
close() is called. Archives opened read-only reject mutating operations.
Reading methods throw when an entry is missing; entry() is the nullable
lookup helper.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', '# Project\n');
await archive.close();
Constructors
constructor(path: string, format: ArchiveFormat, options: ArchiveOpenOptions = {})
Create an archive handle with an explicit format.
This constructor does not read or write the archive file. Prefer
Archive.create() or Archive.open() for extension-based format
detection and parsing.
import { Archive } from 'fino:archive';
const archive = new Archive('bundle.zip', 'zip');
await archive.close();
Getters
get path(): string
Filesystem path backing this archive.
Writable save() and close() serialize to this path. The value is the
string provided to the constructor or factory.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
console.log(archive.path);
await archive.close();
get format(): ArchiveFormat
Archive format in use after extension or option detection.
The value is 'zip', 'tar', or 'tar.gz'.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.tar.gz');
console.log(archive.format);
await archive.close();
get closed(): boolean
Whether the archive handle has been closed.
Closed archives reject all operations that require an open handle. Calling
close() more than once is allowed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.close();
console.log(archive.closed);
Static Methods
static async create(path: string, options: ArchiveOpenOptions = {}): Promise<Archive>
Create a new empty archive handle without reading an existing file.
The archive is written when save() is called or when a dirty writable
handle is closed. Existing files at path are replaced on save.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
await archive.close();
static async open(path: string, options: ArchiveOpenOptions = {}): Promise<Archive>
Open and parse an existing archive from disk.
ZIP file payloads may be loaded lazily; tar payloads are parsed from the archive bytes. Throws when the file cannot be read, the format cannot be inferred, or the archive structure is invalid.
import { Archive } from 'fino:archive';
const archive = await Archive.open('bundle.zip', { readOnly: true });
console.log(await archive.entries());
await archive.close();
Methods
async entries(): Promise<ArchiveEntryInfo[]>
List archive entries in normalized path order.
Returns metadata only; file payloads are not decoded unless already loaded. Throws if the archive is closed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
console.log((await archive.entries())[0].name);
await archive.close();
async entry(name: string): Promise<ArchiveEntryHandle | null>
Return a handle for an entry, or null if no entry exists at that path.
The lookup name is normalized before matching, so docs/./README.md and
docs/README.md refer to the same archive entry. Throws if the archive is
closed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
const entry = await archive.entry('./README.md');
console.log(entry?.size);
await archive.close();
async read(name: string): Promise<Uint8Array>
Read one file entry as bytes.
Directory entries return an empty byte array. ZIP entries compressed with unsupported methods throw when read. Missing entries and closed archives throw.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('data.bin', new Uint8Array([1, 2, 3]));
console.log((await archive.read('data.bin')).byteLength);
await archive.close();
async readText(name: string): Promise<string>
Read one file entry as UTF-8 text.
This decodes read(name) with TextDecoder. Directory entries decode as
an empty string. Invalid UTF-8 uses replacement behavior.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
console.log(await archive.readText('README.md'));
await archive.close();
async write(name: string, data: ArchiveInput, options: ArchiveWriteOptions = {}): Promise<void>
Create or replace one archive entry.
Entry names are normalized to archive paths; empty names throw. Data may be
a Uint8Array, an ArrayBuffer, or a string encoded as UTF-8. Writable
archives are marked dirty and are persisted by save() or close().
Read-only and closed archives throw.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', '# Project\n', { mode: 0o644 });
await archive.close();
async addFile(
srcPath: string,
archivePath: string | null = null,
options: ArchiveWriteOptions = {
}
): Promise<void>
Add a host filesystem file to the archive.
Reads the entire source file into memory and stores it under archivePath
or the source basename when archivePath is null. Source mtime and mode
are copied unless overridden. Throws on source read/stat errors, read-only
archives, or closed archives.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.addFile('dist/app.js', 'app.js');
await archive.close();
async addDirectory(srcPath: string, archivePath: string = ''): Promise<void>
Recursively add the contents of a host directory to the archive.
Directory contents are walked through the Fino filesystem APIs. Regular
files are added; directories are traversed. Symlinks and special files are
skipped by the current implementation. Added entries are stored under the
archivePath prefix when one is provided, otherwise relative to srcPath.
Throws on directory read failures or when the archive is not writable.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.tar.gz');
await archive.addDirectory('dist', 'package');
await archive.close();
async remove(name: string): Promise<void>
Remove an entry if it exists.
Missing entries are ignored. Removing an existing entry marks the archive dirty. Read-only and closed archives throw.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('old.txt', 'old');
await archive.remove('old.txt');
await archive.close();
async rename(oldName: string, newName: string): Promise<void>
Rename an existing entry, failing if the target name already exists.
Both names are normalized before lookup. Throws if the source is missing, the target exists, the archive is read-only, or the archive is closed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('draft.txt', 'hello');
await archive.rename('draft.txt', 'README.txt');
await archive.close();
async extract(destination: string, options: ArchiveExtractOptions = {}): Promise<ExtractResult>
Extract all entries to destination.
Extraction rejects absolute archive paths and parent-directory escapes,
removes pre-existing symlinks at output file paths, creates directories as
needed, and counts only file entries in the result. Existing regular files
are overwritten. ArchiveExtractOptions.maxEntries and maxTotalBytes add
opt-in policy limits; exceeding either throws mid-extraction. Whenever
extraction throws, previously written files remain on disk.
import { Archive } from 'fino:archive';
const archive = await Archive.open('bundle.zip', { readOnly: true });
const result = await archive.extract('unpacked', { maxEntries: 10_000 });
console.log(result.entries);
await archive.close();
async save(): Promise<void>
Serialize the archive to disk atomically through a temporary file.
The parent directory is created when needed. The archive is written to a unique temporary path, then renamed into place. Read-only and closed archives throw. Successful saves clear the dirty flag.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
await archive.save();
await archive.close();
async close(): Promise<void>
Save pending changes when writable, then mark the handle closed.
Calling close() more than once is allowed. Dirty writable archives are
saved automatically; read-only archives are simply closed.
import { Archive } from 'fino:archive';
const archive = await Archive.create('bundle.zip');
await archive.write('README.md', 'hello');
await archive.close();
Functions
async function openArchive(path: string, options: ArchiveOpenOptions = {}): Promise<Archive>
Open an existing archive from disk.
Convenience wrapper for Archive.open(). Pass { readOnly: true } to reject
writes. Throws on read, format, or parse failures.
import { openArchive } from 'fino:archive';
const archive = await openArchive('bundle.zip', { readOnly: true });
console.log(await archive.entries());
await archive.close();
async function createArchive(path: string, options: ArchiveOpenOptions = {}): Promise<Archive>
Create a new archive handle for path.
Convenience wrapper for Archive.create(). The archive is not written until
save() or writable close().
import { createArchive } from 'fino:archive';
const archive = await createArchive('bundle.tar');
await archive.write('README.md', 'hello');
await archive.close();
async function listArchive(
path: string,
options: ArchiveOpenOptions = {
}
): Promise<ArchiveEntryInfo[]>
Open an archive, return its entry list, and close it.
The archive is opened read-only regardless of options.readOnly. Throws on
read, parse, or listing failures.
import { listArchive } from 'fino:archive';
const entries = await listArchive('bundle.zip');
console.log(entries.map((entry) => entry.name));
async function extractArchive(
path: string,
destination: string,
options: ArchiveExtractOptions = {
}
): Promise<ExtractResult>
Open an archive, extract it to a destination directory, and close it.
The archive is opened read-only regardless of options.readOnly. Extraction
uses the same safety checks as Archive#extract(): absolute paths and parent
escapes are rejected, and pre-existing symlinks at output paths are removed.
ArchiveExtractOptions.maxEntries and maxTotalBytes are forwarded as
extraction limits; exceeding either throws.
import { extractArchive } from 'fino:archive';
const result = await extractArchive('bundle.zip', 'unpacked');
console.log(result.entries);