• English
  • Go sync in Production: How Go Archive Primitives Become Safe Production Systems

    Go's archive/tar and archive/zip packages provide archive-format semantics, not application filesystem policies.

    They expose deliberately small primitives—io.Reader, io.Writer, archive headers, entry readers, and filesystem interfaces. The application remains responsible for the boundaries that determine whether archive data can safely become filesystem state:

    • Resource bounding: entry counts, per-entry sizes, and aggregate output must stay within explicit budgets.
    • Path boundaries: archive-name validation is distinct from filesystem path resolution.
    • Resource lifecycles: stateful writers require ordered, single-call finalization.
    • Filesystem policy: links, special files, permissions, and overwrites require explicit rules.
    • Failure isolation: partially written artifacts must not be mistaken for successfully published files.

    The central architectural distinction is:

    Archive packages define format semantics. io/fs defines filesystem capabilities. The application defines filesystem mutation policy.

    1. Archive Access Models: Sequential vs. Indexed

    The split between archive/tar and archive/zip reflects two fundamentally different I/O models.

    tar: sequential stream              zip: indexed archive
    
    ┌───────────────────────┐           ┌───────────────────────┐
    │ Header 1 | Content 1  │           │ File Data 1           │
    ├───────────────────────┤           ├───────────────────────┤
    │ Header 2 | Content 2  │           │ File Data 2           │
    ├───────────────────────┤           ├───────────────────────┤
    │ Header 3 | Content 3  │           │ Central Directory     │
    └───────────────────────┘           └───────────┬───────────┘
    
                                               ReaderAt + size

    archive/tar: Sequential Streaming

    tar.Reader consumes an io.Reader. Next advances through the archive, and the returned reader exposes the current entry's data. The reader does not require the archive's total size or a seekable input.

    tr := tar.NewReader(r)
    
    for {
        hdr, err := tr.Next()
        if err == io.EOF {
            break
        }
        if err != nil {
            return fmt.Errorf("read tar header: %w", err)
        }
    
        if err := processEntry(hdr, tr); err != nil {
            return err
        }
    }

    Tar therefore fits naturally into pipelines where the archive is produced and consumed as a stream:

    network ──► tar.Reader ──► application ──► storage

    Typical use cases include:

    • backups
    • container layers
    • IPC pipelines
    • large exports
    • network-to-storage transfers

    The important property is not "tar is smaller" or "tar is faster."

    It is:

    The access pattern is sequential.

    archive/zip: Indexed Access

    zip.Reader requires an io.ReaderAt and the archive size because ZIP's central directory is located at the end of the archive.

    zr, err := zip.NewReader(readerAt, totalSize)
    if err != nil {
        return fmt.Errorf("open zip: %w", err)
    }
    
    for _, f := range zr.File {
        if f.UncompressedSize64 > maxPerFileSize {
            continue
        }
    
        rc, err := f.Open()
        if err != nil {
            return fmt.Errorf("open %q: %w", f.Name, err)
        }
    
        if err := processEntry(f, rc); err != nil {
            rc.Close()
            return err
        }
    
        if err := rc.Close(); err != nil {
            return fmt.Errorf("close %q: %w", f.Name, err)
        }
    }

    ZIP is therefore useful when the application needs:

    • indexed entry metadata
    • selective extraction
    • independent entry access
    • desktop interoperability
    • per-entry compression

    The distinction is fundamental:

    tar → consume the archive as a stream
    zip → inspect an index, then open individual entries

    2. I/O Composition and Bounded-Memory Streaming

    Archive encoders and decoders deliberately compose with Go's io interfaces.

    A typical processing pipeline can be modeled as:

    HTTP Body
    
    
    gzip.Reader
    
    
    tar.Reader
    
    
    Policy Layer
    
    
    Filesystem

    The archive package does not need to know whether the bytes originate from:

    • a file
    • an HTTP request
    • object storage
    • a pipe
    • another process
    • a compressed stream

    Likewise, archive output can be sent to any io.Writer.

    Bounded-Memory Streaming Export

    A production archive generator should avoid materializing the entire archive in memory.

    func StreamBackup(w io.Writer, files []string) error {
        gw := gzip.NewWriter(w)
        tw := tar.NewWriter(gw)
    
        for _, name := range files {
            if err := addToTar(tw, name); err != nil {
                return err
            }
        }
    
        if err := tw.Close(); err != nil {
            return fmt.Errorf("close tar writer: %w", err)
        }
    
        if err := gw.Close(); err != nil {
            return fmt.Errorf("close gzip writer: %w", err)
        }
    
        return nil
    }
    
    func addToTar(tw *tar.Writer, name string) error {
        f, err := os.Open(name)
        if err != nil {
            return fmt.Errorf("open %q: %w", name, err)
        }
    
        info, err := f.Stat()
        if err != nil {
            f.Close()
            return fmt.Errorf("stat %q: %w", name, err)
        }
    
        hdr, err := tar.FileInfoHeader(info, "")
        if err != nil {
            f.Close()
            return fmt.Errorf("create header for %q: %w", name, err)
        }
    
        hdr.Name = filepath.ToSlash(name)
    
        if err := tw.WriteHeader(hdr); err != nil {
            f.Close()
            return fmt.Errorf("write header for %q: %w", name, err)
        }
    
        if _, err := io.Copy(tw, f); err != nil {
            f.Close()
            return fmt.Errorf("copy %q: %w", name, err)
        }
    
        if err := f.Close(); err != nil {
            return fmt.Errorf("close %q: %w", name, err)
        }
    
        return nil
    }

    The relevant property is bounded-memory streaming, not "zero allocation."

    The application does not construct:

    archiveBytes, err := io.ReadAll(...)

    and does not need a temporary file containing the entire archive.

    The data remains a stream:

    source
    
    
    tar.Writer
    
    
    gzip.Writer
    
    
    destination

    This is the same capability-oriented composition used throughout Go's io package.


    3. Archive State Boundaries and Finalization

    Archive writers are stateful encoders.

    NewWriter
    
    
    WriteHeader
    
    
    Write payload
    
    
    WriteHeader
    
    
    Write payload
    
    
    Close

    Header-Payload Invariant

    For tar, WriteHeader begins a new entry and subsequent writes belong to that entry. Header.Size determines the amount of entry data represented by the header.

    Conceptually:

    Header.Size = N
    
    Write ───────────────► N bytes
    
    
                        next entry

    The archive writer is therefore not an arbitrary byte sink.

    The application must respect the archive's state machine.

    Close Is Protocol Finalization

    Close is part of the output protocol.

    For ZIP, Writer.Close writes the central directory.

    For a compressed tar pipeline:

    tar.Writer
    
    
    gzip.Writer
    
    
    destination

    finalization must occur in reverse construction order:

    if err := tw.Close(); err != nil {
        return fmt.Errorf("close tar writer: %w", err)
    }
    
    if err := gw.Close(); err != nil {
        return fmt.Errorf("close gzip writer: %w", err)
    }

    Do not combine an explicit Close with an unconditional deferred Close merely to make cleanup convenient.

    This pattern is problematic:

    defer tw.Close()
    
    // ...
    
    if err := tw.Close(); err != nil {
        return err
    }

    The writer is closed twice, and the deferred error is discarded.

    For stateful encoders, finalization should have one clearly owned call site.


    4. Three Semantic Layers: Archive, Filesystem, Host Mutation

    An archive entry is not a filesystem operation.

    It becomes one only after the application crosses a policy boundary.

    ┌──────────────────────────────────────┐
    │ Archive Format Semantics             │
    │ tar / zip                            │
    └──────────────────┬───────────────────┘
    
    
    ┌──────────────────────────────────────┐
    │ Filesystem Capability Semantics      │
    │ io/fs                                │
    └──────────────────┬───────────────────┘
    
    
    ┌──────────────────────────────────────┐
    │ Host Filesystem Mutation             │
    │ os / filepath / permissions / links  │
    └──────────────────────────────────────┘

    This distinction prevents a common architectural mistake:

    archive header
    
    
    os.Create(filepath.Join(...))

    The archive parser knows what the entry says.

    It does not know whether your application should permit that operation.


    5. Untrusted Metadata and Bounded Resource Domains

    Archive metadata is external input.

    A valid archive can still violate an application's resource policy.

    The relevant dimensions include:

    Incoming Archive
    
          ├──► Entry count
          ├──► Declared entry size
          ├──► Actual output bytes
          ├──► Input bytes
          └──► Processing time

    Resource Budgets

    A production extractor should establish explicit budgets before consuming untrusted payloads.

    var ErrBudgetExceeded = errors.New("archive resource budget exceeded")
    
    type ResourceBudget struct {
        MaxEntries int
        MaxPerFile int64
        MaxTotal   int64
    
        entriesSeen int
        totalBytes  int64
    }
    
    func NewResourceBudget(entries int, perFile, total int64) (*ResourceBudget, error) {
        if entries <= 0 || perFile <= 0 || total <= 0 {
            return nil, errors.New("invalid resource budget")
        }
    
        return &ResourceBudget{
            MaxEntries: entries,
            MaxPerFile: perFile,
            MaxTotal:   total,
        }, nil
    }
    
    func (b *ResourceBudget) AdmitEntry(declaredSize int64) error {
        if b.entriesSeen >= b.MaxEntries {
            return fmt.Errorf("%w: entry count exceeds %d",
                ErrBudgetExceeded, b.MaxEntries)
        }
    
        if declaredSize < 0 {
            return fmt.Errorf("%w: negative entry size",
                ErrBudgetExceeded)
        }
    
        if declaredSize > b.MaxPerFile {
            return fmt.Errorf(
                "%w: entry size %d exceeds %d",
                ErrBudgetExceeded,
                declaredSize,
                b.MaxPerFile,
            )
        }
    
        if declaredSize > b.MaxTotal-b.totalBytes {
            return fmt.Errorf(
                "%w: aggregate output exceeds %d",
                ErrBudgetExceeded,
                b.MaxTotal,
            )
        }
    
        b.entriesSeen++
        return nil
    }

    The important distinction is:

    declared size
    
        ├── admission check
    
    
    actual bytes written
    
        └── aggregate accounting

    Archive metadata provides an early bound.

    Actual I/O provides the accounting source.

    Bounding the Stream

    When the declared size is not sufficient to establish a trusted bound, wrap the stream itself.

    func copyBounded(dst io.Writer, src io.Reader, max int64) (int64, error) {
        if max < 0 {
            return 0, errors.New("negative limit")
        }
    
        if max == math.MaxInt64 {
            return io.Copy(dst, src)
        }
    
        lr := io.LimitReader(src, max+1)
    
        n, err := io.Copy(dst, lr)
        if n > max {
            return max, fmt.Errorf(
                "%w: payload exceeds %d bytes",
                ErrBudgetExceeded,
                max,
            )
        }
    
        return n, err
    }

    The extra byte is a sentinel:

    limit = 100 MiB
    
    read 100 MiB       → accepted
    read 100 MiB + 1   → exceeded

    The math.MaxInt64 case avoids overflowing max+1.

    This pattern is broadly useful beyond archives:

    Untrusted input should enter a bounded resource domain before unrestricted consumption.

    Compressed Size Is Not Output Size

    ZIP makes this distinction especially important.

    small compressed input
    
    
    decompression
    
    
    large output

    A compressed archive can consume relatively little input bandwidth while producing a large amount of output.

    Therefore an extraction service should distinguish:

    • archive input size
    • compressed entry size
    • uncompressed entry size
    • total extracted bytes

    A per-entry limit alone is not sufficient. Aggregate limits are equally important.


    Path validation is only the first filesystem boundary.

    filepath.IsLocal Is String Validation

    Go's archive packages expose insecure-path checks, and filepath.IsLocal defines the relevant local-path concept. In Go 1.20, archive/tar and archive/zip added optional ErrInsecurePath reporting through the tarinsecurepath and zipinsecurepath GODEBUG settings. Go 1.27 retains these APIs and behaviors.

    But:

    filepath.IsLocal(name)

    only reasons about the path string.

    It does not prove that the host filesystem will resolve that path beneath the intended extraction root.

    archive entry
    
    
    filepath.IsLocal
    
    
    filepath.Join
    
    
    filesystem resolution
    
        ├── existing symlink
        ├── concurrent mutation
        └── permissions / mount boundaries

    For example:

    extract/
    └── config -> /etc

    Then:

    config/app.conf

    can pass a local-path check while resolving through an existing symlink.

    Therefore:

    Path validation is not filesystem resolution safety.

    For ordinary application extraction, a conservative policy is:

    allow:
        regular files
        directories
    
    reject by default:
        symbolic links
        hard links
        device nodes
        FIFOs
        other special entries

    For tar:

    switch hdr.Typeflag {
    case tar.TypeReg, tar.TypeRegA:
        // regular file
    
    case tar.TypeDir:
        // directory
    
    default:
        return fmt.Errorf(
            "unsupported archive entry type %d: %q",
            hdr.Typeflag,
            hdr.Name,
        )
    }

    A backup or system-image tool may require links and special files.

    An application upload service usually does not.

    The archive format's capability set should not automatically become the application's permission set.

    Permissions Are Policy

    Archive metadata can contain permissions that are inappropriate for the destination environment.

    Do not blindly restore:

    setuid
    setgid
    sticky
    world-writable

    unless the application's contract explicitly requires them.

    A safer default for ordinary application data is to apply application-defined modes rather than reproducing archive metadata verbatim.

    Duplicate Names

    Duplicate entries also need an explicit policy:

    file.txt
    file.txt

    Possible semantics include:

    • reject duplicates
    • first entry wins
    • last entry wins
    • allow overwrites

    For security-sensitive extraction, rejection is generally the easiest policy to reason about.


    7. Failure Isolation and Atomic Publication

    Writing directly to the final destination creates partially published state.

    archive
    
    
    final/file.bin
    
       ├── write 40%
       └── failure

    The result is a file that exists but does not represent a complete archive entry.

    A stronger design separates:

    write
    
    validate
    
    close
    
    publish

    For an individual file:

    Archive Entry
    
    
    Temporary File
    
    
    Sync / Close
    
    
    Rename
    
    
    Published File

    Per-Artifact Atomic Publication

    func extractFileAtomic(
        dstDir, name string,
        src io.Reader,
        maxBytes int64,
    ) error {
        if !filepath.IsLocal(name) {
            return fmt.Errorf("insecure archive path: %q", name)
        }
    
        target := filepath.Join(dstDir, filepath.FromSlash(name))
        parent := filepath.Dir(target)
    
        if err := os.MkdirAll(parent, 0755); err != nil {
            return fmt.Errorf("create parent directory: %w", err)
        }
    
        f, err := os.CreateTemp(parent, ".extract-*")
        if err != nil {
            return fmt.Errorf("create temporary file: %w", err)
        }
    
        tmpName := f.Name()
        committed := false
    
        defer func() {
            if !committed {
                _ = f.Close()
                _ = os.Remove(tmpName)
            }
        }()
    
        n, err := copyBounded(f, src, maxBytes)
        if err != nil {
            return fmt.Errorf("write %q: %w", name, err)
        }
    
        if n > maxBytes {
            return fmt.Errorf("file %q exceeds size limit", name)
        }
    
        if err := f.Sync(); err != nil {
            return fmt.Errorf("sync %q: %w", name, err)
        }
    
        if err := f.Close(); err != nil {
            return fmt.Errorf("close %q: %w", name, err)
        }
    
        if err := os.Chmod(tmpName, 0644); err != nil {
            return fmt.Errorf("chmod %q: %w", name, err)
        }
    
        if err := os.Rename(tmpName, target); err != nil {
            return fmt.Errorf("publish %q: %w", name, err)
        }
    
        committed = true
        return nil
    }

    The temporary file is created in the destination directory so that the rename does not require a cross-filesystem move.

    This provides failure isolation and per-artifact atomic publication.

    It does not mean that an entire multi-file extraction is transactional.

    Per-File Atomicity Is Not Archive-Level Atomicity

    Suppose an archive contains:

    a.txt
    b.txt
    c.txt

    and extraction publishes each file independently.

    If c.txt fails:

    a.txt → published
    b.txt → published
    c.txt → missing

    The operation is not transactional.

    If the application requires all-or-nothing publication, use a staging-tree design:

    archive
    
    
    temporary extraction tree
    
       ├── validate
       ├── enforce budgets
       └── complete successfully
    
    
         publication step

    The exact publication strategy depends on the target operating system, existing destination state, filesystem semantics, and durability requirements.

    Do not describe ordinary per-file os.Rename calls as "atomic extraction."

    They provide atomic publication for individual filesystem objects.

    Durability Is a Separate Property

    Sync strengthens persistence of file contents before publication:

    write
    
    Sync
    
    Close
    
    Rename

    But durable crash recovery can also depend on directory metadata persistence and filesystem-specific semantics.

    Therefore:

    Atomic publication and crash durability are separate properties.

    A system that requires strong durability guarantees must explicitly define those guarantees for its supported operating systems and filesystems.


    8. Capability Conversion: archive/zip and io/fs

    The integration between archive/zip and io/fs is one of the most useful architectural features of the package.

    The key relationship is:

    archive/zip provides format semantics; io/fs provides filesystem capabilities.

    zip.Reader implements the fs.FS interface, allowing downstream code to consume a ZIP archive through generic filesystem APIs. ZIP also provides Writer.AddFS, which adds an fs.FS tree to an archive. AddFS was added in Go 1.22.

    The architecture becomes:

                      archive/zip
    
    
                         fs.FS
    
           ┌───────────────┼───────────────┐
           ▼               ▼               ▼
       fs.Open         fs.WalkDir       fs.Sub
    
    
     application code

    The application does not need to know that the filesystem happens to be backed by ZIP.

    Consume a ZIP as fs.FS

    func InspectConfigTree(zr *zip.Reader) error {
        var fsys fs.FS = zr
    
        configFS, err := fs.Sub(fsys, "config")
        if err != nil {
            return fmt.Errorf("select config tree: %w", err)
        }
    
        return fs.WalkDir(configFS, ".", func(
            path string,
            d fs.DirEntry,
            err error,
        ) error {
            if err != nil {
                return err
            }
    
            if d.IsDir() {
                return nil
            }
    
            f, err := configFS.Open(path)
            if err != nil {
                return fmt.Errorf("open %q: %w", path, err)
            }
            defer f.Close()
    
            return processConfig(path, f)
        })
    }

    This version deliberately uses fs.Open rather than fs.ReadFile.

    The latter materializes the entire file:

    data, err := fs.ReadFile(configFS, path)

    That may be perfectly appropriate for a small configuration file, but a streaming-oriented reference should not introduce unnecessary materialization when an fs.File is sufficient.

    Convert an fs.FS into ZIP

    func ArchiveFileSystem(w io.Writer, fsys fs.FS) error {
        zw := zip.NewWriter(w)
    
        if err := zw.AddFS(fsys); err != nil {
            _ = zw.Close()
            return fmt.Errorf("add filesystem to zip: %w", err)
        }
    
        if err := zw.Close(); err != nil {
            return fmt.Errorf("finalize zip: %w", err)
        }
    
        return nil
    }

    The resulting architecture is symmetrical:

    filesystem capability
    
    
         ZIP writer
    
    
          archive
    
    archive
    
    
     ZIP reader
    
    
    filesystem capability

    This is capability-oriented design rather than format-specific application logic.


    9. Production Rules

    1. Treat archive metadata as untrusted input

    Headers describe what the archive contains. They do not grant permission to mutate the host filesystem.

    2. Separate format validity from filesystem policy

    A structurally valid archive can still violate application security or resource requirements.

    3. Distinguish path validation from filesystem resolution

    Use local-path validation for archive names, but do not treat filepath.IsLocal as protection against symlink traversal or filesystem races.

    4. Define an explicit resource budget

    At minimum, consider:

    maximum archive input
    maximum entry count
    maximum per-entry output
    maximum aggregate output
    maximum processing time
    maximum concurrent extractions

    5. Bound the actual data stream

    Metadata checks are admission controls. io.LimitReader and actual byte accounting provide a second enforcement boundary.

    6. Keep stateful writer finalization explicit

    Call archive and compression writer Close methods exactly once and in reverse construction order when finalization errors matter.

    7. Prefer bounded-memory streaming

    Do not materialize an entire archive merely because a byte slice is convenient.

    8. Deny filesystem capabilities by default

    Allow only the entry types and metadata required by the application.

    9. Publish files atomically

    Write to an adjacent temporary file, complete the payload, and publish it with os.Rename.

    10. Do not confuse atomic publication with transactional extraction

    Per-file atomic rename does not make a multi-file extraction all-or-nothing.

    11. Treat durability separately from atomicity

    Sync, Close, Rename, and directory durability have different semantics.

    12. Prefer io/fs for filesystem-shaped application logic

    When the application only needs filesystem capabilities, depend on fs.FS rather than archive-specific types.


    Final Perspective

    The production value of Go's archive packages is not their ability to create .tar or .zip files.

    It is the way they expose format primitives that compose with the rest of the standard library.

    The resulting architecture has three distinct layers:

                     Archive Format
                     tar / zip
    
    
                 Format Semantics
    
    
                      io/fs
                 Filesystem Capability
    
    
                 Application Policy
    
              ┌──────────┼──────────┐
              ▼          ▼          ▼
           Resource    Security   Mutation
            Budget      Rules      Rules

    An archive entry is metadata plus a byte stream.

    io determines how that stream moves.

    io/fs provides a filesystem-shaped capability.

    os and filepath perform host filesystem operations.

    The application decides whether those operations are allowed.

    That separation is the key to using archive/tar and archive/zip safely in production.

    The most important engineering rule is therefore:

    An archive parser tells you what the input means. It does not tell you what your system is allowed to do with it.