Skip to content
internal/archive
Edit
On this page

API reference for the archive package — file and directory archival with hard links, copy-and-verify where links are refused, and tar+zstd compression.

#internal/archive

#internal/archive

Package archive handles file and directory archival to the saferm archive. It supports same-filesystem hard links, copy-and-verify wherever a link is refused, and tar+zstd compression for directories.

Archival is two calls, not one: [Execute] writes the archive entry and leaves the original alone, [RemoveSource] removes the original afterwards, and [DiscardBlob] takes the entry back. The gap between them is where a caller records the deletion, so an archive entry never exists without a way to find it.

#KindFile

Go go
const KindFile Kind = iota

The three shapes an archived entry takes on disk.

#KindDirectory

Go go
const KindDirectory
Go go
const KindSymlink

#ErrFileNotFound

Go go
var ErrFileNotFound      = errors.New("file not found")

Sentinel errors.

#ErrRecursiveRequired

Go go
var ErrRecursiveRequired = errors.New("target is a directory; recursive flag required")

#ErrHashMismatch

Go go
var ErrHashMismatch      = errors.New("hash mismatch after copy")

#ErrEntryMissing

Go go
var ErrEntryMissing  = errors.New("the archived copy is not in the archive")

What a restore can find wrong with an archived copy before it touches anything at the destination. See [VerifyEntry] for what each kind's hash does and does not prove.

#ErrEntryCorrupt

Go go
var ErrEntryCorrupt  = errors.New("the archived copy is not what the record says it is")

#ErrEntryDiverged

Go go
var ErrEntryDiverged = errors.New("the archived symlink entry does not name the target the record names")

#ErrUnverifiable

Go go
var ErrUnverifiable  = errors.New("the record carries no hash, so the archived copy cannot be checked before the destination is destroyed")

#ErrSourceReplaced

Go go
var ErrSourceReplaced         = errors.New("the source path no longer names the file that was archived")

The ways a source can stop being what was archived while the caller is recording the deletion. See [RemoveSource] for why that window is wide enough to matter and what each of these means for the record.

#ErrArchiveEntryMissing

Go go
var ErrArchiveEntryMissing    = errors.New("the archived copy is gone, so nothing holds the content the removal would destroy")

#ErrArchiveEntryReplaced

Go go
var ErrArchiveEntryReplaced   = errors.New("the archive entry is no longer the file that was archived")

#ErrSourceDiverged

Go go
var ErrSourceDiverged         = errors.New("the source changed after it was hashed, and the archive holds an independent copy of the older content")

#ErrArchivedContentChanged

Go go
var ErrArchivedContentChanged = errors.New("the source was written through while its archive entry was a link to it, so the recorded hash no longer describes the archived bytes")

#ErrDirectoryChanged

Go go
var ErrDirectoryChanged       = errors.New("the tree changed after it was archived, so the archive does not hold everything the removal would destroy")

#ErrNotExecuted

Go go
var ErrNotExecuted            = errors.New("the plan was never executed, so there is nothing to check the source against")

#ArchiveResult

Go go
type ArchiveResult struct

ArchiveResult holds the outcome of archiving a file or directory.

#Kind

Go go
type Kind int

Kind names what an archival is about to move.

#Plan

Go go
type Plan struct

Plan is everything an archival can determine by reading: what the entry is, where it will land, and (for a symlink) what it points at. Building one mutates nothing, so a caller can render a plan as a preview and stop, or hand it to [Execute] and go through with it.

#RestorePlan

Go go
type RestorePlan struct

RestorePlan is everything a restore can determine by reading: which archive entry holds the record's content, what shape that entry is, and where it is going. Building one mutates nothing.

A restore is split the way an archival is, and for the same reason: the acts that consume the archived copy must be separable from the acts that write the destination, so that a failure can always leave the copy where it is. Every primitive below is one of those halves, and none of them decides anything -- what to do about a destination that already exists, and whether the entry is verified first, are the caller's decisions.

#NewPlan

Go go
func NewPlan(path string, archiveDir string, isRecursive bool) (*Plan, error)

NewPlan inspects path and resolves where archiving it would put it. It performs no mutation.

#Execute

Go go
func Execute(p *Plan) (*ArchiveResult, error)

Execute writes the archive entry a [Plan] describes, and LEAVES THE SOURCE WHERE IT IS.

Archiving is deliberately split from removing the original, because between the two there is a third party: the caller's database, which is what makes an archived entry findable at all. Doing it in one step meant an archive that succeeded and a record that failed produced a blob nobody could name, a source path that was gone, and no way back -- and the live archive really did collect orphaned blobs that way. So the order is Execute, record, then [RemoveSource]; a record that fails calls [DiscardBlob] instead and the caller's file has never been touched.

For a regular file the entry is a hard link to the original, which is why leaving the source in place costs nothing: the content is not copied and both names point at the same inode until RemoveSource drops one of them.

#RemoveSource

Go go
func RemoveSource(p *Plan) error

RemoveSource removes the original an executed [Plan] archived. It is the second half of an archival and runs only once the entry is recorded.

It removes by identity, not by name, and only once it has seen the archived copy. Between [Execute] and this call sits the caller's database insert, and that is not an instant: a contended SQLite write retries for tens of seconds, and both the source path and the archive entry are live filesystem paths the whole time. Three things can happen in there, and removing whatever the name happens to resolve to gets all three wrong:

