This roadmap defines the planned `internal/storage` contract for the `distributor` MVP. The goal is to give bundle validation, destination state inspection, publish planning, and backend adapters one consistent IO boundary without leaking local filesystem, SSH/SFTP, or S3-specific behavior into core packages.
## Purpose and Invariants
The storage layer is responsible for safe, backend-rooted access to files, objects, prefixes, and destination bundle paths.
Core invariants:
- Backends are opened at configured roots.
- Core packages operate on backend-rooted logical paths, not absolute filesystem paths or raw object keys.
- Backend adapters translate native storage behavior into common storage entries and typed errors.
- Destructive operations remain narrow and managed.
- Staging or atomic write behavior belongs behind the storage interface where practical.
- The fake backend exists for tests only and must not be registered as a runtime backend.
## Logical Path Model
Storage paths are slash-separated logical paths relative to an already configured backend root.
Prefix paths use the same slash-separated model. A prefix may be empty to represent the backend root for traversal and destination emptiness checks.
Prefix matching must preserve logical path boundaries. A prefix of `foo` matches `foo` and entries below `foo/`; it must not match a sibling path such as `foobar`. Backends that map logical paths to object keys must apply the same normalized boundary rule after combining configured backend prefixes with caller-provided logical prefixes.
Backends own conversion from logical paths to native paths or object keys. Core packages should not construct local filesystem paths, SFTP paths, or S3 object keys directly.
## Core Interface Shape
The MVP should use a hybrid byte and stream interface:
Byte helpers are expected to cover manifests, destination state, small source artifacts, and generated outputs. Stream methods are included from the start for backend flexibility and larger future artifacts.
Write operations should create required parent directories or prefixes as needed.
`Stat` returns metadata for one exact logical path. It may report a real filesystem directory, symlink, file, or exact object. It must not synthesize S3-like directory metadata solely because objects exist below a prefix; callers that need prefix existence or destination emptiness must use `HasAny` or `Walk`.
If a callback returns `ErrStopWalk`, traversal stops successfully and `Walk` returns nil. Any other callback error stops traversal and is returned with storage context where practical. If `WalkOptions.Limit` is greater than zero, reaching the limit stops traversal successfully.
Backends may stream or paginate traversal internally. S3-compatible adapters should not need to load a whole prefix into memory to satisfy traversal.
Raw traversal is not required to be lexically sorted. A helper that materializes walk results for bundle discovery, tests, or CLI output should sort entries lexically by logical path before returning them.
`HasAny` reports whether at least one entry exists below a prefix. It should stop as soon as content is found.
Source validation must reject symlink entries reported by local `Stat` or `Walk`.
## Read Behavior
`ReadFile` reads the whole object into memory and is appropriate for MVP manifest, state, and ordinary artifact handling.
`OpenReader` returns a stream for callers that need to copy or hash content without requiring a second storage-specific API. Callers must close the returned reader.
Both read methods must:
- validate logical paths before backend access;
- reject directories, prefixes, symlinks, and unsupported entries;
- return typed not-found and invalid-path errors where applicable.
## Write Behavior
`WriteOptions` should include:
- content type, when the destination backend can use it;
Remote adapters may buffer or spool `WriteFrom` input when needed to satisfy backend requirements such as content length, multipart upload, or retry behavior. Callers that know the stream size should set `SizeKnown` and `Size`.
If overwrite is false and the target exists, writes should fail with an already-exists error.
`WriteFile` and `WriteFrom` should return the written `Entry`, including final path and size where available.
`DeleteOptions` should include:
- whether missing managed output paths are ignored;
- whether empty parent directories may be pruned for filesystem-like backends.
## Managed Deletion
The storage interface should expose a guarded managed deletion operation rather than raw recursive delete.
`DeleteManagedBundle(ctx, bundlePath, managedOutputPaths, opts)` may delete only:
- files or objects listed in valid `.distributor.json.outputs`;
-`.distributor.json` at the destination bundle path;
- empty directories created by those files, for filesystem-like backends.
`managedOutputPaths` are relative to the destination bundle path. The backend validates each path and resolves it under `bundlePath`.
If `bundlePath == ""`, deletion may remove explicit managed files at the destination root, but must never delete the root itself.
Prefix or recursive deletion is out of MVP scope. A future force-overwrite stage may add broader behavior, but it must remain explicit and separately documented.
## Destination Emptiness
Destination emptiness should use `HasAny(prefix)` and typed not-found behavior. Callers that only need emptiness must not materialize a full recursive traversal.
Rules:
- A local destination bundle path is empty when the directory does not exist or exists with no entries.
- An S3-compatible prefix is empty when no objects exist below that exact destination bundle prefix.
- Entries outside the exact destination bundle path or prefix do not affect emptiness.
## Error Model
Storage should expose typed error categories with wrapping context. Callers should use helper predicates rather than string matching.
Required categories:
- not found;
- already exists;
- not empty;
- invalid path;
- conflict;
- permission;
- temporary;
- unsupported;
- unknown.
Adapters should translate backend-native errors into these categories while preserving useful operation, backend, path, and cause context.
## Adapter Expectations
### Local
The local backend should:
- constrain all operations beneath the configured root;
- reject traversal and absolute logical paths;
- report symlinks through metadata;
- reject symlink reads for source artifacts;
- use staged writes where practical;
- perform managed deletion only for explicit managed files and `.distributor.json`;
- clean up empty directories created by managed outputs where safe.
### Fake
The fake backend should:
- be in-memory and deterministic;
- implement the same logical path validation rules;
- support `Stat`, `Walk`, `HasAny`, byte reads and writes, stream reads and writes, managed deletion, and destination emptiness helper behavior;
- support configured symlink entries for validation tests;
- be used only by tests.
### SSH/SFTP
The SSH/SFTP backend should:
- use native SFTP operations;
- enforce the same logical path rules as local storage;
- use temporary file plus rename for staged writes where available;
- translate remote errors into storage error categories;
- avoid exposing SSH or SFTP dependency types through `internal/storage`.
### S3-Compatible
The S3-compatible backend should:
- treat prefixes as object trees, not real directories;
- normalize configured prefix plus logical path into object keys;
- use object PUT as publish-on-success;
- set content type from `WriteOptions`;
- implement traversal and emptiness by exact prefix;
- use backend pagination for traversal where available;
- allow `HasAny` to stop after the first matching object;
- constrain managed deletion to listed output objects and `.distributor.json`.