archives

package module
v0.10.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 30 Imported by: 4

README

archives

A Go library for reading and browsing archive files in memory. Supports ZIP, TAR (with gzip, bzip2, xz, zstd compression), Ruby gem, Hex, and conda formats.

Installation

go get github.com/git-pkgs/archives

Usage

package main

import (
	"fmt"
	"os"

	"github.com/git-pkgs/archives"
)

func main() {
	f, _ := os.Open("package.tar.gz")
	defer f.Close()

	reader, _ := archives.Open("package.tar.gz", f)
	defer reader.Close()

	// List all files
	files, _ := reader.List()
	for _, fi := range files {
		fmt.Println(fi.Path, fi.Size)
	}

	// List a specific directory
	dirFiles, _ := reader.ListDir("src")
	for _, fi := range dirFiles {
		fmt.Println(fi.Name, fi.IsDir)
	}

	// Extract a file
	rc, _ := reader.Extract("README.md")
	defer rc.Close()
	// read from rc...
}
Hashing

Open buffers the raw archive bytes in memory, so the reader can compute checksums of the original artifact without re-reading from the source. This is useful for verifying a downloaded package against the digest published by its registry.

reader, _ := archives.Open("rails-7.1.0.gem", f)
defer reader.Close()

sha, _ := reader.Hash(archives.SHA256)
fmt.Println(sha) // hex-encoded sha256 of the .gem file

// also available: archives.SHA512, archives.SHA1, archives.MD5

The hash is computed over the archive as it was passed to Open, not the decompressed contents. For nested formats like gems this means the outer .gem file, which is what rubygems.org publishes. If you already have the bytes in hand, OpenBytes skips the extra read:

reader, _ := archives.OpenBytes("pkg.tgz", data)

Filename mappings are used first. When a name has no supported extension, Open and OpenBytes detect ZIP, TAR, gzip, bzip2, xz, and zstd from the content. Compressed content is inspected for TAR headers; other payloads become a single member named content. Open reads at most 512 bytes before rejecting an unsupported stream with no recognised extension.

Streaming browsing

OpenBrowser lists entries and directories and extracts files without retaining expanded bodies. It uses the same formats and limits as OpenStream.

reopen := func() (io.ReadCloser, error) { return os.Open("package.tar.gz") }
content, err := reopen()
if err != nil {
    return err
}
defer content.Close()
browser, err := archives.OpenBrowser("package.tar.gz", content, reopen, archives.BrowseOptions{
    Limits: archives.StreamOptions{MaxInputBytes: 64 << 20},
    DetectRoot: true,
})
if err != nil {
    return err
}
files, err := browser.ListDir("")
if err != nil {
    return err
}
fmt.Println(files)
file, err := browser.Extract("README.md")
if err != nil {
    return err
}
defer file.Close()

DetectRoot strips one shared top-level directory. Top-level files, including an archive containing only README.md, remain visible. StripPrefix sets an explicit prefix such as package/ and takes precedence over root detection. CommonRoot(files) exposes the same detection for metadata from a buffered reader.

List() returns all entries, including nested paths, in one pass. ListDir(path) returns immediate children. Both retain metadata in archive order, including duplicate files, and apply the configured prefix stripping. Extraction returns the first matching entry and leaves later entries unchecked. Missing paths wrap fs.ErrNotExist; directories wrap archives.ErrIsDir. Limits apply per pass.

The caller owns the initial input, including when construction fails. Keep it open until the first operation and any extracted file finish. TAR, gem, and Hex use reopen for later passes; the browser closes those readers. Root detection adds a pass before extraction. A nil callback is allowed, but attempting another sequential pass wraps errors.ErrUnsupported. ZIP and conda buffer their compressed input once and reuse it across passes. Close each extracted file before another operation; a browser is not safe for concurrent use.

Use OpenBrowserContext(ctx, ...) to cancel listing, prefix detection, and file reads, including decompression of buffered ZIP and conda input. The same context applies to every pass. Supply a request context or one with a deadline, and make the reopen callback honor it if opening can block. Close extracted files after cancellation too; the browser still leaves the initial input owned by the caller.

Sequential reading

OpenStream reads entries one at a time through Next and Read, without retaining expanded file bodies. It supports every format listed below, including the inner files of gem, Hex, and conda packages.

