On this page
#saferm
AI-first safe rm replacement. Archives files instead of deleting them.
#CLI Reference
- All commands and options
- Machine surface -- the
--jsonenvelope, each verb's payload, and thecapabilitiesprobe
#Packages
saferm is organized into internal packages that handle distinct responsibilities: file and directory archival with integrity verification, SQLite-based metadata storage, git context detection, environment and process metadata capture, and reading the shared process trace store that answers which tool ran a deletion. Each package is independently testable and designed for concurrent use via WAL-mode SQLite and atomic file operations. Configuration is handled by strictcli's built-in config system (TOML format at ~/.saferm/config.toml).
#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.
#internal/db
Package db manages the SQLite database tracking all saferm deletions.
Concurrency safety across simultaneous sessions comes in three layers: WAL mode, SQLite's own busy_timeout, and a bounded retry on top of both -- every operation that meets SQLITE_BUSY or SQLITE_LOCKED is run again, up to five attempts with a 50ms linear backoff, reported through a RetryNotifier. Contention that outlives the whole budget is returned as a *ContentionError, a type distinct from every other database failure, so a caller can tell "another process holds the write lock, try again" from "this archive is broken".
#SchemaSQL
const SchemaSQL = `#ErrNotFound
var ErrNotFound = errors.New("record not found")ErrNotFound is returned when a queried record does not exist.
#ErrOriginVersionWithoutName
var ErrOriginVersionWithoutName = errors.New("origin_version requires origin_name")ErrOriginVersionWithoutName is returned by Insert when a record carries an origin version but no origin name.
SQLite cannot add a CHECK constraint to an existing table, so the invariant is enforced here instead of in the schema: one enforcement path, identical on a fresh database and on one that reached this schema through the migration ladder, and no table rebuild. The cost is stated rather than hidden -- a hand-edited database, or an older binary writing into a newer one, bypasses it.
#ErrOriginEmpty
var ErrOriginEmpty = errors.New("origin fields must be absent or non-empty")ErrOriginEmpty is returned by Insert when an origin field is present but empty. Both fields are nullable and never empty: an empty string would be a third state beside "a tool claimed this" and "none did", and nothing can read it as either.
#RetryNotifier
type RetryNotifier func(attempt, maxAttempts int, delay time.Duration, err error)RetryNotifier is called once before each contention retry, so a caller can report the wait under --verbose. It is never called for a failure that is not contention, and never for the final attempt (there is no wait after it).
#ContentionError
type ContentionError structContentionError reports that an operation was still meeting a locked database after the whole retry budget was spent. It is deliberately its own type: a caller that collapses it into a generic database failure loses the one piece of information that distinguishes "another process is busy, try later" from "this database is broken".
#DB
type DB structDB wraps a *sql.DB connection to the saferm SQLite database.
#DeletionRecord
type DeletionRecord structDeletionRecord represents a single archived deletion in the database.
#IsContention
func IsContention(err error) boolIsContention reports whether err is SQLITE_BUSY/SQLITE_LOCKED-class contention -- a lock held by another connection, which retrying can clear.
The driver reports a result code alongside the message, so the classification reads that code rather than matching on English. SQLite's extended codes carry the primary code in their low byte (SQLITE_BUSY_SNAPSHOT is SQLITE_BUSY | (2 << 8), and so on), so the low byte is what is compared -- every extended flavour of BUSY and LOCKED classifies with its primary.
#IsContentionExhausted
func IsContentionExhausted(err error) boolIsContentionExhausted reports whether err is a ContentionError -- contention that outlived the retry budget. It is what maps a database failure onto saferm's distinct contention exit code.
#Open
func Open(dbPath string, notify RetryNotifier) (*DB, error)Open opens (or creates) the SQLite database at dbPath with WAL mode and busy_timeout, then runs the schema DDL.
notify, when non-nil, is called before each contention retry -- for every operation on the returned DB as well as for the schema work below, which is why it is supplied here rather than set afterwards. Pass nil for no reporting.
#ContentionError.Error
func (e *ContentionError) Error() string#ContentionError.Unwrap
func (e *ContentionError) Unwrap() error { return e.Err }#DB.Close
func (d *DB) Close() errorClose closes the underlying database connection.
#DB.Insert
func (d *DB) Insert(rec *DeletionRecord) (int64, error)Insert inserts a DeletionRecord and returns the auto-increment ID.
The origin invariants are checked here, before anything is written: see ErrOriginVersionWithoutName for why they live in code rather than in the schema.
#DB.QueryByID
func (d *DB) QueryByID(id int64) (*DeletionRecord, error)QueryByID retrieves a single record by ID. Returns ErrNotFound if it does not exist.
#DB.QueryByUUID
func (d *DB) QueryByUUID(uuid string) (*DeletionRecord, error)QueryByUUID retrieves a single record by its archive uuid. Returns ErrNotFound if it does not exist.
The uuid is the identifier a record keeps: the numeric id is this database's autoincrement counter, while the uuid names the archived entry on disk and is what delete hands back to its caller.
#DB.QueryByPath
func (d *DB) QueryByPath(path string) ([]*DeletionRecord, error)QueryByPath returns all non-restored records matching the given original_path, ordered by deleted_at DESC (newest first).
#DB.QueryAll
func (d *DB) QueryAll(includeAll bool) ([]*DeletionRecord, error)QueryAll returns all records ordered by deleted_at DESC. If includeAll is false, restored and purged records are excluded.
#DB.MarkRestored
func (d *DB) MarkRestored(id int64, restoredTo string) errorMarkRestored sets restored_at to now and restored_to to the given path. Returns ErrNotFound if the record does not exist.
#DB.MarkPurged
func (d *DB) MarkPurged(id int64) errorMarkPurged sets purged_at to now, preserving the metadata record. Returns ErrNotFound if the record does not exist.
#DB.QueryOlderThan
func (d *DB) QueryOlderThan(before time.Time) ([]*DeletionRecord, error)QueryOlderThan returns all non-restored, non-purged records deleted before the given time.
#internal/git
Package git provides helpers for managing the git index alongside saferm's archive/restore operations.
#IsInGitRepo
func IsInGitRepo(dir string) boolIsInGitRepo returns true if dir is inside a git working tree.
#IsGitTracked
func IsGitTracked(path string) boolIsGitTracked returns true if the file at path is tracked by git (i.e., known to the index). The path must be absolute or relative to the current working directory; the command runs from the file's parent directory.
#GitRmCached
func GitRmCached(path string, recursive bool) errorGitRmCached stages the removal of path in the git index without touching the working tree (the file is already archived). When recursive is true, -r is added for directory removal.
#GitAdd
func GitAdd(path string) errorGitAdd stages a file in the git index. The command runs from the file's parent directory.
#internal/meta
Package meta collects rich metadata about each deletion: environment variables, git repository context, parent process information, and arbitrary user-supplied key-value pairs.
#Metadata
type Metadata structMetadata holds contextual information captured at deletion time.
#Collect
func Collect(excludePatterns []string, customMeta map[string]string) (*Metadata, error)Collect gathers metadata from the current environment.
Best-effort per collector -- git context, parent-process details and the process ancestry read from the trace store degrade to empty values, or to a recorded anomaly, rather than failing. The one thing it refuses to work around is an exclude pattern that does not compile: that is the caller asking for a redaction, and continuing without it would write the very variables the caller meant to keep out. See collectEnv.
#internal/trace
Package trace reads the strictcli process trace store, so a deletion can record who ran it.
The store is a shared, append-only JSONL record of process ancestry: at the seam where one command-line tool spawns another, the spawning invocation writes one line describing itself and hands that line's identifier to the child in STRICTCLI_TRACE_PARENT. saferm is a consumer, never a writer. It parses the variable and reads the store itself, per the normative specification (strictcli's docs/process-trace-store.md), because the framework deliberately exposes no accessor for the ancestry stack -- nothing in the framework may branch on data no framework code reads.
Everything here is observational. A capture cannot fail a deletion: an absent variable, a polluted one, a pruned store, a torn line and a parent that resolves to nothing are all legal states, each recorded as an anomaly and carried on from. Consumers noticing dangling parents is the store's primary failure-detection channel, which is why the anomalies are written into the record rather than discarded.
A capture resolves the FULL ancestry chain at capture time and keeps the flattened entries, not only their identifiers, so the record stays self-contained: age-based pruning of the store can never orphan it. The identifiers are kept alongside for correlation with whatever store data still exists.
#ParentEnv
const ParentEnv = "STRICTCLI_TRACE_PARENT"ParentEnv is the one variable ancestry travels through. It carries exactly one thing: the identifier of the entry describing the process that spawned this one.
#AnomalyMalformedParentValue
const AnomalyMalformedParentValue = "malformed-trace-parent"AnomalyMalformedParentValue: the environment variable was set to something that is not a canonical identifier. Recorded verbatim.
#AnomalyDanglingParent
const AnomalyDanglingParent = "dangling-parent"AnomalyDanglingParent: an identifier resolved to no entry -- the store was pruned or missing, the writer was another tool, or someone set the variable by hand. Legal by design, and the store's primary failure-detection channel.
#AnomalyMalformedEntry
const AnomalyMalformedEntry = "malformed-entry"AnomalyMalformedEntry: a line in a partition could not be read as an entry -- torn by a non-atomic write, missing one of the thirteen keys, or carrying an unparseable identifier.
#AnomalyStoreUnreadable
const AnomalyStoreUnreadable = "store-unreadable"AnomalyStoreUnreadable: a partition or the store directory could not be read at all. A store that does not exist is NOT this: that is an ordinary dangling parent.
#AnomalyChainCycle
const AnomalyChainCycle = "chain-cycle"AnomalyChainCycle: walking parent_id revisited an identifier. No store a conforming writer produces can contain one, since an entry's parent is always older than itself.
#AnomalyOversizedField
const AnomalyOversizedField = "oversized-entry-field"AnomalyOversizedField: a string an entry contributes to the embedded chain was longer than a chain entry may carry, and was truncated. The line was conforming -- nothing in the entry rules bounds a value's length -- so this is the consumer stating what it kept, not a complaint about the writer.
#AnomalyAnomaliesDropped
const AnomalyAnomaliesDropped = "anomalies-dropped"AnomalyAnomaliesDropped: the capture saw more anomalies than one record may carry. Synthetic, always last, and present only when something was dropped: it names how many, so a truncated anomaly list can never read as a complete one.
#Entry
type Entry structEntry is one line of the store: an invocation that spawned a child. Every key is always present in a conforming line, so an absent one makes the line malformed rather than defaulted.
#Anomaly
type Anomaly structAnomaly is something the capture saw and could not treat as well-formed.
#Capture
type Capture structCapture is what one deletion records about its ancestry.
Chain holds the flattened ancestry, nearest caller first, so the record stays readable after the store is pruned. ChainIDs is the same walk as bare identifiers, kept for correlation with whatever store data still exists.
#Collect
func Collect() *CaptureCollect resolves the ancestry of the running process from the store.
It returns nil when STRICTCLI_TRACE_PARENT is unset -- nothing claimed this invocation, which is not an anomaly and is the state every deletion is in until callers upgrade to a framework that writes the store.
#StoreDir
func StoreDir(home string) stringStoreDir is the store's literal path under home.
It is deliberately NOT derived from XDG_DATA_HOME or any other variable, despite matching the XDG default: a writer that honoured XDG_DATA_HOME and one that did not would write to two stores on the same machine, and a chain crossing them would dangle at both ends while both writers behaved correctly.
#Capture.Origin
func (c *Capture) Origin() (name, version *string)Origin is the immediate caller's declared name and version -- the two values a deletion records as its origin. Both are nil when nothing resolved, which is what "no tool claimed this" means.