- The path can be REPLACED -- renamed over, or removed and recreated. The archive holds the original; the name now leads somewhere else, and removing it would destroy a file nothing archived. - A regular file's archive entry is a hard link, so a write THROUGH the path mutates the archived bytes. The recorded hash then describes content that no longer exists anywhere, and removing the source would leave that record standing over a blob it does not match. - The ENTRY can go, or stop being the archived thing. The row is inserted before this runs, so a concurrent purge can select it and destroy its blob perfectly legitimately; removing the source afterwards leaves no copy of the content anywhere at all.

So both sides are re-checked first and the removal is refused on any mismatch, with [ErrSourceReplaced], [ErrSourceDiverged], [ErrArchivedContentChanged], [ErrArchiveEntryMissing] or [ErrArchiveEntryReplaced] naming which one the caller is holding. Nothing is undone here: refusing to remove is the whole of the remedy this half can apply, and what to do about the record is the recording caller's decision.

#NamePaths

Go go
func NamePaths(paths []string) string

NamePaths renders the paths a refusal is about. All of them up to a handful, because the caller has to go and look at them, and a count after that so a tree that changed wholesale does not print itself into the terminal.

#DiscardBlob

Go go
func DiscardBlob(p *Plan) error

DiscardBlob removes the archive entry [Execute] wrote, undoing it. The source is untouched by both calls, so a discarded archival leaves the filesystem exactly as it was.

#NewRestorePlan

Go go
func NewRestorePlan(uuid string, archiveDir string, dest string, isDirectory bool, symlinkTarget string) *RestorePlan

NewRestorePlan resolves where a record's content lives and where it is going. The kind is read off the record, not off the archive: a record knows whether it archived a tree and what a symlink pointed at, and both facts must be available before anything is read from disk.

#EntryPresent

Go go
func EntryPresent(p *RestorePlan) error

EntryPresent reports whether the archived copy is there to be restored at all. It is a stat, not a read: every restore makes this check, including the ones that deliberately do no verification, because an absent entry is worth naming as such rather than surfacing as a failed rename of a UUID.

#VerifyEntry

Go go
func VerifyEntry(p *RestorePlan, recordedHash string) error

VerifyEntry checks the archived copy against what the record says about it, reading only -- so a caller can refuse a destructive restore BEFORE the destination is touched.

The recorded hash means three different things, one per kind, and this is the only place that states all three honestly:

- KindFile: recordedHash is the SHA-256 of the archived file's CONTENT, and the entry is that file. The check is exact: a byte that rotted in the archive is found here. - KindDirectory: recordedHash is the SHA-256 of the .tar.zst CONTAINER, not of any member and not of the tree. So this proves the container arrived intact -- which is what a corrupt or truncated archive fails -- and says nothing about individual members beyond that. There is no per-member digest anywhere in the archive, so no check here can promise one. - KindSymlink: nothing was hashed at all. A symlink has no content; its entry is the recorded target written out, and recordedHash is empty by construction. The check is therefore an equality: the entry must still name the target the record names. A hash comparison here would fail spuriously on every symlink ever archived.

A file or a tree whose record carries no hash cannot be verified at all, and that is [ErrUnverifiable] rather than a pass: the caller asked to destroy a destination on the strength of a check that cannot be made.

Go go
func RestoreSymlink(p *RestorePlan) error

RestoreSymlink recreates the link at the plan's destination. It does NOT consume the entry: the caller removes it once the link is there, so a failure leaves the recorded target on disk.

#ExtractTree

Go go
func ExtractTree(p *RestorePlan) ([]string, error)

ExtractTree extracts the tree held in the plan's entry into its destination and does NOT consume the entry, for the same reason as [RestoreSymlink]: an extraction that fails partway must leave the archived copy readable, so the restore can simply be run again.

It returns every path it created, in creation order, whether it succeeded or not -- that list is what makes a partial extraction reportable and undoable.

#RollbackExtraction

Go go
func RollbackExtraction(created []string) []string

RollbackExtraction removes the paths a failed extraction created, newest first, and returns the ones it could not remove.

The destination of a restore holds nothing but archive-derived bytes: it was absent, or an empty directory, or removed outright by an overwrite that verified the archived copy first. So undoing a partial extraction destroys nothing that is not still in the archive -- the entry is consumed only after the extraction succeeds. Leaving the half tree instead would leave a destination that looks restored and is not, and a retry would then meet its own leftovers as a conflict.

Directories go through os.Remove, not os.RemoveAll: reverse order empties them first, and one that is still not empty holds something this extraction did not write. That is exactly what must survive, so it is reported as stuck rather than destroyed.

#CopyOut

Go go
func CopyOut(src string, dst string) error

CopyOut is the cross-device half of a file restore: the archived copy is copied to dst and consumed only once the copy is complete.

A rename cannot cross a filesystem boundary, and the archive and the destination are not always on one. The order is what keeps the failure safe: a copy that fails leaves the entry untouched and takes its own partial destination back, so nothing is left looking restored and the restore can be run again.

#IsCrossDeviceError

Go go
func IsCrossDeviceError(err error) bool

IsCrossDeviceError reports whether a failed rename means "these two paths are on different filesystems", which is what sends a file restore through [CopyOut].

#NewUUID

Go go
func NewUUID() string

NewUUID returns a UUID v4 string using crypto/rand.

It names an archive entry, and it is also what mints the group identifier a delete invocation stamps on every record it writes: both are opaque handles minted with no coordination between processes, so they are the same thing.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search