stream, err := archives.OpenStream("package.tgz", f, archives.StreamOptions{
    MaxInputBytes:    64 << 20,
    MaxEntryBytes:     8 << 20,
    MaxExpandedBytes: 64 << 20,
    MaxEntries:       2000,
})
if err != nil {
    return err
}
defer stream.Close()

for {
    entry, err := stream.Next()
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }
    fmt.Println(entry.Path, entry.Size)
    if _, err := io.Copy(io.Discard, stream); err != nil {
        return err
    }
}

Import io for this example. Next discards unread entry data, and skipped entries still count towards the limits. Entries remain in archive order, including duplicates. Each entry's Read ends at io.EOF; iteration errors are terminal. The caller owns the input, and Close releases decoder resources without closing or draining it. A TAR end marker ends iteration, so trailing data and compression trailers beyond that marker may remain unchecked.

After a ZIP member error, callers can explicitly call stream.Recover() to discard that member and continue with Next. The central directory provides the next member's offset. TAR-based formats, including conda, return errors.ErrUnsupported from Recover, as does a stream with no pending error. Cancellation and limit errors remain terminal. Recovery does not validate unread data in the discarded member.

stream.Skip() abandons an unread ZIP entry without decompressing or validating its body. This also clears a size-limit rejection from Next, which returns the rejected entry alongside its error. The entry still counts toward MaxEntries, but its unread body does not consume the expanded-byte budget. Later entries can use the remaining budget. Skip rejects entries already read and does not support TAR-based formats or conda. Input limits, entry-count limits and cancellation remain terminal.

ZIP and conda require random access to their compressed input. OpenStream buffers that input within MaxInputBytes; OpenStreamBytes(name, data, options) reuses an existing byte slice without copying it. Keep that slice unchanged until Close. TAR and gem can consume a forward-only input directly.

When content detection finds gzip, bzip2, xz or zstd, the decompressed prefix determines whether to read TAR entries or expose one member named content. The single member has Size: -1 in streams and browser listings because its size is unknown before reading. Entry and expansion limits apply while reading or skipping it. Buffered readers report its actual size. Explicit TAR filenames such as .tar.gz still require TAR content.

Hex packages use .tar filenames. Buffered readers, streams, and browsers detect the four regular outer files VERSION, CHECKSUM, metadata.config, and contents.tar.gz in any order, with VERSION equal to 3 and a 64-character uppercase hexadecimal checksum. They expose the files inside contents.tar.gz. A TAR without this signature keeps its outer entries. Detection buffers the outer prefix within MaxInputBytes; if the contents member precedes other markers, that prefix includes its compressed body. Expanded file bodies remain streamed. Hash covers the original outer TAR. Detection does not verify the package checksum.

OpenStreamContext(ctx, ...) and OpenStreamBytesContext(ctx, ...) add cancellation to the same operations. Checks run between reads, while skipping entries, and while parsing ZIP metadata. Cancellation errors match context.Canceled or context.DeadlineExceeded and end iteration. These methods cannot interrupt an input read already blocked inside the caller's reader; use a context-aware input for that. The constructors without a context use context.Background().

Zero limits default to 512 MiB for each byte limit and 100,000 entries. Negative limits are rejected. Entry and expanded-byte limits use logical header sizes, including sparse files, before exposing a body. Container members in gem, Hex, and conda have a separate entry-count budget, with their combined bodies bounded by MaxInputBytes. Decoder workspace and metadata are additional memory; these limits do not cap total heap usage. For forward-only input, the input limit covers bytes consumed, with at most one extra byte read to detect overflow.

For swhid-go, regular files can go directly to objects.ComputeContentHashReader(stream, entry.Size). This preserves its collision-detecting hash without buffering a file. StreamEntry includes the mode bits, Linkname, and IsHardlink: TAR symlink targets are in Linkname, while ZIP symlink targets are in the body. A directory-hashing consumer must retain paths, modes, and content hashes, resolve hard links, and apply its own duplicate-path policy. The stream cannot rewind to hash the whole artifact; hash the original byte slice or use a tee on the input and consume it fully.

Prefix stripping

Some package formats wrap content in a directory (npm uses package/). OpenWithPrefix strips a path prefix from all entries:

