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

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);