mountx logomountx

S3

The transport that is not a mount: serve a driver as an S3-compatible HTTP endpoint.

mountx/s3 speaks enough of the S3 REST API — path-style, SigV4-signed — for rclone, the AWS CLI, an AWS SDK, or a presigned URL in a browser to talk to any FsDriver as if it were a bucket. There is no kernel involved and nothing here produces a mountpoint, which is why it is a separate subpath rather than another choice for mountx/auto: auto's whole contract is "hand back a mounted directory", and a gateway never has one to hand back.

import { createS3Server } from "mountx/s3";
import { createMemoryDriver } from "mountx/drivers/memory";

await using server = await createS3Server(createMemoryDriver()).listen();
server.url; // http://127.0.0.1:<port>

// aws --endpoint-url $server.url s3 ls s3://mountx

#Quick start

#One driver, or several

// One driver, one bucket (default name "mountx"):
const one = createS3Server(driver, { bucket: "photos" });

// Several drivers, several buckets:
const many = createS3Server({ buckets: { photos: photosDriver, notes: notesDriver } });

The two shapes are told apart by the driver side: a value with stat, readdir and open is a driver whatever else it carries; only then is a buckets property looked at. createS3Server() returns immediately — nothing is bound until listen() — but it throws right away for a bucket name that cannot be a URL path segment, or for a host it will not bind.

#rclone

The two flags below are not stylistic — this gateway implements ListObjectsV2 only and has no CreateBucket, and rclone needs to be told both things or its first request fails:

# rclone.conf, or the equivalent RCLONE_CONFIG_MX_* environment variables
[mx]
type = s3
provider = Other
access_key_id = AKIAIOSFODNN7EXAMPLE
secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
endpoint = http://127.0.0.1:PORT
region = us-east-1
force_path_style = true
list_version = 2
no_check_bucket = true
rclone sync ./photos mx:mountx/photos
rclone lsjson -R mx:mountx
  • list_version=2 — rclone picks the list API from the provider: V2 for AWS, V1 for everything else, including Other. This gateway only implements ListObjectsV2, so an unconfigured remote gets 501 NotImplemented: ListObjects (V1) is not implemented by this gateway on its first listing.
  • no_check_bucket=true — rclone's S3 backend sends CreateBucket before an upload unless a listing has already marked the bucket as existing; this gateway's buckets are the drivers it was constructed with; there is nothing to create, so it answers NotImplemented and rclone reports failed to prepare upload.
  • force_path_style=true — this gateway speaks path-style URLs only (/bucket/key), never virtual-hosted (bucket.host/key).

