Skip to content

Filesystem

Shena gives Maat applications a named-disk abstraction for storing files, the same idea as Laravel’s Storage facade. An application talks to a disk by name — local, s3, or whatever it configures — without caring whether the bytes end up on the local filesystem or in an S3 bucket.

What a disk is

A disk is a Disk implementation bound to one storage location: a root directory on the local filesystem, or a bucket (and optional prefix) in S3. Every disk exposes the same operations, so switching a disk’s driver in configuration never changes application code.

abstract class Disk {
String get name;
Future<bool> exists(String path);
Future<Uint8List> get(String path);
Future<Stream<List<int>>> readStream(String path);
Future<void> put(String path, List<int> contents, {Visibility? visibility});
Future<void> putStream(String path, Stream<List<int>> contents,
{Visibility? visibility});
Future<void> delete(String path);
Future<void> copy(String from, String to);
Future<void> move(String from, String to);
Future<int> size(String path);
Future<DateTime> lastModified(String path);
Future<Visibility> visibility(String path);
Future<void> setVisibility(String path, Visibility visibility);
Future<List<String>> files(String directory, {bool recursive = false});
Future<List<String>> directories(String directory);
Future<void> deleteDirectory(String directory);
String url(String path);
Future<String> temporaryUrl(String path, Duration expiresIn);
}

path is always relative to the disk’s root. A path that would leave that root — a leading /, a .. segment — throws PathTraversal rather than reading or writing outside it, on every driver: the check is one shared function (assertRelativePath) that each driver calls before it touches its backing store, so FakeDisk and S3Disk reject exactly what LocalDisk rejects. Reading or removing a path that does not exist throws FileNotFound. Both exceptions name the disk and the path, so a log line is enough to find the fix.

deleteDirectory additionally refuses an argument that means “the whole disk” — '', '.' or '/' — with an ArgumentError. Every driver implements it as a prefix match, so an unset id in deleteDirectory('media/$id') would otherwise wipe the disk silently. To empty a disk, list its entries and delete them explicitly.

Visibility is either public or private. It controls whether a stored file is reachable through url() without a signature. Passing no visibility to put/putStream keeps a driver’s default.

url() and temporaryUrl() answer different questions. url() is synchronous and builds a link assuming the file’s visibility already permits access — it throws a StateError on a disk with no urlPrefix configured, rather than guessing one. temporaryUrl() is asynchronous and returns a signed link that expires after expiresIn, valid regardless of the file’s visibility, and only on a driver that can actually sign one (S3Disk can; LocalDisk throws — see Why temporaryUrl is unavailable below).

Reading and writing

The everyday calls go through Storage.disk() in a Maat application (see Storage below), and read exactly like the Disk interface:

await Storage.disk().put('reports/q1.pdf', bytes);
final bytes = await Storage.disk().get('reports/q1.pdf');
if (await Storage.disk('public').exists('avatars/1.png')) {
await Storage.disk('public').delete('avatars/1.png');
}
await Storage.disk().copy('reports/q1.pdf', 'archive/q1.pdf');
await Storage.disk().move('reports/q1.pdf', 'archive/q1.pdf');

get/put return and accept a Uint8List/List<int> held entirely in memory — fine for a PDF or an avatar, wrong for anything large enough that holding the whole thing in memory matters. See The streaming rule for the alternative.

The streaming rule

readStream and putStream are part of the interface from the start, not bolted on once a driver needs them. A disk backed by S3 must be able to stream a large upload or download without buffering the whole file in memory, and get/put alone cannot express that — so every driver, local included, implements both the buffered and the streaming methods. Application code picks whichever fits: get/put for small files it already holds in memory, readStream/putStream for anything sized to matter.

Testing with FakeDisk

FakeDisk is a complete, in-memory Disk — nothing it does touches the real filesystem or a network. It ships from shena’s lib/, not test/, because it is not a test-only scaffold: everything downstream tests against it. A driver-registry test binds a name to a FakeDisk instead of standing up a temp directory or an S3 bucket; a package that merely depends on Disk (a media library, an upload handler) writes its tests against FakeDisk without ever depending on a concrete driver. Keeping it in test/ would have made that impossible without every consumer depending on shena’s dev-only test sources.

FakeDisk passes the same disk_contract.dart suite every real driver must pass, plus a few assertions built for tests:

final disk = FakeDisk();
await disk.put('avatars/1.png', bytes);
disk.assertStored('avatars/1.png'); // throws StateError if missing
disk.assertMissing('avatars/2.png'); // throws StateError if present
print(disk.stored); // every path currently written, sorted

assertStored and assertMissing throw a plain StateError rather than depending on package:test — a storage package cannot ship a test framework as a runtime dependency of every application that uses it. The StateError message for a failed assertStored lists every path currently on the disk, which is usually enough to see a typo’d path or a write that never happened.