reader, _ := archives.OpenWithPrefix("pkg.tgz", f, "package/")
// files are now accessible without the package/ prefix
Using io/fs

NewFS adapts a reader to fs.FS, with ReadDir, ReadFile, and Stat support. It indexes metadata once and reads file contents through the reader as needed.

archiveFS, err := archives.NewFS(reader)
if err != nil {
    return err
}
data, err := fs.ReadFile(archiveFS, "lib/util.js")
if err != nil {
    return err
}
fmt.Println(string(data))
if err := fs.WalkDir(archiveFS, ".", func(name string, entry fs.DirEntry, err error) error {
    if err != nil {
        return err
    }
    fmt.Println(name)
    return nil
}); err != nil {
    return err
}

Import io/fs for these functions. Paths use / separators and "." for the root, and missing parent directories appear automatically. Archive names may have leading ./ or directory trailing slashes; other invalid paths and file/directory conflicts cause NewFS to return fs.ErrInvalid. Duplicate paths use the first entry. Symlinks and other special entries appear in listings but cannot be opened.

Keep the reader open while using the filesystem. Closing an individual file leaves the reader open.

Extracting to disk

ExtractAll writes every entry under a target directory, creating it and any intermediate directories. Entry names are validated with filepath.Localize so absolute paths and .. segments that would escape the target return ErrUnsafePath naming the offending entry.

reader, _ := archives.Open("pkg.whl", f)
defer reader.Close()

if err := archives.ExtractAll(reader, dir); err != nil {
    return err
}

File permissions are preserved where the archive records them. Entries that the format marks as symlinks or other non-regular types are skipped.

Under TinyGo, ExtractAll returns an error matching errors.ErrUnsupported because os.Root is unavailable. In-memory reading through Reader.Extract remains available.

Comparing versions

The diff subpackage compares two archives and produces unified diffs. It classifies each file as added, deleted, modified, or binary, and includes line-level diff output for text files.

import "github.com/git-pkgs/archives/diff"

result, _ := diff.Compare(oldReader, newReader)
for _, f := range result.Files {
	fmt.Printf("%s %s (+%d -%d)\n", f.Type, f.Path, f.LinesAdded, f.LinesDeleted)
	if f.Diff != "" {
		fmt.Println(f.Diff)
	}
}

Supported formats

  • .zip, .jar, .whl, .nupkg, .egg, .vsix (ZIP-based)
  • .tar, .tar.gz, .tgz, .crate, .tar.bz2, .tar.xz, .tar.zst
  • gzip, bzip2, xz and zstd compressed single files, detected by content
  • .gem (Ruby gems with nested data.tar.gz)
  • .tar Hex packages (detected by outer markers, with nested contents.tar.gz)
  • .conda (v2 conda packages: zip of pkg-*.tar.zst and info-*.tar.zst, presented as one merged tar)
  • .apk (routed by content: Android packages open as ZIP, Alpine packages open as gzipped tar)

Filenames without a recognised extension are opened by inspecting the first bytes for a ZIP, tar, gzip, bzip2, xz, or zstd signature.

License

MIT.

Documentation

Overview

Package archives provides in-memory archive reading and browsing capabilities.

It supports multiple archive formats including:

  • ZIP (.zip, .jar, .whl, .nupkg, .egg, .vsix)
  • TAR (.tar, .tar.gz, .tgz, .crate, .tar.bz2, .tar.xz, .tar.zst)
  • GEM (.gem - Ruby gems with nested tar structure)
  • Hex (.tar - Hex packages with nested contents.tar.gz)
  • CONDA (.conda - v2 conda packages, zip of zstd tarballs)

The .apk extension is routed by content since Android packages are ZIP and Alpine packages are gzipped tar. Filenames without a recognised extension are opened by inspecting the first bytes.

The package is designed to work entirely in memory without writing to disk, making it suitable for browsing cached artifacts on-demand.

Index

Examples

Constants

View Source
const (
	SHA256 = "sha256"
	SHA512 = "sha512"
	SHA1   = "sha1"
	MD5    = "md5"
)

Supported hash algorithm names for Reader.Hash.

Variables

View Source
var ErrDecompressLimit = errors.New("decompressed content exceeds size limit")
View Source
var ErrEntryLimit = errors.New("archive entry count exceeds limit")
View Source
var ErrEntrySizeLimit = errors.New("archive entry exceeds size limit")
View Source
var ErrExtractLimit = errors.New("extracted bytes exceed limit")

