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 missingdisk.assertMissing('avatars/2.png'); // throws StateError if present
print(disk.stored); // every path currently written, sortedassertStored 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 defaultmanager.disk('public'); // the 'public' diskEach 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.dartUsing 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):
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. Createconfig/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 diskawait Storage.disk('public').put('avatars/1.png', bytes); // a named diskStorage.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 throughStorage.disk(). Code that resolvesapp<DiskManager>()straight from the container bypasses the fake entirely and writes to the real filesystem, even inside a test. Always callStorage.disk()in code you intend to fake, and never resolveDiskManagerfrom the container directly in that code path.
maat storage:link
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:linkThe [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.