seed is a shorthand for tests that just need a file to exist and don’t care about the write path:

await disk.seed('config.json', '{"ok": true}');

get and readStream hand back a copy of the stored bytes, so code that mutates a buffer it read (image processing, in-place re-encoding) cannot reach back into the disk’s contents — no real driver would let it.

FakeDisk.url and temporaryUrl return stable, predictable fakes (/fake/<path>) rather than anything resembling a real URL — assert against them if a test cares about the shape a disk’s URL takes, not the literal value.

LocalDisk

LocalDisk is the first real driver: a Disk backed by a directory on the machine Maat is running on.

final disk = LocalDisk(
name: 'local',
root: '/var/www/app/storage/app',
urlPrefix: 'https://example.com/files',
);

root is resolved to an absolute, normalized path once, at construction. Every other path a caller passes — 'avatars/1.png', 'reports/2026/q1.csv' — is always relative to that root; LocalDisk has no notion of an absolute filesystem path outside its own root.

files and directories always return root-relative paths joined with /, even on Windows — never Platform.pathSeparator. That keeps a listing from LocalDisk indistinguishable from one out of an object-store driver, which is the point of the shared contract suite.

The traversal guard

The guard has two layers, and LocalDisk uses both.

The first is lexical and shared by every driver: assertRelativePath(disk, path) in shena rejects an absolute path (POSIX- or Windows-shaped), any .. segment, and the empty path, throwing PathTraversal before a byte moves. It is exported, so a driver registered through DiskManager.extend gets the same guarantee by calling it — and the contract suite in disk_contract.dart fails any driver that doesn’t.

The second is LocalDisk’s own, because a lexical check alone is not a containment proof on a real filesystem. Every method that touches the filesystem routes through one private _resolve step, which runs the shared guard, joins the path onto the root, normalizes the result (a/../b.txt collapses to b.txt, exactly as it would with no traversal at all), and finally checks with p.isWithin that the result is still the root or lives inside it. Neither layer replaces the other: the lexical one is what FakeDisk and S3Disk can enforce on an opaque key, and the containment one is what catches anything lexical analysis cannot see. This is treated as a security boundary: it is exercised by the contract suite and by tests of its own, and there is no path into LocalDisk that reaches dart:io without passing through it first.

Streaming

readStream and putStream are backed directly by File.openRead() and File.openWrite(). Nothing buffers the file into memory first — get/put are for content already small enough to hold as bytes, readStream/putStream are for anything sized to matter, and on LocalDisk that distinction is real, not cosmetic.

Visibility on POSIX vs Windows

On POSIX (Linux, macOS), Visibility maps directly onto the file’s permission bits: setVisibility(path, Visibility.public) chmods the file to 644 (owner read/write, everyone else read), .private chmods it to 600 (owner only), and visibility() reads the mode back off the file and reports public when the “others” bit grants read access. Windows has no equivalent permission model that maps onto “world readable”, so on Windows setVisibility is a no-op and visibility always reports the disk’s configured defaultVisibility — an application that needs enforced, per-file visibility on Windows needs a driver (or a web-server rule) other than raw filesystem permissions.

setVisibility and visibility throw FileNotFound for a path that doesn’t exist, matching every other driver. A chmod that fails for any other reason (permissions, a read-only mount) throws a StateError carrying chmod’s stderr rather than failing silently — a storage misconfiguration in a container should be loud at the call site, not discovered later as a file with the wrong mode.

Why temporaryUrl is unavailable

temporaryUrl exists on Disk because S3 (and similar object stores) can generate a signed URL that expires — a link a browser can use directly without the application being in the request path. A local filesystem has no equivalent signing mechanism: there is no service sitting in front of the directory that a signature could be verified against. So LocalDisk.temporaryUrl always throws a StateError naming the fix — use the s3 driver for a disk that needs expiring links, or serve the file through an application route that checks authorization itself on every request.

DiskManager

DiskManager turns a configuration map into Disk instances by name — the same idea as MailManager for mailers. It is the only file in shena that reads configuration; every driver (LocalDisk, FakeDisk, and whatever extend registers) stays constructible with no config at all, so it stays testable on its own.

shena does not depend on maat, so DiskManager takes a plain Map<String, Object?>, not a Config object. Adapting config('filesystems') into that map is ShenaServiceProvider’s job.

final manager = DiskManager({
'default': 'local',
'disks': {
'local': {'driver': 'local', 'root': '/var/www/app/storage/app'},
'public': {
'driver': 'local',
'root': '/var/www/app/storage/app/public',
'url': 'https://example.com/storage',
},
},
});
manager.disk(); // the 'local' disk, since it's the default
manager.disk('public'); // the 'public' disk