ErrExtractLimit is returned by ExtractAll when the total decompressed bytes written would exceed the WithMaxBytes limit.

View Source
var ErrInputLimit = errors.New("archive input exceeds size limit")
View Source
var ErrIsDir = errors.New("path is a directory")
View Source
var ErrUnsafePath = errors.New("archive entry escapes target directory")

ErrUnsafePath is returned by ExtractAll when an archive entry name would resolve outside the target directory.

Functions

func CommonRoot added in v0.9.0

func CommonRoot(files []FileInfo) string

CommonRoot returns the shared top-level directory, including its slash. Empty archives and archives containing top-level files have no common root.

func ExtractAll added in v0.5.0

func ExtractAll(r Reader, dir string, opts ...ExtractOption) error

ExtractAll writes every entry in r under dir, creating dir and any intermediate directories as needed. Entry permissions are preserved where the archive format records them; entries with no stored mode are written as 0644 (files) or 0755 (directories).

All filesystem operations are confined to dir using os.Root, so a symlink under dir cannot redirect a write outside it. Entry names are additionally validated with filepath.Localize: absolute paths, names containing ".." elements that escape dir, and platform-invalid names cause ExtractAll to return ErrUnsafePath wrapping the offending entry name. Entries that the archive marks as non-regular (symlinks, devices) are skipped.

Types

type BrowseOptions added in v0.9.0

type BrowseOptions struct {
	Limits StreamOptions
	// StripPrefix takes precedence over DetectRoot. Include the trailing slash.
	StripPrefix string
	// DetectRoot strips a directory shared by every entry.
	DetectRoot bool
}

BrowseOptions controls per-pass limits and path prefixes.

type Browser added in v0.9.0

type Browser struct {
	// contains filtered or unexported fields
}

Browser lists directories and streams individual files without retaining expanded bodies. Finish and close each extracted file before the next call. A Browser is not safe for concurrent use.

func OpenBrowser added in v0.9.0

func OpenBrowser(filename string, content io.Reader, reopen func() (io.ReadCloser, error), options BrowseOptions) (*Browser, error)

OpenBrowser prepares an archive for browsing. The caller owns content and must keep it open until the first operation and any extracted file finish. Later TAR, gem, and Hex passes call reopen; the browser closes those readers. ZIP and conda input is buffered once and reused without calling reopen. A nil reopen is allowed, but a second sequential pass wraps errors.ErrUnsupported.

func OpenBrowserContext added in v0.9.0

func OpenBrowserContext(ctx context.Context, filename string, content io.Reader, reopen func() (io.ReadCloser, error), options BrowseOptions) (*Browser, error)

OpenBrowserContext is OpenBrowser with cancellation across every pass, including decompression of buffered input. The reopen callback must honor ctx itself if opening can block. Canceling does not close the caller's input.

func (*Browser) Extract added in v0.9.0

func (b *Browser) Extract(filePath string) (io.ReadCloser, error)

Extract streams the first matching file. The caller must close the result. Root detection requires a separate pass, so sequential input needs reopen. Missing paths wrap fs.ErrNotExist; directories wrap ErrIsDir. Skipped entries count against limits. Entries after the target and trailers are not checked.

func (*Browser) List added in v0.9.0

func (b *Browser) List() ([]FileInfo, error)

List returns all entries in archive order, retaining only metadata. Root detection and listing share one pass. Duplicate entries are preserved.

func (*Browser) ListDir added in v0.9.0

func (b *Browser) ListDir(dirPath string) ([]FileInfo, error)

ListDir returns immediate children in archive order, retaining only metadata. Root detection and listing share one pass. Duplicate files are preserved.

type ExtractOption added in v0.7.0

type ExtractOption func(*extractConfig)

ExtractOption configures ExtractAll.

func WithMaxBytes added in v0.7.0

func WithMaxBytes(n int64) ExtractOption

WithMaxBytes caps the total number of decompressed bytes ExtractAll will write. The limit is enforced against bytes actually read from each entry, not header-declared sizes, so an archive whose headers under-report content still cannot exceed it. A value of zero or less disables the limit.

type FS added in v0.8.0

