Astrolabe / ajxpublic /korvus
Repository

Blob Storage Backend and Key Rules

reference scope internal/blob Verification failed
  • internal/blob/blob.go
  • internal/blob/fs.go
  • internal/blob/keys.go
  • FS
  • Presigner
  • ValidateKey
  • NewFS
Relevant source files · 5

internal/blob is the storage seam for artifact bytes in Korvus: report markdown, session attachments, workspace archives. The package exists so that object storage never gates the vertical slice — the interface is defined now, with a filesystem implementation, and the S3-compatible client (Garage, AJX-309) is meant to swap in behind the same three methods later blob.go.

The division of responsibility is deliberately narrow. Blob storage owns the bytes; PostgreSQL owns the keys and metadata. Neither is authoritative for the other, which is why this package has no listing operation at all: you enumerate artifacts rows in PostgreSQL, not the bucket blob.go. A key is therefore only ever meaningful alongside the row that records it. The broader store-and-metadata contract is described in Storage Domain Model and Store Interfaces.

The seam: three methods and one optional capability

The contract is deliberately three methods and no more — Put, Get, Delete Store. Listing, copying and metadata belong to PostgreSQL; adding them here would turn the bucket into a second source of truth for what exists.

Put is atomic from a reader's point of view: a Get returns either the complete previous object or the complete new one, never a partial write blob.go. Delete of a key that does not exist is not an error, matching S3 DeleteObject and making retried cleanup idempotent blob.go.

Presigned URLs are deliberately not on Store. A filesystem backend cannot produce one, so putting it on the interface would force every implementation to carry a method one of them can only fail. It lives on a separate interface, Presigner, that consumers reach through a type assertion and fall back to streaming through the API when the assertion fails Presigner.

Two sentinels make the difference between "no such artifact" and "storage is broken" observable without importing a backend's package: ErrNotFound for a missing object and ErrInvalidKey for a key that is unsafe on every backend blob.go.

The filesystem backend

FS is a Store backed by a directory on disk, holding the absolute root path and an os.Root handle FS. Every operation goes through that root handle, so containment is enforced by the kernel on each path component rather than argued from the shape of the key.

NewFS creates the directory if it is missing, resolves it to an absolute path once, and opens the root handle once, pinning the directory that was logged at startup. Re-opening per operation would silently follow a root that had been replaced underneath the process NewFS. A root that is itself a symlink is followed and accepted — the root is the boundary, so where an operator puts it is their business — but a symlink inside the root that leads out of it is refused.

Put writes to a temporary file in the destination directory and renames it over the target, which buys atomicity and destination symlink safety, since rename(2) replaces the final component itself rather than writing through it FS::Put. The temp file is created in the destination directory rather than os.TempDir because a cross-filesystem rename fails with EXDEV. The temporary name is claimed with O_EXCL, so the collision check rather than the randomness is authoritative and a losing racer retries FS::createTemp.

Get opens the file and, because a key may legitimately name a directory created by an earlier Put of a child key, stats it: a non-regular file is reported as ErrNotFound, matching what S3 would say about the same key FS::Get.

Delete uses Lstat rather than Stat — a directory is a container this backend created, never an object — and does not follow a final symlink, so a link inside the root is unlinked without touching what it points at. Emptied parent directories are deliberately left behind, because pruning them races with a concurrent Put FS::Delete.

Containment is enforced, not validated

This distinction is the heart of the package. ValidateKey rejects absolute keys, backslashes and every . or .. element, so a key cannot leave the root lexically — but that is all a string can promise. Lexical safety says nothing about where the kernel lands when it walks the path, and a symlink's escape cannot be spelled in the string at all. If <root>/reports is a link elsewhere, then reports/inc-1/sess-1.md is a perfectly well-formed key that resolves outside the root FS::resolve.

So every operation goes through the os.Root handle, which refuses any component that would leave the root, and the prefix check in resolve remains only an assertion that ValidateKey and the join have not drifted apart.

When the kernel refuses a path it does so with an unexported sentinel wrapped in a *PathError, which would make errors.Is(err, ErrInvalidKey) false — exactly the confusion the sentinels exist to prevent. On the error path only, the backend re-walks the path's deepest existing ancestor and reports ErrInvalidKey when it resolves outside the root FS::explain. That check races with a concurrent symlink creation, which is acceptable because it decides only the wording: containment was already enforced before this point FS::escapesViaSymlink.

Key layout and derivation

Every key is derived, never taken from user input, and the prefixes are stable because they become directory names under a filesystem backend and shared key prefixes under an object-storage one keys.go:

Key Shape
Report reports/<incidentID>/<sessionID>/<revision>.md
Artifact artifacts/<incidentID>/<sessionID>/<artifactID>
Incident-level artifact artifacts/<incidentID>/_incident/<artifactID>
Workspace archive workspaces/<incidentID>/<sessionID>/<revision>.tar.gz

Incident comes first and session second because every listing question the system asks is incident-scoped, so an incident's bytes are one contiguous range on any backend. The _incident segment stands in for the session of an incident-level artifact; a literal marker beats omitting the segment, because with the segment omitted an artifact ID would occupy the position a session ID occupies elsewhere and the depth of a key would no longer tell you what it is keys.go.