Each disk entry needs a driver key; local also reads root (the directory to resolve every path against), and optionally url (passed through to LocalDisk.urlPrefix) and visibility ('public' or anything else, treated as private). Resolving a name is cached — the same name always returns the same Disk instance — so callers can hold onto a disk across requests without re-reading configuration each time.

Misconfiguration throws a StateError that names the fix. Asking for a disk that isn’t configured lists the disks that are:

manager.disk('s3');
// StateError: No disk named "s3". Configured disks: local, public.
// Add "s3" to config("filesystems.disks").

Asking for a driver nothing has registered lists what is registered and how to add one:

// disk "weird" configured with driver: "gcs"
manager.disk('weird');
// StateError: No disk driver registered for "gcs" (disk "weird").
// Registered drivers: local. Register one with
// DiskManager.extend("gcs", ...).

Registering a third-party driver

Driver lookup goes through an explicit registry — never a switch on a runtime type — so it stays enumerable for tooling and open to drivers shena doesn’t ship. DiskManager.extend binds a driver name to a DiskFactory:

typedef DiskFactory = Disk Function(String name, Map<String, Object?> settings);
DiskManager.extend('memory', (name, settings) => FakeDisk(name: name));
final manager = DiskManager({
'default': 'cache',
'disks': {
'cache': {'driver': 'memory'},
},
});
manager.disk('cache'); // a FakeDisk named 'cache'

DiskManager.resetExtensions() clears every registered extension — call it in a test’s tearDown so one test’s extend call can’t leak into the next.

S3Disk

S3Disk is a Disk backed by any S3-compatible object store — AWS S3, Cloudflare R2, DigitalOcean Spaces, MinIO — through package:minio. It takes a constructed Minio client rather than credentials directly, so tests can point it at a local MinIO container without going through DiskManager:

final disk = S3Disk(
name: 's3',
bucket: 'app-uploads',
client: Minio(
endPoint: 's3.eu-west-1.amazonaws.com',
accessKey: '...',
secretKey: '...',
region: 'eu-west-1',
),
urlPrefix: 'https://app-uploads.s3.eu-west-1.amazonaws.com',
);

Configuring it through DiskManager

The 's3' driver reads:

final manager = DiskManager({
'default': 's3',
'disks': {
's3': {
'driver': 's3',
'bucket': 'app-uploads',
'key': '...',
'secret': '...',
'region': 'eu-west-1',
'url': 'https://app-uploads.s3.eu-west-1.amazonaws.com',
},
},
});