type FS struct {
	// contains filtered or unexported fields
}

FS is a read-only filesystem over a Reader. The Reader must remain open while the filesystem and its files are in use.

func NewFS added in v0.8.0

func NewFS(r Reader) (*FS, error)

NewFS indexes r without extracting file contents. Missing parent directories are created in the index. Leading "./" and directory trailing slashes are removed from archive names; other invalid fs paths and file/directory conflicts return fs.ErrInvalid. The first entry for each path is used. Symlinks and other special entries can be listed, but cannot be opened. Closing an opened file does not close r; the caller owns the Reader.

Example
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("lib/hello.txt")
if err != nil {
	panic(err)
}
if _, err := io.WriteString(w, "hello\n"); err != nil {
	panic(err)
}
if err := zw.Close(); err != nil {
	panic(err)
}
r, err := Open("package.zip", &buf)
if err != nil {
	panic(err)
}
defer func() { _ = r.Close() }()
f, err := NewFS(r)
if err != nil {
	panic(err)
}
err = fs.WalkDir(f, ".", func(name string, entry fs.DirEntry, err error) error {
	if err == nil {
		fmt.Println(name)
	}
	return err
})
if err != nil {
	panic(err)
}
data, err := fs.ReadFile(f, "lib/hello.txt")
if err != nil {
	panic(err)
}
fmt.Print(string(data))
Output:
.
lib
lib/hello.txt
hello

func (*FS) Open added in v0.8.0

func (f *FS) Open(name string) (fs.File, error)

Open opens a file or directory. Names follow fs.ValidPath, with "." as root.

func (*FS) ReadDir added in v0.8.0

func (f *FS) ReadDir(name string) ([]fs.DirEntry, error)

ReadDir returns directory entries sorted by name.

func (*FS) ReadFile added in v0.8.0

func (f *FS) ReadFile(name string) ([]byte, error)

ReadFile reads the named file's contents.

func (*FS) Stat added in v0.8.0

func (f *FS) Stat(name string) (fs.FileInfo, error)

Stat returns metadata for a file or directory without extracting its contents.

type FileInfo

type FileInfo struct {
	Path           string    // Full path within archive
	Name           string    // Base name
	Size           int64     // Uncompressed bytes; -1 for an unknown streaming size
	ModTime        time.Time // Modification time
	IsDir          bool      // Whether this is a directory
	Mode           uint32    // fs.FileMode value: permission bits plus fs.ModeType bits
	HasMode        bool      // Whether Mode was recorded by the archive
	CompressedSize int64     // Compressed size (if available)
}

FileInfo represents metadata about a file in an archive.

type Reader

type Reader interface {
	// List returns all files in the archive.
	List() ([]FileInfo, error)

	// ListDir returns files in a specific directory path.
	// Use "" or "/" for root directory.
	ListDir(dirPath string) ([]FileInfo, error)

	// Extract reads a specific file from the archive.
	// Returns io.ReadCloser for the file content.
	Extract(filePath string) (io.ReadCloser, error)

	// Hash returns the hex-encoded digest of the raw archive bytes using
	// the named algorithm. Supported algorithms are SHA256, SHA512, SHA1
	// and MD5. The hash is computed over the original archive as passed
	// to Open, not the decompressed contents.
	Hash(algo string) (string, error)

	// Close releases resources associated with the reader.
	Close() error
}

Reader provides methods to browse and extract files from archives.

func Open

func Open(filename string, content io.Reader) (Reader, error)

Open creates an archive reader for the given content. The filename is used first to detect the archive format. If it has no supported extension, the content is checked for a supported physical format. Recognised archives are read entirely into memory. An unrecognised stream with no supported extension is rejected after reading at most 512 bytes.

func OpenBytes added in v0.3.0

func OpenBytes(filename string, content []byte) (Reader, error)

OpenBytes is like Open but accepts the archive content as a byte slice. The slice is retained (not copied) for the lifetime of the Reader and must not be modified by the caller after this call.

func OpenBytesWithPrefix added in v0.3.0

func OpenBytesWithPrefix(filename string, content []byte, stripPrefix string) (Reader, error)

OpenBytesWithPrefix is like OpenWithPrefix but accepts the archive content as a byte slice. The slice is retained (not copied) for the lifetime of the Reader and must not be modified by the caller after this call.