An empty directory needs both --create-empty-src-dirs (rclone's local backend otherwise drops it before the sync sees it) and --s3-directory-markers (its S3 backend otherwise has no key to represent one) — rclone sync --create-empty-src-dirs --s3-directory-markers ./tree mx:mountx/tree round-trips one, both directions.

#Presigned URLs

mountx/s3 exports the same SigV4 codec the session verifies against, so a presigned URL needs no server:

import { presignRequest, formatAmzDate, uriEncode } from "mountx/s3";

const path = "/mountx/notes/hello.txt";
const signed = presignRequest({
  method: "GET",
  path,
  headers: [{ name: "host", value: "127.0.0.1:PORT" }],
  credentials: { accessKeyId, secretAccessKey },
  region: "us-east-1",
  timestamp: formatAmzDate(Date.now()),
  expiresIn: 300, // seconds
});
const query = signed.query.map((q) => `${uriEncode(q.name)}=${uriEncode(q.value)}`).join("&");
const url = `http://127.0.0.1:PORT${path}?${query}`;
curl "$url" # no Authorization header anywhere — the signature lives in the query

#Who may connect

SigV4 is only authentication when there is a secret to verify against, so the rule is split on whether credentials was passed:

  • No credentials — every signature is parsed but never checked, so the bind is loopback-only: a non-loopback host — including the unspecified 0.0.0.0/::, which bind every interface — is refused outright, before a socket opens, with a named S3BindError.
  • With { accessKeyId, secretAccessKey } — every request is verified with strict SigV4 (both the header form and the presigned-query form), clock skew bounded to ±15 minutes, and any host is allowed.

The loopback check is literal: localhost, ::1, and any 127.x.y.z (plus the ::ffff: IPv4-mapped spelling a dual-stack listener uses). Nothing resolves a name, so a hostname that happens to point at 127.0.0.1 is still refused — the check is a fact about the address handed in, not about where it currently resolves.

#Semantics

#ETags are derived, not MD5

An object's ETag is the first 32 hex characters of sha256("dev:ino:size:mtimeMs"), suffixed -1. The -1 is the shape a multipart ETag has, which is deliberate: it is the signal to a client that this is not a content hash. rclone reads it that way — rclone check announces it is using MD5, finds none it can verify, and falls back to comparing size and modification time instead, reporting the files as matching and the hashes as "could not be checked" rather than treating the mismatch as a difference. --size-only gets the same result with no hash step logged at all. The ETag is otherwise stable across repeated GETs of the same object.

#mtime, not much else

x-amz-meta-mtime (epoch seconds — a bare integer when whole, three decimals when not) is rclone's own metadata convention, and the only x-amz-meta-* header this gateway keeps. On PUT (and on CompleteMultipartUpload, from the value given to CreateMultipartUpload) it maps to driver.utimes() when the driver declares times; on GET/HEAD it is emitted from stat.mtime. Every other x-amz-meta-* header is dropped. Resolution stops at the millisecond — the header has no room for more — so a file with sub-millisecond mtime precision re-transfers on the next rclone sync unless it is run with --modify-window 1ms.

Content-Type is not stored at all: every GET/HEAD answers application/octet-stream.

#Directories are prefixes

There is no directory object except for an empty one. PUT key/ (empty body) is a recursive mkdir; DELETE key/ is an rmdir (ENOTEMPTY409 BucketNotEmpty). A directory with children needs no marker — its children are the listing — but an empty one would otherwise vanish from every listing, so it lists as a zero-byte object with key key/. That is exactly what lets sync recreate it, and exactly what rclone's --s3-directory-markers flag asks for.

#The NotImplemented boundary

The capabilities philosophy applies to the wire as much as to a driver: an operation this gateway does not implement answers a well-formed S3 NotImplemented (501) error document, never a fall-through to a neighbouring one — GET /bucket/key?acl is refused, and must never quietly answer the object's bytes because the sub-resource went unrecognized. CreateBucket/DeleteBucket answer the same way; bucket existence here is "was a driver given to createS3Server()", not something a request can create. ?versioning is refused too, which is what makes rclone purge log Failed to read versioning status, assuming unversioned and then carry on correctly.

Supported: ListBuckets, HeadBucket, ListObjectsV2, GetObject, HeadObject, PutObject, DeleteObject, DeleteObjects, CopyObject, and the five multipart operations below. Conditionals (If-Match/If-None-Match/If-Modified-Since/If-Unmodified-Since, 304/412) and a single Range (416 when unsatisfiable) are implemented on GET/HEAD.

#Multipart

CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload and ListParts stage their parts through the driver, under a reserved bucket-root prefix, .mountx-multipart/<uploadId>/part-<N> — invisible to every other operation (a direct GET/HEAD/PUT of a staging key answers as though it did not exist, and it is skipped in every listing). CompleteMultipartUpload streams the staged parts together in the order the client listed them, because parts arrive out of order and out of size, so the offset of part N is unknowable until every part before it exists. Aborting an upload, and closing the server, both sweep the staging area; an interrupted upload leaves no debris behind once either happens.

#The write-in-place caveat

A PUT (and multipart completion) writes the object in place: there is no temporary file and no rename, because the driver interface has no atomic-create primitive to build one on. What is guaranteed is the first byte — the destination is not opened, so an existing object is not truncated and a new one is not created, until the first payload byte has arrived (and, for a signed aws-chunked body, been verified). An upload rejected at or before its first byte therefore leaves the bucket exactly as it was; one that fails after writing has begun leaves whatever had been written by then — a partial object where a whole one used to be, not the old object restored.

#Serving without mounting, on purpose

There is nothing platform-specific here: createS3Server is node:http, and Node's HTTP server runs wherever Node runs. Nothing in this transport has been exercised on Windows, but nothing in it depends on anything Linux- or macOS-specific either — it is an HTTP server serving an already-portable driver interface.

#createS3Server(source, options?)

function createS3Server(
  source: FsDriver | { buckets: Record<string, FsDriver> },
  options?: S3ServerOptions,
): S3Server;

#S3ServerOptions

Extends S3SessionOptions, so everything the session takes is settable here too.

optiondefault
bucket"mountx"bucket name for the single-driver call shape; ignored for a bucket map
host"127.0.0.1"address to bind — see Who may connect
port0an ephemeral port, which S3Server.port then reports; never a conventional S3-gateway port
credentialsnone{ accessKeyId, secretAccessKey } — present enables verified SigV4 and any bind
drainTimeout5000ms close() lets in-flight responses finish before dropping connections
onTransportErrornone(error, peer) — a socket error, or a reply that could not be written

#S3Server

interface S3Server extends AsyncDisposable {
  readonly session: S3Session;
  readonly host: string;
  readonly port: number;
  readonly url: string; // e.g. "http://127.0.0.1:54321"; IPv6 bracketed
  readonly buckets: string[];
  readonly connections: number;
  listen(): Promise<S3Server>; // idempotent; resolves once bound
  close(): Promise<void>; // stop accepting, drain, drop, sweep multipart staging — idempotent
}

close() drains in a deliberate order: stop accepting, let in-flight responses finish (bounded by drainTimeout), drop what's left, and only then sweep the multipart staging area — sweeping first could race a part still being written by a request that was allowed to finish.

#S3BindError / isS3BindError() / isLoopbackHost()

class S3BindError extends Error {
  readonly code: "ERR_S3_BIND";
  readonly host: string;
}
function isS3BindError(error: unknown): error is S3BindError;
function isLoopbackHost(host: string): boolean;

The one error type createS3Server() throws for an address, named so it can be caught rather than pattern-matched on a message. isLoopbackHost() is the literal check described above, exported on its own because it is worth asking without constructing a server first.

#S3Session

new S3Session(buckets: Record<string, FsDriver>, options?: S3SessionOptions)

One HTTP request in, one S3 reply out, with no socket anywhere — which is what makes the whole protocol testable with no listener and no client, the same posture FuseSession and NfsSession keep.

session.buckets; // Map<string, Loopback>
session.bucketNames; // string[], in listing order
session.stats; // { requests, replies, errors, operations: Map<string, number>, assertions }
await session.handleRequest(head, body?); // → S3StreamResponse; never rejects
await session.close(); // idempotent; sweeps multipart staging

handleRequest's boundary is streaming, unlike NfsSession.handleCall(bytes): the body in and the body out may each be an AsyncIterable<Uint8Array>, because buffering a multi-gigabyte PUT to parse it — or a multi-gigabyte GET to answer it — is not something a gateway may do.

#S3SessionOptions

optiondefault
credentialsnonepresent verifies every request; absent parses signatures without checking them
regionanyregion the credential scope must name
nowDate.nowthe clock — SigV4 skew and reply timestamps are facts about now
requestIdrandom hexthe x-amz-request-id minter
maxBodyBytesunlimitedcap on a decoded PUT body; over it is EntityTooLarge
maxXmlBytesthe XML parser's own budgetcap on a request document (DeleteObjects, CompleteMultipartUpload)
readChunkBytes128 KiBbytes per positional read while streaming a GET
debugon outside productionrun the reply-exactly-once assertions
onErrornonecalled for every request that ends in an error reply
onAssertioncollectcalled when a dev-mode assertion fails

#The layers below

Everything except server.ts touches no socket and no listener, which is what makes the protocol testable by a signing JS client built out of the same codecs (test/s3/client.ts, the conformance column's transport):

module
constants.tsthe errno → S3 error table (s3ErrorOf), and the protocol's limits (MAX_KEYS, MIN_PART_SIZE/MAX_PART_SIZE/MAX_PARTS, MAX_KEY_BYTES, MULTIPART_PREFIX)
sigv4.tsAWS Signature Version 4, signed and verified: signRequest/verifyRequest/presignRequest, the canonical request and signing-key derivation, uriEncode
xml.tsthe bounded XML encoder and parser for the list/error/multipart documents
chunked.tsthe aws-chunked / STREAMING-AWS4-HMAC-SHA256-PAYLOAD decoder (AwsChunkedDecoder), per-chunk signature verification
protocol.tspure request parsing and routing — path-style URL to (bucket, key), the S3_OPS table, Range and the conditional headers, the error documents
session.tsS3Session — the operation semantics, over one or more drivers
server.tsthe socket, and the only file that imports node:http

All of it is re-exported from mountx/s3 by name, except the generic XML layer xml.ts is itself built on (XmlNode, xmlDocument, parseXml, and the rest) — primitives rather than anything a consumer of an S3 gateway composes with, the same treatment mountx/nfs gives its own sub-struct helpers.

Note

There is no RFC for S3. Every constant and error code here is transcribed from Amazon's own documentation and named where it is used — the S3 API Reference, its "Error responses" page, and the AWS SigV4 specification (including the official aws-sig-v4-test-suite goldens). Real clients (rclone, curl, the AWS SDKs) are oracles for this transport, never sources: what they accept is evidence, what Amazon documents is the fact.

#Not available

  • CreateBucket/DeleteBucket, bucket ACLs and policies, object ACLs, versioning, and every list-type=1 request. All refused with a well-formed NotImplemented rather than a fake answer — see the boundary above.
  • Symlinks, hardlinks, permissions and access time. An S3 object has none of these, so there is nothing for the gateway to carry even where the driver underneath has one.
  • Windows, not because anything here is platform-specific, but because it has not been run there.

#Next

  • FUSE and NFSv3 — the two transports that produce a mountpoint.
  • mountx/auto reference — the chooser that picks among the three mount transports, and why S3 is not an option there.
mountx logo

mountx  Write a filesystem in JavaScript, mount it as a real folder.