bucket, key, and secret are always required — a missing or empty one throws a StateError naming the disk and the key. An endpoint must be a full URL with a scheme and a host: s3.example.com parses as a path, not a host, so it is rejected with a StateError showing the expected shape rather than failing later inside the S3 client. region builds the default AWS endpoint (https://s3.<region>.amazonaws.com) when no endpoint is given; a provider other than AWS sets endpoint instead (region can still be passed alongside it, since SigV4 signing needs a region even against a non-AWS host):

'r2': {
'driver': 's3',
'bucket': 'app-uploads',
'endpoint': 'https://<account-id>.r2.cloudflarestorage.com',
'key': '...',
'secret': '...',
},

The same shape covers DigitalOcean Spaces (https://<region>.digitaloceanspaces.com) and a local MinIO (http://localhost:9000) — only endpoint changes.

temporaryUrl

S3Disk.temporaryUrl signs a real, expiring URL with presignedGetObject — a browser can use it directly, with no application in the request path, and it stops working once expiresIn elapses. This is the capability LocalDisk.temporaryUrl cannot offer, and the reason a Disk that needs expiring links picks the s3 driver.

Visibility on S3

S3Disk.setVisibility is a deliberate no-op, and S3Disk.visibility always reports Visibility.private. This is not an oversight: package:minio exposes no per-object ACL write, and object ACLs are disabled outright on some of the providers this driver targets (R2 has no ACL API at all). The mechanism that actually controls whether an object in an S3-compatible bucket is publicly reachable is the bucket policy, configured once on the bucket itself, not per object — so S3Disk does not pretend to enforce something it cannot enforce. An application that needs some objects public and others private on S3 puts them in separate buckets (or prefixes covered by separate policy statements) rather than relying on per-file visibility.

setVisibility still throws FileNotFound for a path that doesn’t exist, matching every other driver — “no-op” means “cannot enforce visibility,” never “skips checking the file exists.”

S3Disk declares this to the shared contract suite explicitly: diskContract('S3Disk', ..., supportsVisibility: false). A driver that opts out is still held to a weaker bar, not let off the hook — setVisibility must not throw for an existing file and visibility must always return a valid Visibility.

Testing S3Disk

test/s3_disk_test.dart runs the full contract suite plus a temporaryUrl signing check, gated on S3_TEST_ENDPOINT (skipped without it, exactly like the Postgres suites gate on PG_TEST_URL). A local MinIO satisfies the four S3_TEST_* variables it reads:

docker run -p 9000:9000 -e MINIO_ROOT_USER=minio \
-e MINIO_ROOT_PASSWORD=minio123 minio/minio server /data
S3_TEST_ENDPOINT=http://localhost:9000 \
S3_TEST_KEY=minio \
S3_TEST_SECRET=minio123 \
S3_TEST_BUCKET=shena-test \
dart test test/s3_disk_test.dart

Using it in a Maat application

shena on its own knows nothing about maat — no Config, no ServiceProvider, no application lifecycle. shena_maat is the integration layer that wires it in, the same split as seshat/seshat_maat and khnum/khnum_maat.

config/filesystems.dart

ShenaServiceProvider builds a DiskManager from config('filesystems'). An application supplies that config in config/filesystems.dart, returning a Map shaped exactly like the one DiskManager itself expects (see DiskManager above):

config/filesystems.dart
Map<String, Object?> filesystems() => {
'default': 'local',
'disks': {
'local': {'driver': 'local', 'root': storagePath('app')},
'public': {
'driver': 'local',
'root': storagePath('app/public'),
'url': '/storage',
'visibility': 'public',
},
},
};

storagePath() resolves against the application’s storage/ directory, exactly like publicPath() resolves against public/ — storagePath() is storage, storagePath('app/public') is storage/app/public.

Install the extension once, alongside the framework’s others — it carries both ShenaServiceProvider and the storage:link command described below:

await Application.configure(basePath: ..., environment: env)
.withExtensions([shenaExtension, ...])
.create();

A missing or malformed config('filesystems') fails at the first disk resolution — not at boot, since registering the binding only stores a factory — with a StateError naming the fix:

StateError: config("filesystems") is missing. Create
config/filesystems.dart returning a Map with "default" and "disks" keys.
See docs/filesystem.md.

Storage

Storage is the static entry point, the same shape as Mail: no instance to thread through the app, one call per disk.

import 'package:shena_maat/shena_maat.dart';
await Storage.disk().put('reports/q1.pdf', bytes); // the default disk
await Storage.disk('public').put('avatars/1.png', bytes); // a named disk

Storage.disk() resolves through the application’s DiskManager, so it requires a booted Application with ShenaServiceProvider registered — exactly like app<DiskManager>() would.

Testing with Storage.fake()

Anything that calls Storage.disk() — most usefully, a package like a media library built on top of this one — is tested against Storage.fake() rather than a real disk:

tearDown(Storage.restore);
test('uploading a file stores it on the public disk', () async {
final disk = Storage.fake('public');
await uploadAvatar(userId: 1, bytes: pngBytes);
disk.assertStored('avatars/1.png');
});

Storage.fake() replaces every disk with an in-memory FakeDisk, with no real Application or DiskManager required, and returns the faked disk so a test needs no cast. Storage.disk('public') returns the same FakeDisk instance on every call for that name, so one part of a test can write through it and another can assert against it.

Storage.fake() with no name fakes the disk Storage.disk() resolves to. That is the application’s configured default when one is booted — so code written against Storage.disk() and a test asserting on Storage.disk('local') see the same fake, exactly as they would see the same real Disk in production — and a disk named default when there is no application at all. Storage.restore() undoes the fake completely, clearing every disk it created; call it in tearDown so a fake from one test can never leak into the next.

Warning: Storage.fake() only intercepts calls routed through Storage.disk(). Code that resolves app<DiskManager>() straight from the container bypasses the fake entirely and writes to the real filesystem, even inside a test. Always call Storage.disk() in code you intend to fake, and never resolve DiskManager from the container directly in that code path.

A file written to the public disk lands under storage/app/public — not under public/, which is what a web server actually serves. storage:link is the direct port of Laravel’s php artisan storage:link: it creates a symbolic link at public/storage pointing at storage/app/public, so anything the public disk writes becomes reachable over HTTP without ever being duplicated on disk.

You need this once per environment that serves those files — typically right after config/filesystems.dart first defines a public disk, and again on any fresh deploy target where public/storage doesn’t exist yet (most hosts don’t carry a symlink through a fresh checkout or a build artifact).

$ maat storage:link
The [public/storage] link has been connected to [storage/app/public].

Running it again is a no-op if the link is already correct, and it never touches a real directory or file that happens to occupy public/storage — that fails loudly with a StateError telling you to move or delete it first, rather than silently overwriting something that might hold data.

Windows caveat: creating a symbolic link needs a privilege Windows doesn’t grant by default. If storage:link fails there, enable Developer Mode (Settings → Update & Security → For developers) or run the command from an elevated shell; the command fails with a clear StateError naming this rather than silently doing nothing.