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
const KindFile Kind = iotaThe three shapes an archived entry takes on disk.
#KindDirectory
const KindDirectory#KindSymlink
const KindSymlink#ErrFileNotFound
var ErrFileNotFound = errors.New("file not found")Sentinel errors.
#ErrRecursiveRequired
var ErrRecursiveRequired = errors.New("target is a directory; recursive flag required")#ErrHashMismatch
var ErrHashMismatch = errors.New("hash mismatch after copy")#ErrEntryMissing
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
var ErrEntryCorrupt = errors.New("the archived copy is not what the record says it is")#ErrEntryDiverged
var ErrEntryDiverged = errors.New("the archived symlink entry does not name the target the record names")#ErrUnverifiable
var ErrUnverifiable = errors.New("the record carries no hash, so the archived copy cannot be checked before the destination is destroyed")#ErrSourceReplaced
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
var ErrArchiveEntryMissing = errors.New("the archived copy is gone, so nothing holds the content the removal would destroy")#ErrArchiveEntryReplaced
var ErrArchiveEntryReplaced = errors.New("the archive entry is no longer the file that was archived")#ErrSourceDiverged
var ErrSourceDiverged = errors.New("the source changed after it was hashed, and the archive holds an independent copy of the older content")#ErrArchivedContentChanged
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
var ErrDirectoryChanged = errors.New("the tree changed after it was archived, so the archive does not hold everything the removal would destroy")#ErrNotExecuted
var ErrNotExecuted = errors.New("the plan was never executed, so there is nothing to check the source against")#ArchiveResult
type ArchiveResult structArchiveResult holds the outcome of archiving a file or directory.
#Kind
type Kind intKind names what an archival is about to move.
#Plan
type Plan structPlan 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
type RestorePlan structRestorePlan 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
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
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
func RemoveSource(p *Plan) errorRemoveSource 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
func NamePaths(paths []string) stringNamePaths 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
func DiscardBlob(p *Plan) errorDiscardBlob 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
func NewRestorePlan(uuid string, archiveDir string, dest string, isDirectory bool, symlinkTarget string) *RestorePlanNewRestorePlan 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
func EntryPresent(p *RestorePlan) errorEntryPresent 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
func VerifyEntry(p *RestorePlan, recordedHash string) errorVerifyEntry 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.
#RestoreSymlink
func RestoreSymlink(p *RestorePlan) errorRestoreSymlink 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
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
func RollbackExtraction(created []string) []stringRollbackExtraction 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
func CopyOut(src string, dst string) errorCopyOut 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
func IsCrossDeviceError(err error) boolIsCrossDeviceError reports whether a failed rename means "these two paths are on different filesystems", which is what sends a file restore through [CopyOut].
#NewUUID
func NewUUID() stringNewUUID 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.