The three constructors are pure functions: ReportKey includes the revision so a reopened session's next document never overwrites an earlier one ReportKey; ArtifactKey substitutes _incident when the session ID is empty ArtifactKey; WorkspaceArchiveKey produces the archived scratch tarball WorkspaceArchiveKey. All of them validate their ID components first.

ValidateIDComponent is stricter than ValidateKey because an ID is an opaque identifier, not a path: it may not contain a separator at all. The accepted alphabet is [A-Za-z0-9._-], which covers every UUID v7 the system generates, and a leading underscore is reserved for internal segments such as _incident so a derived segment can never be forged by an ID ValidateIDComponent.

The key rules

ValidateKey reports whether a key is safe as both a filesystem path relative to a root and an S3 object key. Every backend calls it before touching storage ValidateKey.

Rule The failure it prevents
Non-empty, no empty element a//b is one path on a filesystem and a different, legal key on S3; the backends would disagree about identity
No leading / Absolute on a filesystem; a legal but distinct key on S3
No trailing / Names a directory on a filesystem, and an S3 zero-byte folder marker rather than an object
No . or .. element Traversal
No \ A path separator on Windows, and a legal literal in an S3 key, so one key would name two different things
No control characters or DEL Unrepresentable or hostile in both, and a newline corrupts any line-oriented listing
At most 1024 bytes S3's hard key limit keys.go
No element over 255 bytes The filesystem's NAME_MAX limit, which S3 does not share keys.go

The last two limits are independent: a key can satisfy either while violating the other, which is precisely the cross-backend divergence the function exists to prevent.

The leading- and trailing-separator rules are redundant as verdicts — both split to an empty element, which the element loop rejects anyway. They are kept for their message: an operator reading "must not start with a separator" knows what to change, where "contains an empty element" describes only a symptom ValidateKey.

Error handling

Condition Result
Missing object ErrNotFound, wrapped with the key quoted
Key rejected by ValidateKey ErrInvalidKey
Path refused by the root handle Re-worded as ErrInvalidKey by explain when a symlink is the cause
Delete of a non-existent key Success
Context cancelled mid-Put The copy aborts on the next read; io.Copy has no context, so a contextReader checks per read contextReader::Read

Keys quoted into error messages are truncated to 80 bytes, because a 1 KiB key in a log line helps nobody truncate.

Relationships

graph TD
    main["buildBlobStore"] --> NewFS
    NewFS --> FS
    FS -.->|"implements"| Store
    FS --> ValidateKey
    Presigner -.->|"optional capability"| Store
    ReportKey --> ValidateIDComponent
    ArtifactKey --> ValidateIDComponent
    WorkspaceArchiveKey --> ValidateIDComponent
    validateIDs --> ValidateIDComponent
    keysgo["internal/blob/keys.go"] --> ValidateKey
    fsgo["internal/blob/fs.go"] --> FS
    blobgo["internal/blob/blob.go"] --> Presigner

The process wiring that chooses a backend lives in korvus CLI and Process Wiring: buildBlobStore returns a session.BlobStore interface, and blob.path selects the filesystem backend, while blob.endpoint is accepted by configuration but reported as unimplemented until AJX-309 buildBlobStore. The consumer-side interface is declared by the session package, which is where it belongs BlobStore; the configuration keys are described in Configuration Loading and Validation.

Reference

Symbol Kind What it does Location
Store interface The three-method storage contract every backend satisfies internal/blob/blob.go:31
Presigner interface Optional capability: time-limited direct download URL internal/blob/blob.go:69
ErrNotFound / ErrInvalidKey sentinels Distinguish "no such object" from "broken storage" internal/blob/blob.go:72
FS struct Filesystem-backed Store over an os.Root handle internal/blob/fs.go:32
NewFS function Creates the root directory and opens the pinned root handle internal/blob/fs.go:55
FS::Put method Temp-file write, sync, then atomic rename into place internal/blob/fs.go:109
FS::Get method Opens an object; non-regular files report ErrNotFound internal/blob/fs.go:174
FS::Delete method Removes an object idempotently; directories are left alone internal/blob/fs.go:213
FS::resolve method Validates a key and joins it under the root internal/blob/fs.go:257
FS::explain method Re-wraps a root-handle refusal as ErrInvalidKey internal/blob/fs.go:284
FS::escapesViaSymlink method Walks down from the root, following links, to spot an escape internal/blob/fs.go:294
ReportKey function Derives reports/<incident>/<session>/<revision>.md internal/blob/keys.go:66
ArtifactKey function Derives `artifacts//<session _incident>/`
WorkspaceArchiveKey function Derives workspaces/<incident>/<session>/<revision>.tar.gz internal/blob/keys.go:97
ValidateIDComponent function Restricts an opaque ID to [A-Za-z0-9._-], no leading underscore internal/blob/keys.go:132
ValidateKey function Enforces the cross-backend key rules internal/blob/keys.go:206

Generated by astrolabe from commit 0c2dca73 on branch main.