func OpenWithPrefix

func OpenWithPrefix(filename string, content io.Reader, stripPrefix string) (Reader, error)

OpenWithPrefix opens an archive and strips the given prefix from all paths. This is useful for npm packages which wrap content in a "package/" directory.

type Stream added in v0.8.1

type Stream struct {
	// contains filtered or unexported fields
}

Stream reads entries in archive order without retaining expanded bodies. Paths and duplicate entries are preserved. A Stream is not safe for concurrent use. It does not implement the random-access Reader interface.

func OpenStream added in v0.8.1

func OpenStream(filename string, content io.Reader, options StreamOptions) (*Stream, error)

OpenStream reads supported archives sequentially. ZIP and conda input is buffered up to MaxInputBytes for random access. Hex detection buffers an outer TAR prefix; TAR, gem, and Hex entry bodies are streamed. Compression detected by content exposes a non-TAR payload as one member named "content", with size -1. The caller owns content and must close it when needed.

func OpenStreamBytes added in v0.8.1

func OpenStreamBytes(filename string, content []byte, options StreamOptions) (*Stream, error)

OpenStreamBytes reuses content without copying it, including ZIP and conda input. The caller must not modify the slice until the stream is closed.

func OpenStreamBytesContext added in v0.9.0

func OpenStreamBytesContext(ctx context.Context, filename string, content []byte, options StreamOptions) (*Stream, error)

OpenStreamBytesContext is OpenStreamBytes with cancellation, including while decoding or skipping buffered ZIP and conda entries.

func OpenStreamContext added in v0.9.0

func OpenStreamContext(ctx context.Context, filename string, content io.Reader, options StreamOptions) (*Stream, error)

OpenStreamContext is OpenStream with cancellation during reading and decoding. Cancellation is terminal. The caller owns content; canceling does not close it.

func (*Stream) Close added in v0.8.1

func (s *Stream) Close() error

Close releases resources without draining or closing the input. Unread content and compressed trailers after a TAR end marker are not validated.

func (*Stream) Next added in v0.8.1

func (s *Stream) Next() (*StreamEntry, error)

Next discards the unread body and returns the next entry, or io.EOF. Header sizes, including sparse logical sizes, count against limits before the body is exposed. Unknown sizes count as bytes are read or skipped. MaxEntries counts visible entries; nested containers have a separate MaxEntries budget. Hidden TAR extension headers are excluded. Size and entry-count limit errors return the rejected entry alongside the error. Errors remain terminal unless explicitly cleared by Recover or Skip.

func (*Stream) Read added in v0.8.1

func (s *Stream) Read(p []byte) (int, error)

Read reads the current entry and returns io.EOF at its end or before Next. Non-EOF errors prevent further iteration.

func (*Stream) Recover added in v0.10.0

func (s *Stream) Recover() error

Recover abandons a failed ZIP entry so the next call to Next can continue. TAR-based formats, including conda, return errors.ErrUnsupported, as does a stream with no pending error. Cancellation and resource-limit errors remain terminal. Recovery does not validate the discarded body.

func (*Stream) Skip added in v0.10.0

func (s *Stream) Skip() error

Skip abandons an unread ZIP entry without decompressing or validating it. It also clears size-limit errors returned with an entry by Next. The entry counts toward MaxEntries, but its unread body does not consume expanded bytes. Other formats, missing entries and entries already read return ErrUnsupported. Cancellation, input limits and entry-count limits remain terminal.

type StreamEntry added in v0.8.1

type StreamEntry struct {
	FileInfo
	Linkname   string
	IsHardlink bool
}

StreamEntry includes link metadata needed to hash TAR links. ZIP symlink targets are stored in the entry body. Hard links refer to another TAR path.

type StreamOptions added in v0.8.1

type StreamOptions struct {
	MaxInputBytes    int64
	MaxEntryBytes    int64
	MaxExpandedBytes int64
	MaxEntries       int
}

StreamOptions limits entry bodies, including skipped entries. Zero values use 512 MiB for byte limits and 100,000 entries; negative values are invalid. Decoder buffers and archive metadata require additional memory.

Directories

Path Synopsis
Package diff provides utilities for comparing package versions.
Package diff provides utilities for comparing package versions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL