Filesystem

Filesystem

On this page

One port — Filesystem — over a local directory, an in-process map, and anything that speaks S3. An application writes the same six calls whichever disk is behind them.

use rainier_framework::facades::Storage;

Storage::instance().put("avatars/7.png", bytes).await?;
let avatar = Storage::instance().get("avatars/7.png").await?;

Storage::disk("archive").unwrap().put("2026/report.pdf", bytes).await?;
get / put / delete / existsthe four that need no explanation
metadata / size / last_modifiedone stat call, or the two fields off it
list(prefix)the files under a prefix
directories(prefix)the directories directly under it, not recursing
copy / renameread-then-write by default; S3 does it server-side
url / temporary_urla public link, and a signed one — not interchangeable
get_string / put_string / appendthe text conveniences

Every path is normalised before it reaches a driver, and a traversal is refused rather than resolved: ../../etc/passwd is an error, not a file outside the disk.

Declaring disks

A disk is not one setting — it names its own driver, and its own bucket, endpoint, region and credentials. Two disks on two services have nothing to share, so they are declared as a section: a default naming one entry, and each entry naming its own driver.

use rainier_framework::filesystem::{DiskConfig, Disks, S3Disk};

Rainier::new(".").with_disks(
    Disks::new("uploads")
        .with("uploads", DiskConfig::local("storage/app"))
        .with("archive", S3Disk::new("archive-bucket").region("us-east-1")),
)

which writes it to the filesystems key, so the same thing can come from the configuration tree instead:

{
  "filesystems": {
    "default": "uploads",
    "disks": {
      "uploads": { "driver": "local", "root": "storage/app" },
      "archive": { "driver": "s3", "bucket": "archive-bucket", "region": "auto",
                   "endpoint": "https://account.r2.cloudflarestorage.com",
                   "key": "…", "secret": "…" }
    }
  }
}

Note the spelling: the entries are disks here, where the queue and database sections call theirs connections. That asymmetry is deliberate — the shapes mirror the framework these sections are ported from, including its own inconsistency, so a section carried across does not have to be re-learned.

The framework seeds one local disk under storage/app, so a fresh clone has working storage with no section at all. FILESYSTEM_DISK names which declared disk is the default, because which disk a deployment writes to is a deployment's decision — and naming one it never declared is a boot failure rather than a fallback to the seeded one.

Each disk is built from its own declaration. There is no shared connector to inherit: the version of this that built them all from one produced a disk with the right bucket name pointed at the wrong service, and that failure raises nothing — the bucket resolves, the prefix is empty, and a listing reports nothing, which reads exactly like a bucket that is genuinely empty.

with_storage hands over a Storage you built yourself and wins over anything declared, the way with_database does.

What a declaration refuses

Each is a case where accepting it would give a working-looking disk reading or writing somewhere other than the one intended, so each is a boot failure:

DeclarationWhy
no driveran assumed driver is a disk pointed at whatever the default happens to be
bucket on a local disksomebody believes these files reach object storage; they reach a directory
key without secretfalls back to the ambient chain, and reads a different account's bucket of the same name
key and secret with no regiona signed request has to name one, and a guess is a wrong one
default naming an undeclared diskthe fallback would be silent, and the wrong disk
a driver nothing answers tothe fallback would be a disk on whichever backend the default happens to be

A disk whose cargo feature is not enabled fails at build time too: an s3 disk without the s3 feature is an error naming the feature, never a quiet substitution of a local directory.

A driver the framework does not ship

The driver field is not limited to the drivers in this crate. An application registers its own and then declares it by name like any other:

use rainier_framework::filesystem::{CustomDisk, Filesystem, FilesystemDriver};

FilesystemDriver::extend("my-store", |disk: CustomDisk| async move {
    let endpoint = disk.string("endpoint").unwrap_or_default();
    Ok(Arc::new(MyStore::connect(endpoint).await?) as Arc<dyn Filesystem>)
})?;
{ "driver": "my-store", "endpoint": "https://example.invalid", "namespace": "uploads" }

Those settings arrive at the factory as a CustomDisk. They are not checked against the built-in field list — the framework has no idea what a driver it does not ship needs — so a custom driver validates its own, which CustomDisk::settings_as::<T>() makes one ?.

An unknown driver never becomes a working default. The name still has to resolve, and it is checked twice, with two different messages because they have two different fixes:

WhenWhat happenedWhat the error says
a declaration is readthe name is neither built in nor registered`x` is not a valid filesystem driver, listing the built-ins and everything registered
a declaration is builtit names a driver nobody registeredno filesystem driver is registered under `x`, and to register it first

The second is the one somebody will hit: the declaration was assembled in code rather than read from configuration, so nothing checked the name until the disk was built. "Register it before the disk that names it is built" is a different fix from "you spelled it wrong".

Signed URLs

url(path) is a public, permanent link, and None for a driver with no public face — a local disk behind an application is not reachable by URL, and pretending otherwise produces a link that 404s.

temporary_url(path, expires_in) is what restricted content needs: the object is not publicly readable, so the link carries its own proof of authorisation and that proof runs out.

let link = Storage::disk("content")
    .unwrap()
    .temporary_url("paid/film.mp4", Duration::from_secs(300))
    .await?;

A public URL is never a substitute for a signed one

This is the one rule that must not bend, and it is why temporary_url answers Result<String> rather than Result<Option<String>>.

url answers with a link anyone who sees it can keep and pass on, for ever — there is nothing in it to expire. A driver that cannot sign and quietly answered with that instead would ship every paywalled object with a permanent, redistributable link, and nothing at the call site would look wrong: it asked for a temporary URL and got a URL.

Option would be an invitation. unwrap_or_else(|| public_url) is a natural line to write, reads as a graceful fallback, and is exactly that paywall bypass. Result makes the same mistake require deliberately discarding an error, and makes the correct handling a ?.

A driver that cannot sign therefore fails, naming itself, and it renders as 501 — because from outside that is what it is: the deployment put restricted content somewhere with no way to sign for it, and no request the client could have made differently would work.

Listing a subtree's shape

list(prefix) answers with files and says nothing about what is below them, so "how many variants are stored beside this one" has no answer through it. The alternative — listing every key in the subtree and cutting each at the first separator — downloads a whole subtree to learn its shape.

let sub = disk.directories("renditions/7").await?;   // ["renditions/7/1080p", …]
let files = disk.list(&sub[0]).await?;

What comes back is a prefix list accepts, not a bare segment, so descending is passing the answer back in rather than rebuilding a path at every call site. "" enumerates the root, and a prefix with nothing under it is an empty Vec rather than an error.

Drivers

FilesystemDriverFeatureSharedDurableSigns
Local—noas durable as the volumeno
Memory—nonono
S3s3yesyesyes
anything registered—its own answer

S3 covers Cloudflare R2, MinIO and anything else speaking the protocol — set endpoint, and path_style where the service wants it.

Testing

use rainier_framework::filesystem::MemoryFilesystem;

let disk = MemoryFilesystem::new();

No directory to clean up and no server. It refuses temporary_url like any other driver that cannot sign, which is what makes a test asserting on a signed link fail rather than pass against a permanent one.

This page is the framework’s docs/filesystem.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.