Skip to content

Latest commit

 

History

History
1252 lines (1034 loc) · 155 KB

s3.md

File metadata and controls

1252 lines (1034 loc) · 155 KB

S3-compatible object storage Provider

The s3 type-id: read-only browsing of buckets, folders and objects, object metadata and capped previews over the S3 REST API, verified on MinIO RELEASE.2025-10-15T17-29-55Z, Silo RELEASE.2026-09-16T00-00-00Z, Garage v2.4.1 and RustFS 1.0.1, and not on AWS S3 or any hosted service. Source: src/lib/db/providers/objectstore/s3/. Tests: tests/unit/db/s3/ and tests/integration/db/s3-provider.test.ts.

Write reach none: the console's closed command table, S3_COMMAND_TABLE, holds reads only, and the client sends only GET and HEAD; there is no object edit, maintenance or transaction route

Before you connect

Seven facts decide whether the first connection works.

  1. Type the server's S3 API address, not its web console's: Endpoint host and Port, or paste http://host:port or https://host:port into Endpoint host. Where Studio runs decides what to type in Endpoint host:
Where the server and Studio run Endpoint host
Both on this machine localhost
The server on this machine, Studio in Docker host.docker.internal; on Linux, start Studio's container with --add-host=host.docker.internal:host-gateway
The server elsewhere its address, over TLS
  1. Studio addresses every object as <endpoint>/<bucket>/<key>, path style, and never by a bucket host name, so a server that answers only virtual-hosted requests cannot be read.
  2. Region: Studio signs for us-east-1 unless you type another region. A server that keeps a fixed region refuses a request signed for any other, and only some servers name the region they expect in the error; section 4.4 says which of the verified servers enforce a region and which name it.
  3. Credentials: put the access key ID in Access key ID and the secret access key in Secret access key. Only the keys you type sign a request: Studio never reads the server's environment, shared credential files or instance role, and with both fields empty it sends unsigned requests, which only a public bucket answers, and Garage answers none. Temporary credentials that need a session token cannot be used in this version.
  4. Bucket: leave it empty to list every bucket the credential may list, or name one bucket to browse only that one. This is a convenience, not a boundary (section 13). MinIO, Silo, Garage and RustFS list a limited key's own buckets; AWS S3 and some hosted services refuse the listing to such a key (not verified here), so name the bucket there.
  5. TLS: Studio refuses plain HTTP to a host that is neither this machine nor reached through an SSH tunnel unless you tick Connect without TLS, because bucket and object names, listings and previews cross the network in the clear, and anyone who captures a signed request can send it again for minutes, and on some servers for hours.
  6. What this version does not do: write, run SQL over files, sign with temporary credentials, or use virtual-hosted addressing; and it has been verified on MinIO, Silo, Garage and RustFS only, not on AWS S3, a hosted service or any other server.

1. Overview

Studio reads buckets in the tree, folders and objects in the Keys panel, an object in the Source tab with its metadata and a capped preview, and one AWS CLI read command per run in the editor. It writes nothing, whatever the connection's readOnly says. The embedded workspace has no Keys panel, so there the console is the only way to reach an object, as etcd documents for its keys (etcd.md, section 6.4).

Concept mapping

S3 Studio
An S3 endpoint One connection
A bucket The object kind bucket in the tree, or the one pinned bucket
A prefix folder A folder of the Keys panel: one CommonPrefixes level
An object The key-browser object kind, opened in the Source tab
Object metadata The Metadata part of the Source tab
Object versions aws s3api list-object-versions only
A region The Region field

2. Architecture

The provider lives in its own family directory and needs no driver.

2.1 Where it sits

src/lib/db/providers/objectstore/s3/, the objectstore family, extends BaseDatabaseProvider directly; the console is under console/ and the preview beside it.

2.2 Modules

Module Purpose Runs in
index.ts S3Provider: lifecycle, declarations, limiter, run registry, the one failure path server
constants.ts Every number and fixed name, the three name patterns, and the preview limits browser
connection-options.ts The connection record to the session's options; every connect-time refusal server
names.ts The virtual key space <bucket>/<key>, the addressability verdicts, shown names browser
encoding.ts Request targets for each operation server
sigv4.ts The SigV4 core and the request signer server
xml.ts The narrow XML reader browser
shapes.ts Readers of each S3 response document browser
headers.ts The response header selection and the header readers browser
client.ts S3Client: one function per operation over the byte transport, GET and HEAD only; S3AgentClient, the agent's six reads server
errors.ts The error classification table and every sentence server
cursor.ts The Keys panel cursor envelope server
key-scan.ts The Keys panel pages server
objects.ts Kinds, counts, listings and the Source documents server
preview-adapter.ts The one adapter from the client's HEAD and ranged reads to the preview server
monitoring-reads.ts The connection probe, health and overview server
agent-statement.ts The agent's reader: the console's parser, then the closed table of six metadata reads server
agent-metadata-read.ts The agent's reads over S3AgentClient, their metadata-only projection and the failure wording server
agent-surface.ts What an agent-mode handle answers outside queryReadOnly, each withheld surface with its reason server
labels.ts S3_LABELS browser
sql-writing.ts The SQL writing row, the inert standard answer no generator calls browser
destructive.ts The engine's row of the confirmation gate's vocabulary, which src/lib/db/destructive-commands.ts collects browser
connection-string.ts The engine's connection string row: no scheme, no TLS read from a URL query, no seed load check browser
execution-ui.ts The engine's execution UI row: the rolled-back notice names COMMIT, no consent deadline browser
sql-grammar.ts The SQL grammar facts the editor and the query log read: not SQL text, nothing kept when redacted browser
editor-syntax.ts The editor's s3 Monaco language over the console's lexer browser
editor-completions.ts The editor's command, flag and closed-value completion browser
editor-language.ts The engine's editor language row: the s3 language, its completion, no formatter, its accent browser
console/constants.ts The console's own numbers, and the shared ones re-exported browser
console/lexer.ts Prompt and aws stripping, the option-word rule, token roles browser
console/paths.ts s3://bucket/key reading browser
console/token.ts The AWS CLI's NextToken codec browser
console/commands.ts The command table, every sentence, the parser browser
console/guard.ts The confirmation gate's reader and refusal browser
console/format.ts Sizes, dates and ETags as the AWS CLI writes them browser
console/generators.ts Tree-click and Generate Command text browser
console/statement-language.ts Plan mode's statement language browser
console/results.ts Each command's grid and notices server
console/execute.ts Paging, the pin and dispatch over the client server
preview.ts previewObject: dispatch over the formats server
preview-detect.ts Format detection browser
preview-text.ts UTF-8 back-off, printable test, JSON re-indent, hex browser
preview-csv.ts The quote-aware CSV and TSV reader browser
preview-json.ts NDJSON lines and JSON documents to rows browser
preview-gzip.ts The capped gunzip of a prefix server
parquet-schema.ts The Parquet schema walk browser
parquet-thrift-guard.ts The structural check of Thrift bytes and the page header reader browser
preview-parquet.ts Footer read, plan, prefetch, page pre-scan, guarded decode server
preview-cells.ts Cell rendering and the output bounds browser
preview-render.ts The Source tab parts and the console grid of a preview browser
migration-ddl.ts S3_MIGRATION_DDL, the schema-diff migration's one refusal line browser

2.3 Registration & lifecycle

createDatabaseProvider() (factory.ts) builds the provider for the type-id s3 through a dynamic import. connect() builds the session's options, refuses before any request what section 4 says it refuses, builds the byte transport and sends the probe of section 7; a session is kept only after its probe parsed as S3 XML. disconnect() aborts every request in flight and closes the transport.

2.4 The client, and why

Requests go through the shared node:http(s) byte transport, extended with an opt-in signing hook, HEAD and a byte body, and are signed by Studio's own SigV4 module over node:crypto, tested on the AWS suite vectors. Answers are read by a narrow XML reader of Studio's own that refuses a document type declaration and every entity. There is no AWS SDK: its default credential chain reads the server's own identity, from the environment, shared files, a container endpoint or instance metadata, which section 3.2 rules out.

3. Design decisions

Each decision below states the reason for it.

3.1 Read-only v1

Studio reads S3 and writes nothing, whatever the connection's readOnly says. The client is built over the byte transport's method set of GET and HEAD alone, so no code path can send a write (row 3.18). Writes are a backlog entry (D264), and so is SQL over files (D261).

3.2 Only typed credentials sign

A request is signed only with the access key pair typed into the connection. Studio never reads an environment variable, a shared credential or config file, a container credential endpoint or instance metadata, so a connection never borrows the identity of the machine Studio runs on (row 3.19). With both keys empty the request goes unsigned: that is what an empty pair means, and there is no separate anonymous mode.

3.3 Path style and the region

Every request is addressed as <endpoint>/<bucket>/<key>, path style, because the shared transport pins one origin per connection and a virtual-hosted request has one origin per bucket. The Region field names the region every request is signed for; empty means us-east-1. Section 4.4 says which verified server enforces a region and which names the one it expects.

3.4 Link-local networks are always refused

The byte transport S3 uses refuses an endpoint that is, or resolves to, an address in one of the four entries of LINK_LOCAL_NETWORKS: 169.254.0.0/16 (also in its IPv4-mapped form), fe80::/10, fd00:ec2::254/128 and 64:ff9b::a9fe:0/112 (the NAT64 form of 169.254.0.0/16), whatever DB_HTTP_BLOCK_PRIVATE_HOSTS says (row 0.6). AWS, Azure and Google Cloud serve instance metadata at 169.254.169.254, and AWS also at fd00:ec2::254, so a path-style GET there would read instance credentials as an object. Through an SSH tunnel, the far end's address literal is checked the same way; a far end given as a name is resolved by the SSH server. An IPv6 literal with a zone index, such as fe80::1%eth0, is refused whatever its address, because the runtimes disagree on matching one against a network. Loopback, private and CGNAT addresses stay reachable, so local servers work; a metadata service a cloud serves outside these networks is not refused, and none is claimed.

3.5 The read-only mode

READ_ONLY_ENFORCED.s3 is true, so a seed may set readOnly: true on an S3 connection, and the connection refuses nothing it would not refuse anyway: S3's provider runs reads only. The dialog draws a fixed Read-only label with no box, and says under it: Studio has no write path on this engine, so every connection to it is read-only. A read-only seed that carries the documented default MinIO or RustFS credential is refused at connect, because the mode would promise a boundary the server does not keep.

3.6 Machine access

Agent mode and MCP read metadata only, and MCP reads only under an agent read key of its own: MCP_AGENT_CREDENTIAL_REQUIRED.s3 is true, so a seed with mcp: true on an S3 connection and no agent credential fails the whole file, and a connection that reaches MCP without one is refused when a tool opens it; MCP_EXPOSABLE.s3 and MCP_STATEMENT_READ.s3 are true, so MCP run_s3_read reads it (section 15.7). The agent read key is the seed's agent credential, agentUser the access key ID and agentPassword its secret (an agent credential), beside the connection's own key, and its grants hold the buckets MCP reads (section 15.5); S3 declares the "dedicated-user" form, so an agent access key ID equal to the connection's own, compared without case or surrounding space, is refused at load and at open, since it names the same key. Agent mode executes one command of a closed table of six metadata reads through queryReadOnly (section 15): bucket names and creation times, a bucket's existence, region, location and versioning status, a whole bucket's object count and total size, and the existence, size, last-modified time and storage class of one object the request names in full. Object keys count as stored values, since key paths name tenants and people, so no agent-mode path lists a key, and no body, user metadata, tag, ETag, version id, owner, ACL, policy or endpoint reaches the model. Plan mode works: it grounds on the bucket names the tree shows and drafts one console command in an s3 block, which it never runs; no object key, prefix, size or content reaches the model. B104 records what stays closed.

3.7 The Keys panel cursor

A Keys panel page's cursor is Studio's own envelope around the server's continuation token, because MinIO, Silo and RustFS accept a forged token as a start position. It is a stateless, scope-bound envelope with no MAC: it is bound to its bucket, prefix and level, and Studio refuses a cursor of another shape or scope before any request. It holds no per-process state, so it is valid in every process that serves the connection, across a restart, an idle eviction and another replica. It carries a position, not an authority: a hand-written cursor of the right shape and scope can reach the server, which can only start the listing at another position inside what the key may list, and the server's 400 or 501 for it is reported as the error table's continuation-token row.

4. Connection

A connection is one S3 endpoint, an optional key pair, a region and an optional bucket.

4.1 Configuration fields

Dialog field Connection field Classification What it is
Endpoint host, Port host, port public The S3 API endpoint; the default port is the provider's declared defaultPort, 9000
SSL mode, CA ssl public TLS; a pasted https:// raises SSL mode without lowering it
Access key ID user public, shown in the clear and part of the connection fingerprint The access key ID, 3 to 512 characters; outside that range it is refused before any request with the one sentence the provider and the form share; empty with the secret empty means unsigned requests
Secret access key password secret Never sent to the S3 server: Studio's server signs each request with it
Region region (new, S3 only) public Defaults to us-east-1 when empty
Bucket database public Optional; when set, Studio never lists buckets
Connect without TLS allowInsecureAuth public The per-connection consent of section 4.6, signed or unsigned alike
Read-only readOnly public Accepted; Studio reads only whatever it says (section 3.5)

The dialog's hint under each field:

  • Endpoint host: A host name or address, or a pasted http:// or https:// endpoint such as http://localhost:9000, which is split into Host and Port. The endpoint only: a bucket goes under Bucket, never in the address. MinIO and RustFS serve on port 9000 unless configured otherwise, Garage on 3900. If Studio runs in a container, localhost is that container: use host.docker.internal.
  • Access key ID: The access key ID. It is stored and shown in the clear, like a user name. Leave both keys empty only for a bucket that allows anonymous reads: Studio then sends unsigned requests, never this server's own cloud credentials.
  • Secret access key: The secret access key. Studio's server signs every request with it and never sends it to the S3 server. Fill in both keys, or neither.
  • Bucket: Optional. With a bucket here, Studio reads only that bucket and never lists the others, which a key limited to one bucket needs. Empty: every bucket this key can list.
  • Region: The region every request is signed for. Empty means us-east-1, which MinIO accepts unless it was started with a region of its own. Garage: its s3_region setting. AWS: the bucket's region.
  • Connect without TLS: Ticked, Studio connects to this host over plain HTTP. The secret access key is never sent to this server, but bucket and object names, listings and previewed contents travel in the clear, readable by anyone on the path, and a captured request can be replayed for several minutes. Choose an SSL mode under SSL / TLS, or an SSH tunnel, wherever the server offers one.

Values the provider refuses before any request, with the sentence the dialog and the provider share:

  • An access key ID with a space, comma, equals sign or slash: Access key ID must be printable ASCII without spaces, commas, equals signs or slashes, because it is sent inside the signed Authorization header. Nothing was sent.
  • An access key ID shorter than 3 or longer than 512 characters: Access key ID holds 3 to 512 characters, because S3 servers issue no shorter ID and it is sent inside the signed Authorization header. Nothing was sent.
  • A bucket name Studio cannot address in path style: Bucket must be 1 to 255 letters, digits, dots, hyphens or underscores, starting and ending with a letter or digit. Nothing was sent.
  • A region that is not a region name: Region must be 1 to 64 letters, digits, hyphens or underscores, such as us-east-1. Nothing was sent.

The access key ID stays a public field, and it is still withheld from any server text an error quotes (section 10).

4.2 Authentication

Fill in both keys to sign every request with AWS Signature Version 4, or leave both empty to send unsigned requests. One key without the other is refused before any request: Access key ID and Secret access key go together: fill in both to sign requests, or clear both to send unsigned requests. Nothing was sent. Studio signs with its own SigV4 code over node:crypto, signing host, range when sent, and every x-amz-* header it sends, with the payload hash of an empty body. The dialog warns when the pair is the documented default root credential of MinIO (minioadmin) or RustFS (rustfsadmin). Temporary credentials need a session token, which this version cannot send (D262).

4.3 TLS

The SSL panel is the shared one: disable, require, verify-system, verify-ca, verify-full, with a custom CA and a client certificate. TLS mode require sends signed requests to an unverified peer.

4.4 One endpoint, path style and the region

One connection is one S3 endpoint, and every bucket is addressed under it in path style (D263). The Region field signs every request for its value, us-east-1 when empty.

Server Region enforced Region named in the error
MinIO No, unless started with MINIO_SITE_REGION; then Yes for bucket and object reads Yes, when a site region is set
Silo No, unless started with MINIO_SITE_REGION; then Yes for bucket and object reads Yes, when a site region is set
Garage Yes, on every request, ListBuckets included: its s3_region Yes, on a GET
RustFS No No

4.5 SSH tunnel

The dialog offers the SSH tunnel on every S3-compatible object storage connection. Through a tunnel, the signed and sent Host is the local forward, so a server behind the tunnel that routes by Host name is not supported in v1 (row A63's outcome). A far end given as a name is resolved by the SSH server, where Studio cannot check it; a far end given as an address literal is checked against the link-local networks of section 3.4 before anything is forwarded to it. The SSH session to the bastion is already open by then, so a refused far end still costs one SSH login, and Studio closes the tunnel it opened for the refused connection.

4.6 Plain HTTP off this machine needs consent

Studio refuses plain HTTP to a host that is neither a loopback address nor reached through an SSH tunnel, signed or unsigned alike, unless the connection's Connect without TLS box is ticked: This connection would reach a host that is not this machine over plain HTTP, where anyone on the path can read bucket and object names, listings and previews, and replay a signed request. Choose an SSL mode under SSL / TLS, connect through an SSH tunnel, or tick Connect without TLS. Nothing was sent. The secret access key never crosses the network either way; what crosses it in the clear is every name, listing and preview, and a signed request anyone who captures it can send again while the server's clock window lasts.

4.7 Pasting an address

Endpoint host takes a pasted http:// or https:// address and splits it into Host and Port; https:// raises an SSL mode of disable to verify-system, and http:// keeps the mode. An address with a path is refused, because a bucket goes under Bucket; an s3://bucket/key URI names an object, not an endpoint, and is refused too. The connection-string box offers no S3 mode.

4.8 Server versions

Server Version Studio was verified on How the fixture gets it
MinIO RELEASE.2025-10-15T17-29-55Z, built from source at commit 9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a Built locally; MinIO publishes no community image or binary any more
Silo RELEASE.2026-09-16T00-00-00Z, image pgsty/silo@sha256:635197cb9f36d01bee221d34d1c7d7960f6a95c48b0b6c01d99cd13bdae51a46 Pulled by digest
Garage v2.4.1, image dxflrs/garage@sha256:9c96caa2612d3411acc5b0e6701fb238dbfba33e533a6d7d3d811a4b12d0d020 Pulled by digest
RustFS 1.0.1, image rustfs/rustfs@sha256:1803faef57627e2d9c2e7d89d655d712ddded5389040054987163043fecb6a3c Pulled by digest

Runtimes: Node 24.14.0 and Bun 1.4.2, with the live check also run on Node 26.10.0. MinIO's community edition is archived, and its last release carries unpatched advisories in upload paths that a read-only Studio never calls (D267). Silo is a maintained MinIO fork. These four are the servers the s3 provider itself was verified on; none of them is a wire-compatible relative of another engine.

Caveats the live pass confirmed, per server:

Server Caveat
MinIO, Silo With no site region set, any signing region is accepted, so a wrong Region field goes unnoticed.
RustFS The signing region is not enforced, so a wrong Region field goes unnoticed.
RustFS A missing bucket and a forbidden one answer the same 403, so Studio cannot tell them apart.
Garage Every request signed for a region other than the server's s3_region is refused, and the error names the right one.
Garage Garage keeps no object versions, so list-object-versions answers that the server does not implement it.
Garage A missing bucket answers 404 to any signed key, even one with no grant, so Garage tells any key holder which bucket names exist.
Garage Garage refuses every unsigned request, so an empty key pair never works there.
Garage A bucket with a local alias for the key is listed twice, once under each name, and both open the same objects.

4.9 What is not verified

Studio has not been run against AWS S3, any hosted S3-compatible service, or any self-hosted server other than the four of section 4.8, such as Ceph RGW or SeaweedFS, so none of them is claimed. These behaviours exist on AWS and were never exercised:

  • a 301 PermanentRedirect for a bucket in another region, which Studio refuses rather than follows;
  • virtual-hosted addressing on real DNS, and bucket names with dots over TLS;
  • temporary credentials from STS, which need a session token Studio cannot send;
  • ListBuckets paging, which AWS requires above 10,000 buckets and every verified server ignores;
  • objects in an archive storage class, which answer a read with 403 InvalidObjectState;
  • the checksum headers AWS SDKs send by default;
  • keys with ., .. or // segments, which AWS accepts and MinIO, Silo and RustFS refuse.

Cloudflare R2 signs for the region auto, and DigitalOcean Spaces asks clients to sign for us-east-1; neither was tried. D265 is the follow-up.

Connection string

s3 has no connection string scheme, no readTls, no paste and no seed load check: every field of its row in CONNECTION_STRING is null (connection-string.ts).

Execution UI

The rolled-back notice names COMMIT as the keyword that keeps an open transaction, and s3 has no consent deadline, so the agent's consent card shows no deadline chip or sentence for it. Its row in EXECUTION_UI is declared in execution-ui.ts. What a tree click and Generate Query write is S3_GENERATORS, the s3 row of DIALECT_GENERATORS, declared in generators.ts and read by the engine-agnostic dispatcher in src/lib/query-generators.ts. Its editor language, s3, is the row in EDITOR_LANGUAGES declared in editor-language.ts: how the editor registers and tints it, and its completion source and formatter where it has one.

5. Query interface

The editor runs one AWS CLI read command per run, or Studio's own preview.

5.1 The command

The editor holds one AWS CLI read command per run, written as the AWS CLI writes it, read as POSIX shell words by the shared reader src/lib/db/console/shell-words.ts, the rule Oxia's console follows. A leading $ or % prompt and the word aws are stripped only when they stand bare; global options may come anywhere after them. A line ending in a backslash continues on the next line; a ^ line continuation, an environment assignment such as AWS_PROFILE=prod aws s3 ls, a pipe, a redirect and a second command are refused before any request. Studio's own preview is written without aws, so a line that starts with aws always pastes into the real AWS CLI.

5.2 Commands and flags

Command Flags
aws s3 ls [PATH] --recursive (no value); --page-size (1 to 1,000); --human-readable (no value); --summarize (no value); --bucket-name-prefix
aws s3api list-buckets --prefix; --max-items (1 to 500)
aws s3api list-objects-v2 --bucket (required); --prefix; --delimiter (/); --encoding-type (url); --max-items (1 to 500); --starting-token; --page-size (1 to 1,000)
aws s3api list-object-versions --bucket (required); --prefix; --delimiter (/); --encoding-type (url); --max-items (1 to 500)
aws s3api head-bucket --bucket (required)
aws s3api head-object --bucket (required); --key (required)
aws s3api get-object-tagging --bucket (required); --key (required)
aws s3api get-bucket-location --bucket (required)
aws s3api get-bucket-versioning --bucket (required)
preview PATH --format (text or json or ndjson or csv or tsv or parquet or hex); --columns; --max-rows (1 to 500); --schema (no value)

A PATH is s3://bucket/key or bucket/key; ls with no path lists buckets. --endpoint-url and --region are accepted only when they name the connection's own endpoint and region, and change nothing; --output, --color, --no-cli-pager and --no-cli-auto-prompt are accepted and change nothing, with a notice that says so. A flag given twice, an abbreviated flag, a value that begins with - written as a separate word, a file:// or fileb:// value and a -- word are refused; --prefix=-x passes a value that begins with -. A key, prefix or bucket-name prefix longer than 1,024 bytes is refused, and so is a word that holds a NUL character. A preview --columns list that names a column twice, or names more columns than the preview's maxColumns, is refused in the editor, with the sentences "--columns names a column more than once: name each column once." and "The column list holds more than 1,024 names.". On a pinned connection, a command that names another bucket is refused, and a bucket listing answers the pinned bucket with no request.

5.3 Refused commands

Every refusal comes before any request, and a refused command is never sent and never written to query history.

Input Sentence
aws s3 rm s3://sales/2026/orders.csv rm writes, and Studio's S3 support reads only in this version: run it with the AWS CLI or your server's own tools.
aws s3 cp s3://sales/2026/orders.csv . cp writes, and Studio's S3 support reads only in this version: run it with the AWS CLI or your server's own tools. To read an object here, run preview s3://bucket/key.
aws s3 presign s3://sales/2026/orders.csv presign is refused: it signs a URL with this connection's secret key, and anyone who holds the URL can read the object without credentials until it expires.
aws s3api put-object --bucket sales --key a.txt put-object writes, and Studio's S3 support reads only in this version: run it with the AWS CLI or your server's own tools.
aws s3api get-object --bucket sales --key a.txt a.txt get-object writes the object to a local file, which Studio never does: run preview s3://bucket/key to see its first rows.
aws s3api get-object-attributes --bucket sales --key a.txt get-object-attributes is not read in this version: one of the servers Studio is verified on answers it with the whole object. Run aws s3api head-object for the size, ETag and storage class.
aws s3api select-object-content --bucket sales --key a.csv select-object-content runs SQL on the server, which this version does not: run preview s3://bucket/key to see an object's first rows.
aws s3api list-objects --bucket sales list-objects is the older listing: run list-objects-v2, which takes the same --bucket, --prefix and --delimiter.
aws s3api get-bucket-policy --bucket sales get-bucket-policy is not a read Studio runs on S3 in this version: it runs list-buckets, list-objects-v2, list-object-versions, head-bucket, head-object, get-object-tagging, get-bucket-location and get-bucket-versioning.
aws ec2 describe-instances ec2 is not S3: Studio's S3 console runs aws s3 ls, the aws s3api reads in the provider doc, and preview.
aws preview s3://sales/2026/orders.csv preview is Studio's own command, not the AWS CLI's: write it without aws.
aws s3 ls --profile prod --profile is refused: the Access key and Secret on the connection sign every request, and Studio reads no AWS profile or credentials file.
aws s3 ls --no-sign-request --no-sign-request is refused: a connection with a blank Access key and Secret sends unsigned requests, and one with a key pair signs every request.
aws s3 ls --debug --debug is refused: it prints request headers and signatures, which Studio never shows.
aws s3api list-buckets --query Buckets --query is refused in this version: Studio shows the whole result as a grid and runs no JMESPath expression.
aws s3api list-objects-v2 --bucket sales --start-after 2026/ --start-after is refused: with a delimiter some servers skip a whole folder after it, and others return an empty page. Use --starting-token to read on.
aws s3api list-object-versions --bucket sales --page-size 10 --page-size is refused on list-object-versions: Studio reads one page of versions, at most 500 entries, and does not read on in this version.
aws s3api head-object --bucket sales --key a.txt --version-id 1 head-object takes no option --version-id: it takes --bucket and --key.
aws s3api head-object --bucket sales --key a.txt --sse-customer-key k --sse-customer-key is refused: Studio does not read objects encrypted with a customer-provided key in this version, and a key typed here would be kept in the query history.
preview s3://sales/2026/orders.csv --columns region,region --columns names a column more than once: name each column once.

A command refused only on the server, because it names an endpoint, a region or a bucket other than the connection's, is not sent either; history keeps its text whole with the refusal sentence as its error, as it does for every statement the editor accepts.

5.4 Examples

Each block runs as written against the fixtures of section 11.3, and the live check runs each one.

aws s3api list-buckets
aws s3 ls s3://sales/2026/
aws s3 ls s3://sales/2026/ --recursive --human-readable --summarize
aws s3api head-object --bucket sales \
  --key 2026/orders.csv
aws s3api list-object-versions --bucket sales --prefix 2026/
aws s3api list-objects-v2 --bucket sales --prefix 2026/ --delimiter / --max-items 50
aws s3api get-object-tagging --bucket sales --key 2026/orders.csv
preview s3://sales/2026/orders.csv
preview s3://sales/2026/part-0.parquet --max-rows 20

5.5 Result shape

Every command answers one grid. ls on buckets has the columns creation_date and name; ls on objects has kind, last_modified, size, name and key, folders before objects within each page, and key is Studio's addition, the full key, so the next command can be written from it. list-buckets has Name and CreationDate; list-objects-v2 has Kind, Key, Size, LastModified, ETag and StorageClass; list-object-versions has Kind, Key, VersionId, IsLatest, LastModified, Size and ETag; head-bucket, head-object, get-bucket-location and get-bucket-versioning answer Field and Value rows; get-object-tagging answers Key and Value rows. s3api grids write timestamps as ISO 8601 UTC with milliseconds; ls grids write the AWS CLI's 19-character form, in UTC. An ETag is shown with its quotes, as the server and the AWS CLI show it; get-bucket-location shows the region the bucket is in, us-east-1 for an empty answer, where the real CLI prints the raw value. --max-items counts every row the grid shows, folder rows included, where the AWS CLI counts objects only. ls --human-readable writes the AWS CLI's spelling (1,280 bytes is 1.2 KiB), the Keys panel writes Studio's shared spelling (1.25 KB), and the Metadata part and the s3api grids write bytes.

A result can carry these notices, each worded as the console states it:

  • When the server sent a continuation token for its bucket list: The server holds more buckets than it listed in one answer, and Studio does not ask for the rest in this version: put a bucket's name in the Bucket field of the connection to read it.
  • When ls on a prefix reads to the end and finds nothing: No key and no folder begins with this prefix.
  • When head-bucket gets no region header: The bucket exists and this connection may read it: the server sent no region for it.
  • When get-bucket-versioning gets no Status: Versioning was never turned on for this bucket: S3 answers no Status.
  • When get-object-tagging finds no tags: The object has no tags.
  • When get-object-tagging finds more tags than a result holds: The object has {total} tags, and the result holds the first {shown}, the most a Studio result holds.
  • When list-object-versions holds more versions than the grid shows: The server holds more versions than this page shows: narrow --prefix to see the rest.
  • When head-object gets more headers than Studio reads: The server sent more headers than Studio reads, so some fields may be missing.

5.6 Bounds

Constant Value What it bounds
S3_MAX_TEXT_BYTES 65,536 Bytes of UTF-8 in one console command; a longer text is refused in the editor and answered 413 by the query route
S3_RESULT_MAX_ROWS 500 Rows in one console result, and the largest --max-items and --max-rows
S3_KEY_SCAN_MAX_COUNT 1,000 Keys S3 answers in one page, and the largest --page-size
S3_PREVIEW_DEFAULT_ROWS 100 Rows preview and the Source tab show without --max-rows
S3_KEY_MAX_BYTES 1,024 Bytes of UTF-8 in a key or a prefix
S3_CELL_CHARS 65,536 Characters in one grid cell
S3_MAX_PAGES_PER_RUN 50 Requests one ListObjectsV2 run sends
S3_MAX_TOKEN_CHARS 32,800 Characters of a --starting-token: the widest form Studio writes of the longest service token the client reads
S3_CURSOR_TOKEN_MAX_CHARS 4,096 Characters of a server continuation token Studio reads on from
S3_MAX_BUCKET_BYTES 255 Bytes of a --bucket value
S3_ECHO_WORD_CHARS 40 Characters of a typed word a refusal echoes
S3_MAX_BUCKETS_READ 10,000 Buckets one ListBuckets asks for
S3_BUCKET_LIST_RESPONSE_BYTES 4,194,304 Bytes of a ListBuckets answer
S3_LIST_RESPONSE_BYTES 8,388,608 Bytes of one ListObjectsV2 or ListObjectVersions page
S3_SMALL_RESPONSE_BYTES 65,536 Bytes of an error, location, versioning or tagging answer
S3_XML_MAX_DEPTH 16 Nesting of an XML answer
S3_XML_MAX_ELEMENTS 100,000 Elements of an XML answer
S3_SURFACE_DEADLINE_MS 10,000 Milliseconds for a tree, Keys panel, Source tab or connect call
S3_HEALTH_DEADLINE_MS 5,000 Milliseconds for a health check
S3_SERVER_TEXT_CHARS 300 Characters of a server message a sentence quotes
S3_SHOWN_NAME_CHARS 120 Characters of a bucket or key name a sentence shows

A console run is bounded by the connection's query timeout.

5.7 Pagination and the starting token

Only ListObjectsV2 reads on: ls on a path and list-objects-v2 stop at --max-items rows (500 by default), at the server's last page, or after 50 requests, and the result's notice gives the --starting-token that reads on, in the AWS CLI's own token form, so a token written by either one resumes in the other. ls, which takes no --starting-token in the AWS CLI, names the equivalent list-objects-v2 command instead. A --starting-token whose service token is longer than S3_CURSOR_TOKEN_MAX_CHARS, the most the client reads from a server, is refused in the editor, before any request. When --max-items falls inside the first page, the AWS CLI writes a token with a null service token and a truncate amount; Studio resumes it as the CLI does: the first request carries no continuation token, and that page's first objects, as many as the amount, and its folders are dropped. Reuse a token only with the command whose notice gave it: a token from another command, bucket or prefix is not refused by Studio and gets the server's answer, which differs by server. ListBuckets and ListObjectVersions read one page each and give no token; a bucket listing narrows with --prefix, and a version listing with a narrower --prefix. There is no Load More: the next page is a run of the command with the token, so the command text shows exactly what ran, in history too. A server that sends the same next token twice stops the run with: The server sent the same next token twice, so reading on would repeat a page: Studio stopped. Report this to the server's maintainers.

5.8 Cancellation and the confirmation gate

Cancel aborts every request of the run at once. The confirmation gate never asks on S3: the console declares no destructive operation, because it runs reads only, and a command is never posted for an AI safety analysis, because it names buckets and keys.

5.9 SQL grammar

How this engine's statement text is read is declared once, in src/lib/db/providers/objectstore/s3/sql-grammar.ts, the s3 row of the SQL grammar registry in src/lib/db/sql-grammar/. readsSqlText is false: s3 takes one AWS CLI subset read command, or Studio's own preview, words split by POSIX shell rules, and a SQL span reader would report its quoting as unreadable and prompt on every run. The confirmation gate therefore answers from this engine's row of NON_SQL_DESTRUCTIVE_VOCABULARY (src/lib/db/providers/objectstore/s3/destructive.ts, collected in src/lib/db/destructive-commands.ts) instead of a SQL reading. Its grammar is DEFAULT_SQL_GRAMMAR, the compatibility default, since no SQL grammar of its own applies to this text. Redaction is none: a redacted statement is stored as ?, because a reader guessing where a literal ends in a language it does not read can store the literal.

6. Schema introspection

Buckets are objects in the tree; folders and objects are walked in the Keys panel.

6.1 The object surface

Two kinds: bucket, listed in the tree with no container level above it, or the pinned bucket alone, and the object kind, which the Keys panel lists and the Source tab opens. Objects are listed in the Keys panel and with aws s3 ls in the console. listObjects() lists buckets only; a bucket's count is the listing itself, and objects are never counted.

6.2 Object source

readObjectSource() opens a bucket or an object in the Source tab. A bucket shows three parts: the bucket and the region it is signed for, its location, and its versioning state. An object shows its Metadata part, then its preview:

  1. Metadata: size, ETag (with the -<parts> suffix kept as the server sent it), Last-Modified, Content-Type as stored, Content-Encoding as stored, every x-amz-meta-* header (a value whose bytes are valid UTF-8 is shown as that text; any other is shown as received), the version id when the server sends one.
  2. Preview, chosen by extension, then magic bytes, then a Content-Type hint for printable text with no known extension; Content-Type never selects Parquet. Content-Type is only a hint because a server stores whatever the uploader sent: an upload with no type is stored as binary/octet-stream on MinIO and the same NDJSON file reads application/octet-stream or application/x-ndjson depending on the client that wrote it. Text, JSON, NDJSON, CSV and TSV show as text or rows; Parquet shows rows through hyparquet with Snappy, gzip, zstd, brotli and LZ4 pages; anything else shows as a hex dump. The preview becomes one to three Source parts: preview, or schema and rows for a Parquet file, then preview-notes. A 0-byte object, or a gzip object whose decoded content is empty, gets one preview part carrying the sentence that says so, because nothing failed. A Parquet file whose first row group does not fit the preview's read, decode, leaf-column or value limits shows its schema, row count and column statistics with the sentence the preview module exports. An object stored with Content-Encoding: gzip (or a .gz key) is decoded one layer, up to the decoded-text cap, with the notice the preview module exports; any other Content-Encoding is shown as stored bytes in hex. A gzip object counts as read whole only when its stream reaches its end, trailer included, on a whole stored object; a stream cut short is shown as a cut read.

A key Studio can list but not address in path style is listed and refused when opened, with its reason.

Object preview

Detection order: the key's extension, then the magic bytes 1f 8b (gzip) and PAR1 (Parquet), then a Content-Type hint for printable text with no known extension; preview --format overrides detection. Each format reads one range of the object: text, JSON, NDJSON, CSV, TSV and hex one GET; gzip one GET of the stored bytes; Parquet one GET of the tail, a second only when the footer is longer than the tail, then one GET per run of planned column chunks that lie at most 8 KiB apart. The bytes between two chunks of one run are read with them, because hyparquet reads the chunks of the selected columns that lie that close in one slice, as Arrow C++ before 18 and Polars write a copy of each chunk's metadata after it. Every ranged answer is checked against the HEAD: its ETag and total size must match, or the preview says the object changed. A 416 to a range inside the size the HEAD reported reads: The server answered 416 Range Not Satisfiable to a range inside the size its HEAD reported, so the object changed while it was previewed; preview it again. Parquet footers and page headers pass a structural Thrift check and declared-size checks before hyparquet reads them, so decompression and value-count bombs are refused. Snappy pages decode through Studio's own decoder, which checks each literal and copy against the input and the page's declared size before it writes a byte. At most two Parquet previews decode at a time on one Studio server, separate from the request limiter, and a preview that finds the decode queue full is refused with a sentence. Not decoded: Content-Encodings other than gzip, LZO, Parquet stored under a whole-object gzip, and VARIANT columns. Legacy LZ4 pages are decoded, but no file from a real writer was measured with them.

The Parquet value and leaf-column limits were measured: each test shape was decoded once in a fresh Node 26 process with a 128 MiB old-space limit, so two decodes fit the image's 384 MiB heap.

Limit Value Basis
textFetchBytes 1,000,000 A Source part holds at most 1,000,000 UTF-16 units, and valid UTF-8 never decodes to more units than it has bytes
gzipFetchBytes 1,000,000 Equal to the text read: the compressed bytes fetched for a gzip layer
decodedTextBytes 1,000,000 The gunzip stops here and is sliced to exactly this many bytes
hexBytes 65,536 Oxia's Source hex dump bound
parquetTailBytes 65,536 One read covered every measured footer
parquetFooterMaxBytes 1,048,576 About 11,000 column chunks at the measured 90 bytes each
parquetFetchBudget 8,388,608 Kept from the design; the decode runs did not vary it, they bounded the value and leaf-column limits
parquetDecodeBudget 33,554,432 Four times the fetch budget, kept from the design; the decode runs did not vary it
parquetMaxPageValues 524,288 Measured: a dictionary page of 2,097,152 values ran out of the 128 MiB limit and one of 1,048,576 decoded inside it; lowered to the total value bound, since a page bound above it never applies
parquetMaxChunkValues 524,288 Measured: a chunk of 2,097,152 boolean values ran out of the 128 MiB limit and one of 1,048,576 decoded inside it; lowered to the total value bound, since a chunk bound above it never applies
parquetMaxTotalValues 524,288 Measured: 262,144 rows of four 64-bit list values, twice this bound, ran out of the 128 MiB limit; every test shape decoded at this bound
parquetMaxLeafColumns 128 Measured: 128 of 1,023 columns decoded inside the 128 MiB limit and 256 of 2,047 ran out of it; the schema walk refuses a schema of more than eight times this, 1,024 elements
parquetMaxSchemaDepth 64 The schema walk recurses once per level
thriftMaxDepth 32 Parquet's file metadata nests six levels
sourceRows 100 100 rows of a 60-column file fit one Source part
defaultRows 100 The same as the Source tab, so both show the same rows
maxRows 500 The bound every console result keeps
cellChars 65,536 The cell bound of the console
summaryCellChars 256 The statistics cells of a Parquet summary
cellMaxDepth 64 Nesting written before the rest of a value becomes ...
outputChars 4,194,304 Holds 1,000 rows of 60 columns as measured
maxColumns 1,024 Bounds a row format's columns and the top-level Parquet columns taken; a CSV, TSV, JSON or NDJSON record keeps at most eight times this, 8,192 fields, and only counts the rest
csvSniffRecords 20 Records read to choose a CSV delimiter

Notices and refusals, exactly as the preview writes them, with {name} standing for a value:

Id Sentence
N-CUT The preview shows the first {shown} bytes of {size} bytes.
N-NOT-UTF8 The object is not UTF-8 text, so it is shown as hex.
N-BLANK The object holds only blank characters, so it is shown as hex.
N-JSON-INVALID The object is not valid JSON, so it is shown as text.
N-JSON-CUT The JSON document is larger than the {n} bytes a preview reads, so it is shown as text and not parsed.
N-LINE-CUT The last line was cut by the {n}-byte read and is not shown.
N-NDJSON-BAD {k} line(s) are not JSON and are shown as text in column {col}.
N-RECORD-CUT The last record was cut by the {n}-byte read and is not shown.
N-CSV-QUOTE {k} field(s) have characters after their closing quote; they are shown as read.
N-CSV-UNCLOSED The last record has a quote that is never closed; it is shown up to the end of the object.
N-CSV-RAGGED {k} record(s) have a different number of fields from the header: missing fields are empty and extra fields are shown in columns named by position.
N-ROWS The preview stops at {cap} rows.
N-OUTPUT The preview stopped after {shown}, at {cap} characters of cell text.
N-CELLS {k} cell(s) were cut at {cap} characters.
N-COLUMNS Showing the first {cap} of {n} columns.
N-GZIP-EMPTY The object is gzip-compressed and its decoded content is empty.
N-GZIP The object is gzip-compressed; the preview shows the first {d} bytes of its decoded content, from the first {f} stored bytes.
N-GZIP-BAD The object is marked as gzip but its bytes are not gzip data, so its stored bytes are shown as hex.
N-GZIP-NESTED The decoded content is itself gzip data; the preview decodes one layer, so it is shown as hex.
N-ENC-OTHER The object is stored with Content-Encoding {coding}, which the preview does not decode, so its stored bytes are shown as hex.
N-PQ-MAGIC The object does not end with the Parquet marker PAR1, so it is not a Parquet file; it is shown as hex.
N-PQ-SOME-COLUMNS Showing {k} of {n} columns: the next column would take the first row group's read past {fetchBudget} MiB, its decoded size past {decodeBudget} MiB, its leaf columns past {leafCap} or its values past {valueCap}, the most a preview reads.
N-PQ-MAX-COLUMNS Showing the first {k} of {n} columns, the most a preview shows.
N-PQ-CODEC Column {c} is compressed with {codec} or stored in another file, which the preview cannot read, so the columns from it on are not shown.
N-PQ-VARIANT Column {c} holds VARIANT values, which the preview does not decode, so the columns from it on are not shown.
N-PQ-NONE-FIT The first row group is too large to preview: its first column stores {fetch} MiB ({decode} MiB decoded), over the {fetchBudget} MiB read and {decodeBudget} MiB decode budgets, or holds more leaf columns or values than a preview reads. The schema, row count and first-row-group statistics are shown instead.
N-PQ-NO-ROWS The file holds no rows.
N-PQ-RG0 Rows come from the first row group, which holds {m} rows.
N-PQ-DECIMAL Column {c} is DECIMAL({p},{s}): values with more than 15 significant digits are shown rounded.
N-PQ-ENCODED A Parquet file stored compressed as a whole cannot be read by range, so the decoded bytes are shown as hex.
N-PQ-SUMMARY {rows} in {groups}; statistics are the first row group's.
N-HINT The console's preview command shows these rows as a grid: preview {path}
R-PQ-FOOTER-BIG The Parquet footer is {n} bytes, over the {footerMax} bytes a preview reads.
R-PQ-SCHEMA The Parquet schema is nested deeper or wider than the preview reads, so the file is not previewed.
R-PQ-SCHEMA-NAMES The Parquet schema gives two columns of one group the same name, so the file is not previewed.
R-PQ-SCHEMA-DECIMAL The Parquet schema declares a DECIMAL type without its precision or its scale, so the file is not previewed.
R-PQ-FOOTER-LONG The Parquet footer declares {n} bytes, more than the object holds.
R-PQ-FOOTER-BAD The Parquet footer could not be read: {reason}.
R-PQ-CHUNK-RANGE The Parquet footer places column {c} outside the file's data, so the file is not previewed.
R-PQ-PAGES The pages of column {c} do not fill its column chunk, so the file is not previewed.
R-PQ-PAGE-VALUES A page of column {c} declares {v} values, more than its column chunk holds or the preview allows, so the file is not previewed.
R-PQ-CHUNK-VALUES The data pages of column {c} declare {v} values, more than its column chunk holds or the preview allows, so the file is not previewed.
R-PQ-TOTAL-VALUES The pages of the columns to show declare {v} values, more than a preview allows, so the file is not previewed.
R-PQ-PAGE-DECODE The pages of the columns to show declare {d} MiB decoded, over the {decodeBudget} MiB a preview decodes, so the file is not previewed.
R-PQ-DECODE The Parquet data could not be decoded.
R-PQ-BUSY The server is decoding other Parquet previews; preview this object again in a moment.
R-PQ-NO-COLUMN The Parquet file has no top-level column {c}.
R-PQ-COLUMN-CODEC Column {c} is compressed with {codec} or stored in another file, which the preview cannot read.
R-PQ-COLUMN-VARIANT Column {c} holds VARIANT values, which the preview does not decode.
R-PQ-COLUMNS-BIG The columns asked for store {fetch} MiB ({decode} MiB decoded) in {leaves} leaf columns and {values} values in the first row group, over the preview's {fetchBudget} MiB read, {decodeBudget} MiB decode, {leafCap} leaf columns or {valueCap} values; ask for fewer columns.
R-COLUMN The preview has no column {c}.
R-COLUMNS-REPEATED The column list names {c} twice.
R-COLUMNS-MANY The column list holds more than {most}.
R-SCHEMA --schema applies only to a Parquet object.
R-MAX-ROWS --max-rows takes a whole number from 1 to {cap}, the most rows a Studio result holds.
R-ROWS The object's rows could not be built, so it is shown as text.

Known limits of the preview: a repeated JSON member keeps its last value; a DECIMAL above 15 significant digits is rounded; statistics are the first row group's, and rows come from the first row group only; timestamps are shown without a zone mark.

6.3 Generated commands

No shipped click reaches the generated commands in v1: a bucket or object row opens its Source tab. For a bucket the generators write aws s3 ls s3://<bucket>/, with aws s3api list-objects-v2 and aws s3api get-bucket-versioning lines as comments; for an object they write preview s3://<bucket>/<key>, with an aws s3api head-object comment. They take the provider's own path, [<bucket>] for a bucket and [<bucket>/<key>] for an object. A key that begins with - is written in the --flag=value form; a key holding a carriage return or a NUL character gets a comment that says to open it from the Keys panel instead.

6.4 The Keys panel

A bucket is chosen in the tree; Browse Keys on its row switches the sidebar to the Keys view with that bucket's top level open, because the sidebar shows the Objects view or the Keys view and never both. The Keys panel lists that bucket's top level, folders first, then objects with their size; a folder opens its own level, read from the server's CommonPrefixes one level at a time and complete; each level has its own Load more. With no bucket chosen, the top level lists the buckets as folders, or the pinned bucket alone. No count is published. A key the provider can list but not address is listed and refused when opened, with its reason. A level's Load more cursor is valid in every process that serves the connection, so it survives a restart, an idle eviction and another replica. Each row is drawn by its own segment rather than by the whole key, like the folder rows beside it, and its full key stays the row's title: a level walk draws a row per level with the ancestors above it on screen, so the whole key on every row repeats what the reader can already see and clips before the part that differs (measured on OPC UA, which shares this walk: eight sibling rows drew the same 59 leading characters at the sidebar's width). A key whose name ends in the separator keeps its full name, because its own last segment is empty. The embedded workspace has no Keys panel.

Declared by getCapabilities(): separator /, cursor opaque, pattern prefix, totalScope none, levels.rootKind bucket, defaultCount 500, maxCount 1,000; containerLevels is empty, so a key walk refuses a database field.

6.5 Object edit (#789): nothing to write

S3 declares no editable object kind and no write path; an object's Source tab is read-only.

7. Monitoring & health

Test Connection and every connect send one probe, and the answer must be an S3 listing. With a pinned bucket, the probe is ListObjectsV2 on that bucket with max-keys=1, delimiter=/ and encoding-type=url, and its answer must parse as ListBucketResult. With no pinned bucket, the probe is ListBuckets, and its answer must parse as ListAllMyBucketsResult. A connection whose probe did not parse as S3 XML never reads an object, so a host that answers HTTP but does not speak S3 shows no body. Both probes need the permission browsing needs (s3:ListBucket on a pinned bucket), so a key that can browse can always pass the test. A 403 from the probe never says that the pinned bucket exists.

On a handle opened for an agent-mode profile the probe goes through the agent's client (section 15.1): ListBuckets with no pin, and with a pin one whole-bucket page of one entry, with an empty prefix and no delimiter, whose entry is dropped. Health there is that probe, and its database size is the reason S3 reports none; the overview, storage, table, index, slow-query, session and performance readings answer their reason rather than an empty reading or a zero, and a failure is worded by its category, never by the server's words.

8. Maintenance

S3 declares no maintenance operation, and runMaintenance() refuses before any request: Studio sends an S3 server no maintenance: this version only reads.

9. Capabilities & labels

getCapabilities() and getLabels() answer exactly the two objects below; the doc test compares both with the built provider.

{
  "queryLanguage": "json",
  "queryDialect": "s3",
  "supportsExplain": false,
  "supportsExternalQueryLimiting": false,
  "supportsInlineRowEdit": false,
  "supportsTestDataGeneration": false,
  "supportsResultPagination": false,
  "supportsTransactions": false,
  "declaresForeignKeys": false,
  "tablesAreDerivedGroupings": false,
  "writeReach": {
    "editor": {
      "reach": "absent",
      "proof": {
        "module": "src/lib/db/providers/objectstore/s3/console/commands.ts",
        "symbol": "S3_COMMAND_TABLE"
      }
    },
    "objectEdit": {
      "reach": "absent"
    },
    "maintenance": {
      "reach": "absent"
    },
    "transaction": {
      "reach": "absent"
    }
  },
  "supportsMaintenance": false,
  "maintenanceOperations": [],
  "supportsConnectionString": false,
  "defaultPort": 9000,
  "statementTerminator": "none",
  "containerLevels": [],
  "objectKinds": [
    {
      "id": "bucket",
      "role": "config",
      "label": "Bucket",
      "labelPlural": "Buckets",
      "hasSource": true,
      "sourceLanguage": "json",
      "countIsListing": true
    },
    {
      "id": "object",
      "role": "config",
      "label": "Object",
      "labelPlural": "Objects",
      "enumeratedBy": "key-browser",
      "hasSource": true,
      "sourceLanguage": "json"
    }
  ],
  "keyScan": {
    "defaultCount": 500,
    "maxCount": 1000,
    "separator": "/",
    "cursor": "opaque",
    "pattern": "prefix",
    "totalScope": "none",
    "levels": {
      "rootKind": "bucket"
    }
  },
  "schemaRefreshPattern": "(?!)",
  "metadataOnly": {
    "list-buckets": [
      {
        "column": "bucket",
        "kind": "name"
      },
      {
        "column": "created",
        "kind": "name"
      }
    ],
    "head-bucket": [
      {
        "column": "bucket",
        "kind": "name"
      },
      {
        "column": "exists",
        "kind": "flag"
      },
      {
        "column": "region",
        "kind": "name"
      }
    ],
    "get-bucket-location": [
      {
        "column": "bucket",
        "kind": "name"
      },
      {
        "column": "location",
        "kind": "name"
      }
    ],
    "get-bucket-versioning": [
      {
        "column": "bucket",
        "kind": "name"
      },
      {
        "column": "status",
        "kind": "name"
      },
      {
        "column": "mfa_delete",
        "kind": "name"
      }
    ],
    "summary": [
      {
        "column": "bucket",
        "kind": "name"
      },
      {
        "column": "objects",
        "kind": "count"
      },
      {
        "column": "total_size_bytes",
        "kind": "count"
      }
    ],
    "head-object": [
      {
        "column": "bucket",
        "kind": "name"
      },
      {
        "column": "exists",
        "kind": "flag"
      },
      {
        "column": "size_bytes",
        "kind": "count"
      },
      {
        "column": "last_modified",
        "kind": "name"
      },
      {
        "column": "storage_class",
        "kind": "name"
      }
    ]
  },
  "agentCredential": "dedicated-user"
}
{
  "entityName": "Bucket",
  "entityNamePlural": "Buckets",
  "rowName": "Object",
  "rowNamePlural": "Objects",
  "selectAction": "List Objects",
  "generateAction": "Generate Command",
  "analyzeAction": "Statistics",
  "vacuumAction": "Maintenance",
  "searchPlaceholder": "Search buckets...",
  "analyzeGlobalLabel": "Statistics",
  "analyzeGlobalTitle": "Not available",
  "analyzeGlobalDesc": "An S3 server keeps no statistics for Studio to update.",
  "vacuumGlobalLabel": "Maintenance",
  "vacuumGlobalTitle": "Not available",
  "vacuumGlobalDesc": "Studio sends an S3 server no maintenance: this version only reads.",
  "statementLanguage": "the AWS CLI read command this editor runs: exactly one command per run, written as the AWS CLI writes it, optionally after aws, with no pipe, no redirect, no second command, no environment assignment and no --profile, --endpoint-url, --region or credential option, because the connection decides where a command runs and who signs it; plan mode drafts only a read, one of aws s3 ls [PATH] [--recursive] [--page-size N] [--human-readable] [--summarize] [--bucket-name-prefix BUCKET_NAME_PREFIX], aws s3api list-buckets [--prefix PREFIX] [--max-items N], aws s3api list-objects-v2 --bucket BUCKET [--prefix PREFIX] [--delimiter /] [--encoding-type url] [--max-items N] [--starting-token STARTING_TOKEN] [--page-size N], aws s3api list-object-versions --bucket BUCKET [--prefix PREFIX] [--delimiter /] [--encoding-type url] [--max-items N], aws s3api head-bucket --bucket BUCKET, aws s3api head-object --bucket BUCKET --key KEY, aws s3api get-object-tagging --bucket BUCKET --key KEY, aws s3api get-bucket-location --bucket BUCKET, aws s3api get-bucket-versioning --bucket BUCKET, with --max-items at most 500; Studio's own preview s3://BUCKET/KEY shows an object's first rows and is written without aws; the inventory names buckets only and never an object key, so a key or a prefix in a drafted command is one the request itself names; for example aws s3 ls s3://sales/2026/ or aws s3api head-object --bucket sales --key 2026/orders.csv; every write (cp, mv, rm, sync, mb, rb, and the s3api put, delete, create, copy and upload operations) and presign is the user's to run with the AWS CLI and is never drafted",
  "slowQueriesEmptyState": "S3 keeps no query log",
  "sessionsEmptyState": "S3 does not list client sessions",
  "tableStatsCaption": "S3 has no tables: buckets are listed under Buckets, and objects in the Keys panel and with aws s3 ls in the console."
}

9.1 SQL writing

s3/sql-writing.ts declares how a generated statement spells a literal, a name and a bind for this engine, and it is this engine's row of SQL_WRITING (src/lib/db/sql-writing/index.ts), which the readers in src/lib/sql/values.ts and src/lib/sql/identifier.ts look up. A literal is written by literalStandard and read back by unquoteStandard: the quote is doubled and a backslash is data. A name is quoted by identifierDoubleQuoted, the SQL standard form: a double-quoted name with an inner double quote doubled. There is no placeholder (placeholder: null), so a generator quotes the value with quoteLiteral instead of emitting a bind. No generator calls quoteLiteral for S3: the console's own quoting writes every word, so the row is the inert answer Oxia's is. Inline row editing reads a key column's kind through the shared INSTANT_TYPE_NAMES, FLOAT64_TYPE_NAMES and EXACT_DECIMAL_TYPE_NAMES of the module's inlineEdit, and binds every edit as typed.

The module's export field writes a result export's CREATE TABLE and INSERT from the shared building blocks alone: STANDARD_DDL_TYPES for the column types, NOTHING_STANDS_ALONE and NO_BARE_SPELLING, so every bare declared name is re-spelled from its family, NULL_NON_FINITE for NaN and the infinities, and binaryStandardHex for a binary cell (X'0102'). The engine declares queryLanguage: "json", so no SQL statement is ever built for it to read, and a file exported from it is meant to run somewhere else: every bare name in it is re-spelled portably rather than kept as one engine's private word.

10. Error handling

Every failure is one sentence; the rows below are the sentences for a signed connection to http://localhost:9000 that signs for us-east-1, with the bucket sales and the key 2026/orders.csv. A server message a sentence quotes is cut to 300 characters.

Server answer Studio's sentence
403 SignatureDoesNotMatch The server refused the request signature: check Secret access key. A proxy between Studio and the server that changes the Host header or the request path causes the same refusal.
403 InvalidAccessKeyId The server does not know this access key ID: check Access key ID.
403 AccessDenied on a bucket This access key may not list bucket "sales" (s3:ListBucket). The server answers the same way for a bucket that does not exist, so this does not say that bucket "sales" exists.
404 NoSuchBucket The server has no bucket "sales".
404 NoSuchKey The server has no object "2026/orders.csv" in bucket "sales".
400 AuthorizationHeaderMalformed naming another region This server expects requests signed for region eu-central-1, and this connection signs for us-east-1: set Region to eu-central-1.
403 RequestTimeTooSkewed This machine's clock and the server's differ by more than the server accepts: correct the clock on this machine or on the server.
400 XMinioInvalidResourceName The server refuses a name with a . or .. segment or an empty segment, here in the key or the prefix: type it without such a segment.
501 NotImplemented on a continuation token The server refused this page's continuation token: list this folder again from its start.
HEAD 403 with no body and no code This access key may not read object "2026/orders.csv" in bucket "sales" (s3:GetObject). The server answers the same way for an object that does not exist, so this does not say that object "2026/orders.csv" exists.
501 NotImplemented on list-object-versions This server does not keep object versions: it answered 501 Not Implemented to ListObjectVersions.
Any 3xx The endpoint answered list buckets with a redirect (HTTP 301), which Studio does not follow. Check that Host and Port name the S3 API, not a web console: MinIO and RustFS serve their consoles on another port. An AWS bucket in another region answers this way when the server does not name the region: set Region to the bucket's region.

A 403 never says that a bucket or an object exists: MinIO, Silo and RustFS answer a missing bucket and a forbidden one with the same 403. A missing bucket or object is said only on a 404. A HEAD error carries no body; on a HEAD 400 or 403 the provider makes one follow-up read to learn the reason, and reports clock skew only from the server's code or its date header, never from the status alone, because MinIO and Silo send no code header on skew alone while RustFS and Garage send none on any HEAD error. A wrong region on a GET names the region the server expects when the server says it; Garage's bare HEAD 400 says to check Region. A cursor of another shape or scope is refused before any request; a hand-written cursor of the right shape and scope can reach the server, and its 400 or 501 is the continuation-token row. Refused before any connection, each with its own sentence:

  • An endpoint in a link-local network (section 3.4), where the transport says: Invalid host: this connection never reaches a link-local address or AWS's IPv6 instance metadata address, whatever DB_HTTP_BLOCK_PRIVATE_HOSTS says.
  • Plain HTTP to a host that is not this machine without consent (section 4.6), quoted whole: This connection would reach a host that is not this machine over plain HTTP, where anyone on the path can read bucket and object names, listings and previews, and replay a signed request. Choose an SSL mode under SSL / TLS, connect through an SSH tunnel, or tick Connect without TLS. Nothing was sent.
  • With the HTTP egress guard on, a loopback or private address.

A 416 from a ranged read is not an error of this table: the preview reports it with its own sentence (section 6.2).

A server's own message is shown only when Studio's sentence quotes it: a signing-scope refusal that names no other region, a server failure, and an error code Studio does not recognise; it follows Studio's sentence, cut to the length the provider exports, and a text that holds the secret access key or the access key ID in any form Studio sends is withheld whole. The secret access key never reaches an error, a result, a log line, an audit row or a notice.

11. Testing

The tests run with no server; the live fixtures and the live check run by hand.

11.1 How the tests work

Each module has its unit test under tests/unit/db/s3/, and none mocks a module. tests/integration/db/s3-provider.test.ts replays exchanges recorded from the four servers, so CI needs no server. tests/unit/db/s3/provider-doc.test.ts reads every number and sentence this page quotes back from the code.

11.2 Run it

bun tests/run-tests.ts tests/unit/db/s3/
bun tests/run-tests.ts tests/integration/db/s3-provider.test.ts

11.3 The live fixtures

database-compose.yml holds the four servers, a MinIO with a site region and a Silo with TLS, with their seeds; docker/s3/README.md says how to start and seed each, which keys exist and what every seeded object is for. MinIO is built from source behind its own profile, because MinIO publishes no community image any more.

11.4 The evidence harness and the live check

tests/live/s3-evidence.ts records the captures the integration test replays, and tests/live/s3-live-check.ts runs every row of the acceptance matrix against one target. Both run by hand, never in CI. The evidence harness runs under Bun, and the live check under Bun, Node 24 and Node 26.10.0.

11.5 The acceptance matrix

The matrix below is rendered from S3_ACCEPTANCE in tests/live/s3-live-support.ts, and tests/unit/db/s3/live-environment.test.ts fails when the two differ.

Connection and Test Connection

Row Behaviour MinIO MinIO-R Silo Garage RustFS
A1 Test Connection, root, no pin (ListBuckets, whose answer must parse as ListAllMyBucketsResult) ok, 5 buckets ok, 5 buckets ok, 5 buckets ok, 5 buckets ok, 5 buckets
A2 Test Connection, root, pinned studio-demo: connect and the health read each send one ListObjectsV2 on the pin with max-keys=1, delimiter=/ and encoding-type=url, and no HEAD ok: two ListObjectsV2, connect's probe and the health read's, each with max-keys=1, delimiter=/ and encoding-type=url, no HEAD ok: two ListObjectsV2, connect's probe and the health read's, each with max-keys=1, delimiter=/ and encoding-type=url, no HEAD ok: two ListObjectsV2, connect's probe and the health read's, each with max-keys=1, delimiter=/ and encoding-type=url, no HEAD ok: two ListObjectsV2, connect's probe and the health read's, each with max-keys=1, delimiter=/ and encoding-type=url, no HEAD ok: two ListObjectsV2, connect's probe and the health read's, each with max-keys=1, delimiter=/ and encoding-type=url, no HEAD
A3 Pinned no-such-bucket, root (the ListObjectsV2 probe of A2) refused, the sentence for 404 NoSuchBucket to ListObjectsV2 naming no-such-bucket refused, the sentence for 404 NoSuchBucket to ListObjectsV2 naming no-such-bucket refused, the sentence for 404 NoSuchBucket to ListObjectsV2 naming no-such-bucket refused, the sentence for 404 NoSuchBucket to ListObjectsV2 naming no-such-bucket refused, the sentence for 404 NoSuchBucket to ListObjectsV2 naming no-such-bucket
A4 Wrong secret, no pin refused, the sentence for 403 SignatureDoesNotMatch to ListBuckets refused, the sentence for 403 SignatureDoesNotMatch to ListBuckets refused, the sentence for 403 SignatureDoesNotMatch to ListBuckets refused, the sentence for 403 AccessDenied "Forbidden: Invalid signature" to ListBuckets refused, the sentence for 403 SignatureDoesNotMatch to ListBuckets
A4p Wrong secret, pinned: the ListObjectsV2 probe carries the error code in its body, so connect sends one GET and no follow-up refused, the sentence for 403 SignatureDoesNotMatch to ListObjectsV2 refused, the sentence for 403 SignatureDoesNotMatch to ListObjectsV2 refused, the sentence for 403 SignatureDoesNotMatch to ListObjectsV2 refused, the sentence for 403 AccessDenied "Forbidden: Invalid signature" to ListObjectsV2 refused, the sentence for 403 SignatureDoesNotMatch to ListObjectsV2
A5 Unknown access key GKffffffffffffffffffffffff refused, the sentence for 403 InvalidAccessKeyId to ListBuckets refused, the sentence for 403 InvalidAccessKeyId to ListBuckets refused, the sentence for 403 InvalidAccessKeyId to ListBuckets refused, the sentence for 403 AccessDenied "Forbidden: No such key: GKffffffffffffffffffffffff" to ListBuckets refused, the sentence for 403 InvalidAccessKeyId to ListBuckets
A6 Connection region us-east-1 (the default), browse studio-demo ok (any region accepted) refused, the sentence for 400 AuthorizationHeaderMalformed to ListObjectsV2 naming eu-central-1 ok (any region accepted) refused, the sentence for 400 AuthorizationHeaderMalformed to ListBuckets naming garage ok (any region accepted)
A7 Connection region not-a-region (negative control), browse studio-demo ok refused, the sentence for 400 AuthorizationHeaderMalformed to ListObjectsV2 naming eu-central-1 ok refused, the sentence for 400 AuthorizationHeaderMalformed to ListBuckets naming garage ok, recorded as: RustFS does not enforce the region
A8 Clock 20 minutes ahead; on Garage also 25 hours behind refused, the sentence for 403 RequestTimeTooSkewed to ListBuckets refused, the sentence for 403 RequestTimeTooSkewed to ListBuckets refused, the sentence for 403 RequestTimeTooSkewed to ListBuckets ahead: ok (no future bound); behind: refused, the sentence for 400 InvalidRequest "Bad request: Date is too old" to ListBuckets refused, the sentence for 403 RequestTimeTooSkewed to ListBuckets
A9 Blank key pair, with every ambient AWS credential source set in the check's own process: the request carries no authorization and no x-amz header, and the canary listener counts no connection anonymous: refused, the sentence for 403 AccessDenied to ListBuckets; unsigned: no authorization, no x-amz header, no canary connection anonymous: refused, the sentence for 403 AccessDenied to ListBuckets; unsigned: no authorization, no x-amz header, no canary connection anonymous: refused, the sentence for 403 AccessDenied to ListBuckets; unsigned: no authorization, no x-amz header, no canary connection anonymous: refused, the sentence for 403 AccessDenied "Forbidden: Garage does not support anonymous access yet" to ListBuckets; unsigned: no authorization, no x-amz header, no canary connection anonymous: refused, the sentence for 403 AccessDenied to ListBuckets; unsigned: no authorization, no x-amz header, no canary connection
A10 Scoped principal pinned to studio-demo, then to no-such-bucket (the ListObjectsV2 probe) studio-demo: refused, the sentence for 403 AccessDenied to ListObjectsV2; no-such-bucket: refused, the sentence for 403 AccessDenied to ListObjectsV2 studio-demo: refused, the sentence for 403 AccessDenied to ListObjectsV2; no-such-bucket: refused, the sentence for 403 AccessDenied to ListObjectsV2 studio-demo: refused, the sentence for 403 AccessDenied to ListObjectsV2; no-such-bucket: refused, the sentence for 403 AccessDenied to ListObjectsV2 studio-demo: refused, the sentence for 403 AccessDenied to ListObjectsV2; no-such-bucket: refused, the sentence for 404 NoSuchBucket to ListObjectsV2 naming no-such-bucket studio-demo: refused, the sentence for 403 AccessDenied to ListObjectsV2; no-such-bucket: refused, the sentence for 403 AccessDenied to ListObjectsV2
A11 Getonly principal, no pin: ListBuckets, open studio-demo, head-object of data/table.csv; then Test Connection pinned to studio-demo list: buckets listed, filtered by the policy; open: refused, the sentence for 403 AccessDenied to ListObjectsV2; head: head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType; pinned: refused, the sentence for 403 AccessDenied to ListObjectsV2 list: buckets listed, filtered by the policy; open: refused, the sentence for 403 AccessDenied to ListObjectsV2; head: head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType; pinned: refused, the sentence for 403 AccessDenied to ListObjectsV2 list: buckets listed, filtered by the policy; open: refused, the sentence for 403 AccessDenied to ListObjectsV2; head: head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType; pinned: refused, the sentence for 403 AccessDenied to ListObjectsV2 list: not applicable: the none key sees no bucket; A12 covers it; open: not applicable: the none key sees no bucket; A12 covers it; head: not applicable: the none key sees no bucket; A12 covers it; pinned: refused, the sentence for 403 AccessDenied to ListObjectsV2 list: buckets listed, filtered by the policy; open: refused, the sentence for 403 AccessDenied to ListObjectsV2; head: head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType; pinned: refused, the sentence for 403 AccessDenied to ListObjectsV2
A12 Scoped principal, no pin: ListBuckets; on Garage also the none key studio-scoped only studio-scoped only studio-scoped only scoped: scoped-local and studio-scoped, the same bucket under two names; none: an empty list studio-scoped only
A55 Endpoints 169.254.169.254, 169.254.0.1, [fe80::1], [fd00:ec2::254], [::ffff:169.254.169.254] and [64:ff9b::a9fe:a9fe], with Connect without TLS ticked and DB_HTTP_BLOCK_PRIVATE_HOSTS unset and again false; then 2852039166 and 0xa9fea9fe 169.254.169.254 unset: refused before any request, the link-local sentence for 169.254.169.254; 169.254.169.254 false: refused before any request, the link-local sentence for 169.254.169.254; 169.254.0.1 unset: refused before any request, the link-local sentence for 169.254.0.1; 169.254.0.1 false: refused before any request, the link-local sentence for 169.254.0.1; fe80::1 unset: refused before any request, the link-local sentence for fe80::1; fe80::1 false: refused before any request, the link-local sentence for fe80::1; fd00:ec2::254 unset: refused before any request, the link-local sentence for fd00:ec2::254; fd00:ec2::254 false: refused before any request, the link-local sentence for fd00:ec2::254; ::ffff:169.254.169.254 unset: refused before any request, the link-local sentence for ::ffff:169.254.169.254; ::ffff:169.254.169.254 false: refused before any request, the link-local sentence for ::ffff:169.254.169.254; 64:ff9b::a9fe:a9fe unset: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 64:ff9b::a9fe:a9fe false: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 2852039166: refused before any request, the shared invalid-host sentence; 0xa9fea9fe: refused before any request, the shared invalid-host sentence 169.254.169.254 unset: refused before any request, the link-local sentence for 169.254.169.254; 169.254.169.254 false: refused before any request, the link-local sentence for 169.254.169.254; 169.254.0.1 unset: refused before any request, the link-local sentence for 169.254.0.1; 169.254.0.1 false: refused before any request, the link-local sentence for 169.254.0.1; fe80::1 unset: refused before any request, the link-local sentence for fe80::1; fe80::1 false: refused before any request, the link-local sentence for fe80::1; fd00:ec2::254 unset: refused before any request, the link-local sentence for fd00:ec2::254; fd00:ec2::254 false: refused before any request, the link-local sentence for fd00:ec2::254; ::ffff:169.254.169.254 unset: refused before any request, the link-local sentence for ::ffff:169.254.169.254; ::ffff:169.254.169.254 false: refused before any request, the link-local sentence for ::ffff:169.254.169.254; 64:ff9b::a9fe:a9fe unset: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 64:ff9b::a9fe:a9fe false: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 2852039166: refused before any request, the shared invalid-host sentence; 0xa9fea9fe: refused before any request, the shared invalid-host sentence 169.254.169.254 unset: refused before any request, the link-local sentence for 169.254.169.254; 169.254.169.254 false: refused before any request, the link-local sentence for 169.254.169.254; 169.254.0.1 unset: refused before any request, the link-local sentence for 169.254.0.1; 169.254.0.1 false: refused before any request, the link-local sentence for 169.254.0.1; fe80::1 unset: refused before any request, the link-local sentence for fe80::1; fe80::1 false: refused before any request, the link-local sentence for fe80::1; fd00:ec2::254 unset: refused before any request, the link-local sentence for fd00:ec2::254; fd00:ec2::254 false: refused before any request, the link-local sentence for fd00:ec2::254; ::ffff:169.254.169.254 unset: refused before any request, the link-local sentence for ::ffff:169.254.169.254; ::ffff:169.254.169.254 false: refused before any request, the link-local sentence for ::ffff:169.254.169.254; 64:ff9b::a9fe:a9fe unset: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 64:ff9b::a9fe:a9fe false: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 2852039166: refused before any request, the shared invalid-host sentence; 0xa9fea9fe: refused before any request, the shared invalid-host sentence 169.254.169.254 unset: refused before any request, the link-local sentence for 169.254.169.254; 169.254.169.254 false: refused before any request, the link-local sentence for 169.254.169.254; 169.254.0.1 unset: refused before any request, the link-local sentence for 169.254.0.1; 169.254.0.1 false: refused before any request, the link-local sentence for 169.254.0.1; fe80::1 unset: refused before any request, the link-local sentence for fe80::1; fe80::1 false: refused before any request, the link-local sentence for fe80::1; fd00:ec2::254 unset: refused before any request, the link-local sentence for fd00:ec2::254; fd00:ec2::254 false: refused before any request, the link-local sentence for fd00:ec2::254; ::ffff:169.254.169.254 unset: refused before any request, the link-local sentence for ::ffff:169.254.169.254; ::ffff:169.254.169.254 false: refused before any request, the link-local sentence for ::ffff:169.254.169.254; 64:ff9b::a9fe:a9fe unset: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 64:ff9b::a9fe:a9fe false: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 2852039166: refused before any request, the shared invalid-host sentence; 0xa9fea9fe: refused before any request, the shared invalid-host sentence 169.254.169.254 unset: refused before any request, the link-local sentence for 169.254.169.254; 169.254.169.254 false: refused before any request, the link-local sentence for 169.254.169.254; 169.254.0.1 unset: refused before any request, the link-local sentence for 169.254.0.1; 169.254.0.1 false: refused before any request, the link-local sentence for 169.254.0.1; fe80::1 unset: refused before any request, the link-local sentence for fe80::1; fe80::1 false: refused before any request, the link-local sentence for fe80::1; fd00:ec2::254 unset: refused before any request, the link-local sentence for fd00:ec2::254; fd00:ec2::254 false: refused before any request, the link-local sentence for fd00:ec2::254; ::ffff:169.254.169.254 unset: refused before any request, the link-local sentence for ::ffff:169.254.169.254; ::ffff:169.254.169.254 false: refused before any request, the link-local sentence for ::ffff:169.254.169.254; 64:ff9b::a9fe:a9fe unset: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 64:ff9b::a9fe:a9fe false: refused before any request, the link-local sentence for 64:ff9b::a9fe:a9fe; 2852039166: refused before any request, the shared invalid-host sentence; 0xa9fea9fe: refused before any request, the shared invalid-host sentence
A55b DB_HTTP_BLOCK_PRIVATE_HOSTS=true, root, 127.0.0.1 refused before any request, the DB_HTTP_BLOCK_PRIVATE_HOSTS sentence refused before any request, the DB_HTTP_BLOCK_PRIVATE_HOSTS sentence refused before any request, the DB_HTTP_BLOCK_PRIVATE_HOSTS sentence refused before any request, the DB_HTTP_BLOCK_PRIVATE_HOSTS sentence refused before any request, the DB_HTTP_BLOCK_PRIVATE_HOSTS sentence
A63 Through an SSH tunnel to silo (plain HTTP, no consent); then to silo-tls with verify-full; then a far end 169.254.169.254 not applicable: tunnel check only, on Silo not applicable: tunnel check only, on Silo plain: Test Connection and a bucket listing succeed through the tunnel, no consent asked; tls: the certificate is checked against silo-tls and the listing succeeds; link-local: refused before any request, the link-local sentence for 169.254.169.254 not applicable: tunnel check only, on Silo not applicable: tunnel check only, on Silo
A56 Endpoint on the host's first non-loopback IPv4, plain HTTP, no consent; then with consent no-consent: refused before any request, the connection check's sentence; consent: refused, an error other than the connection check's sentence no-consent: refused before any request, the connection check's sentence; consent: refused, an error other than the connection check's sentence no-consent: refused before any request, the connection check's sentence; consent: refused, an error other than the connection check's sentence no-consent: refused before any request, the connection check's sentence; consent: refused, an error other than the connection check's sentence no-consent: refused before any request, the connection check's sentence; consent: refused, an error other than the connection check's sentence

Listing and the Keys panel

Row Behaviour MinIO MinIO-R Silo Garage RustFS
A13 Buckets in the sidebar, root 5 rows, sorted bytewise 5 rows, sorted bytewise 5 rows, sorted bytewise 5 rows, sorted by the provider although Garage answers in random order 5 rows, sorted bytewise
A14 studio-demo top level folders a/, data/, dir/, keys/, long/, meta/, parquet/, sp/ (and ctl/ where stored), then root.txt folders a/, data/, dir/, keys/, long/, meta/, parquet/, sp/ (and ctl/ where stored), then root.txt folders a/, data/, dir/, keys/, long/, meta/, parquet/, sp/ (and ctl/ where stored), then root.txt folders a/, data/, dir/, keys/, long/, meta/, parquet/, sp/ (and ctl/ where stored), then root.txt folders a/, data/, dir/, keys/, long/, meta/, parquet/, sp/ (and ctl/ where stored), then root.txt
A15 Folder marker dir/ inside dir/: the marker row dir/ and child.txt inside dir/: the marker row dir/ and child.txt inside dir/: the marker row dir/ and child.txt inside dir/: the marker row dir/ and child.txt inside dir/: the marker row dir/ and child.txt
A16 Marker with a body, keys/dirmarker/ level: inside the folder, the marker row with size 9, never hidden; open: its metadata and its preview (marker!!) level: inside the folder, the marker row with size 9, never hidden; open: its metadata and its preview (marker!!) level: inside the folder, the marker row with size 9, never hidden; open: its metadata and its preview (marker!!) level: inside the folder, the marker row with size 9, never hidden; open: its metadata and its preview (marker!!) level: inside the folder, the marker row with size 9, never hidden; open: its metadata and its preview (marker!!)
A17 Special keys of docker/s3/seed.sh listed under their exact names and opened list: each stored key listed under its exact name; open: its HEAD and preview succeed list: each stored key listed under its exact name; open: its HEAD and preview succeed list: each stored key listed under its exact name; open: its HEAD and preview succeed list: each stored key listed under its exact name; open: its HEAD and preview succeed list: each stored key listed under its exact name; open: its HEAD and preview succeed
A17b Keys panel prefix sp/with space, and list-objects-v2 with --prefix 'sp/*' keys: answered, names decoded; console: answered, * sent as %2A keys: answered, names decoded; console: answered, * sent as %2A keys: answered, names decoded; console: answered, * sent as %2A keys: answered, names decoded; console: answered, * sent as %2A keys: answered, names decoded; console: answered, * sent as %2A
A18 Tab and U+0001 keys tab: listed and opened; control: listed and opened tab: listed and opened; control: listed and opened tab: listed and opened; control: listed and opened tab: listed and opened; control: listed and opened tab: listed and opened; control: listed and opened
A19 //, . and .. keys list: not present (refused at seed); open: not applicable: no such key is stored list: not present (refused at seed); open: not applicable: no such key is stored list: not present (refused at seed); open: not applicable: no such key is stored list: listed exactly, never normalised; open: opened under the key rule of the provider's addressing part list: not present (refused at seed); open: not applicable: no such key is stored
A20 Console head-object of a key starting with / refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence
A21 studio-bulk/many/: 2,500 objects first page 1,000; Load more 1,000, then 500; no repeat; byte order first page 1,000; Load more 1,000, then 500; no repeat; byte order first page 1,000; Load more 1,000, then 500; no repeat; byte order first page 1,000; Load more 1,000, then 500; no repeat; byte order first page 1,000; Load more 1,000, then 500; no repeat; byte order
A22 studio-bulk/folders/: 1,100 folders every folder exactly once; page 1 holds 1,000 folders and the next 100 every folder exactly once; page 1 holds 1,000 folders and the next 100 every folder exactly once; page 1 holds 1,000 folders and the next 100 every folder exactly once; page 1 holds 1,000 folders and the next 100 every folder exactly once; page 1 holds 1,000 folders and the next 100
A23 studio-bulk/mixed/: 600 objects and 600 folders interleaved every object and folder once; a page that ends on a folder resumes after it every object and folder once; a page that ends on a folder resumes after it every object and folder once; a page that ends on a folder resumes after it every object and folder once; a page that ends on a folder resumes after it every object and folder once; a page that ends on a folder resumes after it
A23b studio-versions level under ver/ level: doc.txt and zz-last.txt present; deleted: deleted.txt absent level: doc.txt and zz-last.txt present; deleted: deleted.txt absent level: doc.txt and zz-last.txt present; deleted: deleted.txt absent level: not applicable: no versioning on Garage; deleted: not applicable: no versioning on Garage level: doc.txt and zz-last.txt present; deleted: deleted.txt absent
A24 A cursor of the wrong shape handed to the Keys route: not the envelope's spelling, longer than S3_CURSOR_TEXT_MAX_CHARS, or JSON of another shape spelling: refused before any request, the Keys panel's cursor sentence; long: refused before any request, the Keys panel's cursor sentence; shape: refused before any request, the Keys panel's cursor sentence spelling: refused before any request, the Keys panel's cursor sentence; long: refused before any request, the Keys panel's cursor sentence; shape: refused before any request, the Keys panel's cursor sentence spelling: refused before any request, the Keys panel's cursor sentence; long: refused before any request, the Keys panel's cursor sentence; shape: refused before any request, the Keys panel's cursor sentence spelling: refused before any request, the Keys panel's cursor sentence; long: refused before any request, the Keys panel's cursor sentence; shape: refused before any request, the Keys panel's cursor sentence spelling: refused before any request, the Keys panel's cursor sentence; long: refused before any request, the Keys panel's cursor sentence; shape: refused before any request, the Keys panel's cursor sentence
A24b A cursor written by one provider instance handed to a second instance built for the same connection accepted: the next page, no repeat and no gap accepted: the next page, no repeat and no gap accepted: the next page, no repeat and no gap accepted: the next page, no repeat and no gap accepted: the next page, no repeat and no gap
A25 A cursor of studio-demo reused on studio-scoped or on another prefix bucket: refused before any request, the Keys panel's cursor sentence; prefix: refused before any request, the Keys panel's cursor sentence bucket: refused before any request, the Keys panel's cursor sentence; prefix: refused before any request, the Keys panel's cursor sentence bucket: refused before any request, the Keys panel's cursor sentence; prefix: refused before any request, the Keys panel's cursor sentence bucket: refused before any request, the Keys panel's cursor sentence; prefix: refused before any request, the Keys panel's cursor sentence bucket: refused before any request, the Keys panel's cursor sentence; prefix: refused before any request, the Keys panel's cursor sentence
A26 No request carries max-keys=0 or above 1,000 (wire, every capture) holds holds holds holds holds
A27 No Keys panel request carries start-after (wire) holds holds holds holds holds

Object metadata and preview

Row Behaviour MinIO MinIO-R Silo Garage RustFS
A28 Source tab metadata of meta/tagged.txt and data/multipart.bin tagged: size, ETag, Last-Modified, Content-Type, both meta headers, tag count 2; multipart: ETag with the -2 suffix tagged: size, ETag, Last-Modified, Content-Type, both meta headers, tag count 2; multipart: ETag with the -2 suffix tagged: size, ETag, Last-Modified, Content-Type, both meta headers, tag count 2; multipart: ETag with the -2 suffix tagged: size, ETag, Last-Modified, Content-Type, both meta headers; no tag count; multipart: ETag with the -2 suffix tagged: size, ETag, Last-Modified, Content-Type, both meta headers, tag count 2; multipart: ETag with the -2 suffix
A29 Selecting a folder row sends no HEAD (wire) holds holds holds holds holds
A30 Ranged read of data/one-mib.bin 206 with Content-Range, bytes shown as hex 206 with Content-Range, bytes shown as hex 206 with Content-Range, bytes shown as hex 206 with Content-Range, bytes shown as hex 206 with Content-Range, bytes shown as hex
A31 Parquet footer by suffix range 206 206 206 206 206
A32 data/empty.txt the Source tab says the object is empty; no error the Source tab says the object is empty; no error the Source tab says the object is empty; no error the Source tab says the object is empty; no error the Source tab says the object is empty; no error
A33 No request carries a multi-range header (wire) holds holds holds holds holds
A35 data/rows.ndjson.gz with Content-Encoding: gzip rows decoded from the stored gzip bytes, with notice N-GZIP rows decoded from the stored gzip bytes, with notice N-GZIP rows decoded from the stored gzip bytes, with notice N-GZIP rows decoded from the stored gzip bytes, with notice N-GZIP rows decoded from the stored gzip bytes, with notice N-GZIP
A35b data/bomb.ndjson.gz: 64 MiB of NDJSON stored as about 64 KiB of gzip preview: rows from the first 1,000,000 decoded bytes only, within the row cap, with notice N-GZIP; gets: one GET of the whole stored object preview: rows from the first 1,000,000 decoded bytes only, within the row cap, with notice N-GZIP; gets: one GET of the whole stored object preview: rows from the first 1,000,000 decoded bytes only, within the row cap, with notice N-GZIP; gets: one GET of the whole stored object preview: rows from the first 1,000,000 decoded bytes only, within the row cap, with notice N-GZIP; gets: one GET of the whole stored object preview: rows from the first 1,000,000 decoded bytes only, within the row cap, with notice N-GZIP; gets: one GET of the whole stored object
A36 data/rows.ndjson stored as application/octet-stream previewed as NDJSON rows: detection by extension and content previewed as NDJSON rows: detection by extension and content previewed as NDJSON rows: detection by extension and content previewed as NDJSON rows: detection by extension and content previewed as NDJSON rows: detection by extension and content
A37 Text, CSV (quoted comma and newline), TSV, JSON, truncated JSON, partial NDJSON, UTF-8 boundary csv: 4 records, the quoted comma and newline kept; tsv: 4 records; json: the document as text lines; truncated-json: shown as text with notice N-JSON-CUT; partial-ndjson: the cut last line stated with notice N-LINE-CUT; utf8-boundary: cut back to a character boundary, with notice N-CUT csv: 4 records, the quoted comma and newline kept; tsv: 4 records; json: the document as text lines; truncated-json: shown as text with notice N-JSON-CUT; partial-ndjson: the cut last line stated with notice N-LINE-CUT; utf8-boundary: cut back to a character boundary, with notice N-CUT csv: 4 records, the quoted comma and newline kept; tsv: 4 records; json: the document as text lines; truncated-json: shown as text with notice N-JSON-CUT; partial-ndjson: the cut last line stated with notice N-LINE-CUT; utf8-boundary: cut back to a character boundary, with notice N-CUT csv: 4 records, the quoted comma and newline kept; tsv: 4 records; json: the document as text lines; truncated-json: shown as text with notice N-JSON-CUT; partial-ndjson: the cut last line stated with notice N-LINE-CUT; utf8-boundary: cut back to a character boundary, with notice N-CUT csv: 4 records, the quoted comma and newline kept; tsv: 4 records; json: the document as text lines; truncated-json: shown as text with notice N-JSON-CUT; partial-ndjson: the cut last line stated with notice N-LINE-CUT; utf8-boundary: cut back to a character boundary, with notice N-CUT
A38 parquet/fx-<codec>.parquet for the six codecs, fx-two-groups.parquet, fx-empty.parquet uncompressed: rows, including the struct and list columns; snappy: rows, including the struct and list columns; gzip: rows, including the struct and list columns; zstd: rows, including the struct and list columns; brotli: rows, including the struct and list columns; lz4_raw: rows, including the struct and list columns; two-groups: notice N-PQ-RG0; empty: notice N-PQ-NO-ROWS uncompressed: rows, including the struct and list columns; snappy: rows, including the struct and list columns; gzip: rows, including the struct and list columns; zstd: rows, including the struct and list columns; brotli: rows, including the struct and list columns; lz4_raw: rows, including the struct and list columns; two-groups: notice N-PQ-RG0; empty: notice N-PQ-NO-ROWS uncompressed: rows, including the struct and list columns; snappy: rows, including the struct and list columns; gzip: rows, including the struct and list columns; zstd: rows, including the struct and list columns; brotli: rows, including the struct and list columns; lz4_raw: rows, including the struct and list columns; two-groups: notice N-PQ-RG0; empty: notice N-PQ-NO-ROWS uncompressed: rows, including the struct and list columns; snappy: rows, including the struct and list columns; gzip: rows, including the struct and list columns; zstd: rows, including the struct and list columns; brotli: rows, including the struct and list columns; lz4_raw: rows, including the struct and list columns; two-groups: notice N-PQ-RG0; empty: notice N-PQ-NO-ROWS uncompressed: rows, including the struct and list columns; snappy: rows, including the struct and list columns; gzip: rows, including the struct and list columns; zstd: rows, including the struct and list columns; brotli: rows, including the struct and list columns; lz4_raw: rows, including the struct and list columns; two-groups: notice N-PQ-RG0; empty: notice N-PQ-NO-ROWS
A39 parquet/large/narrow-zstd.parquet; parquet/large/wide-zstd.parquet narrow: rows for the leading 4 of 5 columns (id, name, amount, d), with notice N-PQ-SOME-COLUMNS naming k = 4 of 5: the fifth passes the value cap; wide: rows for the leading 4 of 60 columns (c0_int, c1_str, c2_dbl, c3_date), with notice N-PQ-SOME-COLUMNS naming k = 4 of 60 narrow: rows for the leading 4 of 5 columns (id, name, amount, d), with notice N-PQ-SOME-COLUMNS naming k = 4 of 5: the fifth passes the value cap; wide: rows for the leading 4 of 60 columns (c0_int, c1_str, c2_dbl, c3_date), with notice N-PQ-SOME-COLUMNS naming k = 4 of 60 narrow: rows for the leading 4 of 5 columns (id, name, amount, d), with notice N-PQ-SOME-COLUMNS naming k = 4 of 5: the fifth passes the value cap; wide: rows for the leading 4 of 60 columns (c0_int, c1_str, c2_dbl, c3_date), with notice N-PQ-SOME-COLUMNS naming k = 4 of 60 narrow: rows for the leading 4 of 5 columns (id, name, amount, d), with notice N-PQ-SOME-COLUMNS naming k = 4 of 5: the fifth passes the value cap; wide: rows for the leading 4 of 60 columns (c0_int, c1_str, c2_dbl, c3_date), with notice N-PQ-SOME-COLUMNS naming k = 4 of 60 narrow: rows for the leading 4 of 5 columns (id, name, amount, d), with notice N-PQ-SOME-COLUMNS naming k = 4 of 5: the fifth passes the value cap; wide: rows for the leading 4 of 60 columns (c0_int, c1_str, c2_dbl, c3_date), with notice N-PQ-SOME-COLUMNS naming k = 4 of 60
A40 parquet/bigcells-zstd.parquet preview: rows of the id column only, with notice N-PQ-SOME-COLUMNS; wire: no range request covers the 1 MB string column's chunk preview: rows of the id column only, with notice N-PQ-SOME-COLUMNS; wire: no range request covers the 1 MB string column's chunk preview: rows of the id column only, with notice N-PQ-SOME-COLUMNS; wire: no range request covers the 1 MB string column's chunk preview: rows of the id column only, with notice N-PQ-SOME-COLUMNS; wire: no range request covers the 1 MB string column's chunk preview: rows of the id column only, with notice N-PQ-SOME-COLUMNS; wire: no range request covers the 1 MB string column's chunk
A41 parquet/not-parquet.parquet, parquet/truncated.parquet not-parquet: not a Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks; truncated: not a readable Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks not-parquet: not a Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks; truncated: not a readable Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks not-parquet: not a Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks; truncated: not a readable Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks not-parquet: not a Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks; truncated: not a readable Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks not-parquet: not a Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks; truncated: not a readable Parquet file: hex with notice N-PQ-MAGIC; nothing else breaks
A42 data/noext (no Content-Type) shown as hex shown as hex shown as hex shown as hex shown as hex
A45 No request carries the attributes query (wire) holds holds holds holds holds

Console

Row Behaviour MinIO MinIO-R Silo Garage RustFS
A43 aws s3api list-object-versions --bucket studio-versions --prefix ver/ two versions of doc.txt, the delete marker and the version of deleted.txt, zz-last.txt two versions of doc.txt, the delete marker and the version of deleted.txt, zz-last.txt two versions of doc.txt, the delete marker and the version of deleted.txt, zz-last.txt refused, the sentence for 501 NotImplemented to ListObjectVersions two versions of doc.txt, the delete marker and the version of deleted.txt, zz-last.txt
A46 aws s3api list-buckets 5 rows 5 rows 5 rows 5 rows 5 rows
A47 aws s3 ls s3://studio-demo/, aws s3 ls s3://studio-demo/data/ --recursive, and the latter split over two lines with a backslash top: the top level of studio-demo; recursive: every object under data/; continued: the same rows top: the top level of studio-demo; recursive: every object under data/; continued: the same rows top: the top level of studio-demo; recursive: every object under data/; continued: the same rows top: the top level of studio-demo; recursive: every object under data/; continued: the same rows top: the top level of studio-demo; recursive: every object under data/; continued: the same rows
A48 aws s3api head-object --bucket studio-demo --key data/table.csv head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType head-object answers one Field row each for ContentLength, ETag, LastModified and ContentType
A49 preview s3://studio-demo/data/table.csv, then preview s3://studio-demo/parquet/fx-zstd.parquet csv: rows; parquet: rows csv: rows; parquet: rows csv: rows; parquet: rows csv: rows; parquet: rows csv: rows; parquet: rows
A50 Every write-shaped command the console refuses cp: refused before any request, the console's refusal sentence; rm: refused before any request, the console's refusal sentence; put-object: refused before any request, the console's refusal sentence; delete-object: refused before any request, the console's refusal sentence; create-bucket: refused before any request, the console's refusal sentence cp: refused before any request, the console's refusal sentence; rm: refused before any request, the console's refusal sentence; put-object: refused before any request, the console's refusal sentence; delete-object: refused before any request, the console's refusal sentence; create-bucket: refused before any request, the console's refusal sentence cp: refused before any request, the console's refusal sentence; rm: refused before any request, the console's refusal sentence; put-object: refused before any request, the console's refusal sentence; delete-object: refused before any request, the console's refusal sentence; create-bucket: refused before any request, the console's refusal sentence cp: refused before any request, the console's refusal sentence; rm: refused before any request, the console's refusal sentence; put-object: refused before any request, the console's refusal sentence; delete-object: refused before any request, the console's refusal sentence; create-bucket: refused before any request, the console's refusal sentence cp: refused before any request, the console's refusal sentence; rm: refused before any request, the console's refusal sentence; put-object: refused before any request, the console's refusal sentence; delete-object: refused before any request, the console's refusal sentence; create-bucket: refused before any request, the console's refusal sentence
A51 Every console refusal of a command it does not run, the one-command rule, and the refusals of an object address it cannot read profile: refused before any request, the console's refusal sentence; no-sign-request: refused before any request, the console's refusal sentence; ca-bundle: refused before any request, the console's refusal sentence; no-verify-ssl: refused before any request, the console's refusal sentence; debug: refused before any request, the console's refusal sentence; region-mismatch: refused before any request, the console's refusal sentence; endpoint-mismatch: refused before any request, the console's refusal sentence; get-object-attributes: refused before any request, the console's refusal sentence; get-object: refused before any request, the console's refusal sentence; sync: refused before any request, the console's refusal sentence; key-too-long: refused before any request, the console's refusal sentence; key-nul: refused before any request, the console's refusal sentence; semicolon: refused before any request, the console's refusal sentence; and: refused before any request, the console's refusal sentence; pipe: refused before any request, the console's refusal sentence; substitution: refused before any request, the console's refusal sentence; second-line: refused before any request, the console's refusal sentence; bucket-dotdot: refused before any request, the console's refusal sentence; bucket-space: refused before any request, the console's refusal sentence; key-leading-slash: refused before any request, the console's refusal sentence; preview-empty-segment: refused before any request, the console's refusal sentence profile: refused before any request, the console's refusal sentence; no-sign-request: refused before any request, the console's refusal sentence; ca-bundle: refused before any request, the console's refusal sentence; no-verify-ssl: refused before any request, the console's refusal sentence; debug: refused before any request, the console's refusal sentence; region-mismatch: refused before any request, the console's refusal sentence; endpoint-mismatch: refused before any request, the console's refusal sentence; get-object-attributes: refused before any request, the console's refusal sentence; get-object: refused before any request, the console's refusal sentence; sync: refused before any request, the console's refusal sentence; key-too-long: refused before any request, the console's refusal sentence; key-nul: refused before any request, the console's refusal sentence; semicolon: refused before any request, the console's refusal sentence; and: refused before any request, the console's refusal sentence; pipe: refused before any request, the console's refusal sentence; substitution: refused before any request, the console's refusal sentence; second-line: refused before any request, the console's refusal sentence; bucket-dotdot: refused before any request, the console's refusal sentence; bucket-space: refused before any request, the console's refusal sentence; key-leading-slash: refused before any request, the console's refusal sentence; preview-empty-segment: refused before any request, the console's refusal sentence profile: refused before any request, the console's refusal sentence; no-sign-request: refused before any request, the console's refusal sentence; ca-bundle: refused before any request, the console's refusal sentence; no-verify-ssl: refused before any request, the console's refusal sentence; debug: refused before any request, the console's refusal sentence; region-mismatch: refused before any request, the console's refusal sentence; endpoint-mismatch: refused before any request, the console's refusal sentence; get-object-attributes: refused before any request, the console's refusal sentence; get-object: refused before any request, the console's refusal sentence; sync: refused before any request, the console's refusal sentence; key-too-long: refused before any request, the console's refusal sentence; key-nul: refused before any request, the console's refusal sentence; semicolon: refused before any request, the console's refusal sentence; and: refused before any request, the console's refusal sentence; pipe: refused before any request, the console's refusal sentence; substitution: refused before any request, the console's refusal sentence; second-line: refused before any request, the console's refusal sentence; bucket-dotdot: refused before any request, the console's refusal sentence; bucket-space: refused before any request, the console's refusal sentence; key-leading-slash: refused before any request, the console's refusal sentence; preview-empty-segment: refused before any request, the console's refusal sentence profile: refused before any request, the console's refusal sentence; no-sign-request: refused before any request, the console's refusal sentence; ca-bundle: refused before any request, the console's refusal sentence; no-verify-ssl: refused before any request, the console's refusal sentence; debug: refused before any request, the console's refusal sentence; region-mismatch: refused before any request, the console's refusal sentence; endpoint-mismatch: refused before any request, the console's refusal sentence; get-object-attributes: refused before any request, the console's refusal sentence; get-object: refused before any request, the console's refusal sentence; sync: refused before any request, the console's refusal sentence; key-too-long: refused before any request, the console's refusal sentence; key-nul: refused before any request, the console's refusal sentence; semicolon: refused before any request, the console's refusal sentence; and: refused before any request, the console's refusal sentence; pipe: refused before any request, the console's refusal sentence; substitution: refused before any request, the console's refusal sentence; second-line: refused before any request, the console's refusal sentence; bucket-dotdot: refused before any request, the console's refusal sentence; bucket-space: refused before any request, the console's refusal sentence; key-leading-slash: refused before any request, the console's refusal sentence; preview-empty-segment: refused before any request, the console's refusal sentence profile: refused before any request, the console's refusal sentence; no-sign-request: refused before any request, the console's refusal sentence; ca-bundle: refused before any request, the console's refusal sentence; no-verify-ssl: refused before any request, the console's refusal sentence; debug: refused before any request, the console's refusal sentence; region-mismatch: refused before any request, the console's refusal sentence; endpoint-mismatch: refused before any request, the console's refusal sentence; get-object-attributes: refused before any request, the console's refusal sentence; get-object: refused before any request, the console's refusal sentence; sync: refused before any request, the console's refusal sentence; key-too-long: refused before any request, the console's refusal sentence; key-nul: refused before any request, the console's refusal sentence; semicolon: refused before any request, the console's refusal sentence; and: refused before any request, the console's refusal sentence; pipe: refused before any request, the console's refusal sentence; substitution: refused before any request, the console's refusal sentence; second-line: refused before any request, the console's refusal sentence; bucket-dotdot: refused before any request, the console's refusal sentence; bucket-space: refused before any request, the console's refusal sentence; key-leading-slash: refused before any request, the console's refusal sentence; preview-empty-segment: refused before any request, the console's refusal sentence
A58 aws s3api list-objects-v2 --bucket studio-demo --prefix data/ --delimiter / the objects under data/ the objects under data/ the objects under data/ the objects under data/ the objects under data/
A59 aws s3api head-bucket --bucket studio-demo one Field/Value grid with the members the server sends: none one Field/Value grid with the members the server sends: BucketRegion eu-central-1 one Field/Value grid with the members the server sends: none one Field/Value grid with the members the server sends: none one Field/Value grid with the members the server sends: none
A60 aws s3api get-object-tagging --bucket studio-demo --key meta/tagged.txt two rows, env probe and tier gold two rows, env probe and tier gold two rows, env probe and tier gold refused, the sentence for 501 NotImplemented to GetObjectTagging two rows, env probe and tier gold
A61 aws s3api get-bucket-location --bucket studio-demo one row, LocationConstraint us-east-1 eu-central-1 one row, LocationConstraint us-east-1 garage one row, LocationConstraint us-east-1
A62 aws s3api get-bucket-versioning --bucket studio-versions Status Enabled Status Enabled Status Enabled a grid with no Status, and the note that versioning was never turned on for the bucket Status Enabled
A64 aws s3 ls s3://studio-demo/ --endpoint-url http://169.254.169.254/ refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence refused before any request, the console's refusal sentence
A66 aws s3api list-objects-v2 --bucket studio-demo --starting-token with a forged token random: refused before any request, the console's refusal sentence; array: refused before any request, the console's refusal sentence; extra-key: refused before any request, the console's refusal sentence; boto-encoded-keys: refused before any request, the console's refusal sentence random: refused before any request, the console's refusal sentence; array: refused before any request, the console's refusal sentence; extra-key: refused before any request, the console's refusal sentence; boto-encoded-keys: refused before any request, the console's refusal sentence random: refused before any request, the console's refusal sentence; array: refused before any request, the console's refusal sentence; extra-key: refused before any request, the console's refusal sentence; boto-encoded-keys: refused before any request, the console's refusal sentence random: refused before any request, the console's refusal sentence; array: refused before any request, the console's refusal sentence; extra-key: refused before any request, the console's refusal sentence; boto-encoded-keys: refused before any request, the console's refusal sentence random: refused before any request, the console's refusal sentence; array: refused before any request, the console's refusal sentence; extra-key: refused before any request, the console's refusal sentence; boto-encoded-keys: refused before any request, the console's refusal sentence
A66b The same command with a well-formed token taken from a studio-bulk listing sent; the server authorises it with the connection's own key and answers the listing's rows: one request sent; the server authorises it with the connection's own key and answers the listing's rows: one request sent; the server authorises it with the connection's own key and answers the listing's rows: one request sent; the server authorises it with the connection's own key and answers the listing's rows: one request sent; the server authorises it with the connection's own key and answers the listing's rows: one request

Read-only, signing and bounds

Row Behaviour MinIO MinIO-R Silo Garage RustFS
A52 Every exchange is GET or HEAD; readOnly on and off behave the same wire: holds; readonly-off: list-buckets answers the same 5 rows wire: holds; readonly-off: list-buckets answers the same 5 rows wire: holds; readonly-off: list-buckets answers the same 5 rows wire: holds; readonly-off: list-buckets answers the same 5 rows wire: holds; readonly-off: list-buckets answers the same 5 rows
A53 Every x-amz header sent is signed holds holds holds holds holds
A54 Every signed request carries x-amz-content-sha256 holds holds holds holds holds
A65 No provider message, result cell, notice or thrown error of any row holds a fixture secret in any encoding holds holds holds holds holds
A57 Cancel during the 1,000-key listing of A21 cancel: the run ends cancelled, no further request; again: the panel can list again cancel: the run ends cancelled, no further request; again: the panel can list again cancel: the run ends cancelled, no further request; again: the panel can list again cancel: the run ends cancelled, no further request; again: the panel can list again cancel: the run ends cancelled, no further request; again: the panel can list again

silo-tls runs A1, A2, A14, A37 and A57, each as on Silo; A63 runs only in the SSH tunnel check.

12. Running an S3-compatible server for Studio

Give Studio a read-only credential scoped to the buckets it should see: Studio cannot tell what a key can do, and read-only is a property of what Studio sends unless the key itself is read-only on the server. Serve TLS whenever Studio is not on the same machine. Set a site region on MinIO or Silo (MINIO_SITE_REGION) or Garage (s3_region) and type the same region in Studio.

13. Known limitations

  • Connections are read-only: no upload, copy, delete, bucket, versioning or policy change (D264).
  • No SQL over files (D261).
  • No session token, so temporary credentials from STS cannot sign (D262).
  • Path-style addressing only (D263).
  • Verified on MinIO, Silo, Garage and RustFS only; AWS S3 and hosted services are not verified, as section 4.9 says (D265).
  • The tree lists every bucket in one folder, with no paging (U109).
  • Agent mode and MCP read metadata only, never an object key they were not given, and MCP reads only under an agent read key (B104).
  • Directory buckets, Multi-Region Access Points, Object Lambda and S3 Tables cannot be read, because each needs a signing scheme or an addressing mode Studio does not implement.
  • An object encrypted with a customer-provided key (SSE-C) cannot be read.
  • Unsigned access is what an empty key pair means; there is no separate anonymous mode.
  • Object versions are listed by aws s3api list-object-versions only; no version opens in the Source tab.
  • A key Studio can list but not address in path style is shown and refused when opened, with its reason.
  • A key whose . and .. segments, resolved with empty segments skipped and a backslash read as a separator like /, climb out of its bucket or name the bucket itself is listed but not opened, on every connection and before any request, because a server or proxy that resolves them would read something else.
  • MinIO's community edition is archived; the MinIO fixture is a frozen build (D267).
  • Plain HTTP off this machine needs consent, because bucket and object names, listings and previews cross the network in the clear and a signed request is replayable within the server's clock window by anyone who captures it.
  • With the HTTP egress guard off, which is the default, a connection reaches any loopback or private host that answers as S3, and reads the objects that host serves to the connection's key; a host whose connect probe does not parse as S3 XML never has an object read (section 7). An operator narrows this with DB_HTTP_BLOCK_PRIVATE_HOSTS or by seeding the connections and setting ALLOW_CUSTOM_CONNECTIONS=false (SECURITY "Known limits"); a policy that differs by role is not built (D270).
  • The Bucket field narrows what Studio lists and opens; it is a scope, not an access boundary, and the key's own policy on the server is the boundary. A key whose . and .. segments stay inside its bucket is sent unresolved, so a server, or a proxy in front of it, that resolves them reads the object they resolve to. Give a seeded connection a key whose policy names the one bucket it should see.
  • A Parquet preview that reads the whole fetch budget needs a link of about 7 Mbit/s to finish within the Source tab's 10-second deadline; on a slower link it ends with the timeout sentence, and the console's preview --columns or --schema reads less.
  • A Parquet preview decodes on the server's event loop, at most 2 at a time on one Studio server (D271).
  • A Parquet preview shows at most 128 leaf columns, the leaf-column bound the decode measurement set, and refuses a schema of more than 1,024 elements, eight times it (D273).
  • A Parquet schema that gives two children of one group the same name is refused with a sentence of its own, because the preview chooses a column and matches its chunks by name.
  • A Parquet file whose first row group holds more values than a preview decodes previews fewer columns, or only its schema and statistics when its first column alone holds more values than a preview decodes, as a row group written with pyarrow's defaults of up to 1,048,576 rows can, or is refused when one page declares more values than a preview allows (D274).
  • A CSV, TSV, JSON or NDJSON preview names at most 8,192 columns and only counts the rest, so a column past them cannot be chosen with preview --columns.
  • Through an SSH tunnel, the signed and sent Host is the local forward, so a server behind the tunnel that routes by Host name is not supported in v1 (row A63's outcome); a far end given as a name is resolved by the SSH server.
  • On Garage a bucket can be listed twice, under a global and a per-key local alias, and an empty key pair never works (section 4.8).
  • A key beginning with / is listed but not opened, and a bucket name Studio cannot address in path style is listed but neither opened nor expanded.
  • ListObjectVersions reads one page; GetObjectAttributes is not used.
  • ListBuckets reads at most 10,000 buckets, and an account whose bucket list passes 4 MiB or 100,000 XML elements must set Bucket.
  • A hand-written Keys panel cursor, like a console --starting-token, can start a page at another position inside what the key may list.
  • A compressed XML answer from a proxy is refused, not decoded.
  • --query is not run; get-object-attributes, --start-after and --fetch-owner are refused.
  • Keys holding a NUL character or a carriage return are reachable only from the Keys panel, so not in the embedded workspace.
  • Completion offers commands, flags and closed values only: the schema an S3 connection reads holds no bucket, because S3 declares no relation kind, so no bucket name is offered, and no folder or key either. --max-items counts folder rows.
  • On Garage, list-object-versions and get-object-tagging answer 501.
  • The AWS-only error answers of section 10 (a cross-region 301, InvalidObjectState) are not verified.

The bullets without a backlog id are exclusions nobody asked to schedule, or server behaviour Studio cannot change.

Migration DDL

S3_MIGRATION_DDL is what the schema-diff migration generator writes for s3, read through migrationDdlFor in src/lib/db/migration-ddl/. It declines the whole diff: refusal is the one line the migration prints after its header, in place of any DDL, and transactionWrapper is null. s3 text is one AWS CLI subset command, not SQL, so there is no table DDL to wrap (#284).

The refusal reason it prints, verbatim:

  • "S3 stores objects under keys with no schema, so there is no column definition to change."

14. References

15. Agent mode

Agent mode reads S3 metadata through queryReadOnly in index.ts. The command reader is agent-statement.ts, the reads and their metadata-only projection are agent-metadata-read.ts, and every other surface of an agent-mode handle is agent-surface.ts. Every claim below about a server cites a capture under tests/fixtures/s3/agent-boundary/, one set per server, each recorded by its run.sh against a fresh digest-pinned server with a root key and a reader key made for the run, and written by tests/live/s3-agent-boundary.ts. The sets are Silo RELEASE.2026-09-16T00-00-00Z, RustFS 1.0.1 and Garage v2.4.1; MinIO is not measured, since its fixture is a frozen build made from source behind its own profile (D267) and Silo is its maintained fork. The reader holds reader-policy.json, every read and no write, on Silo and RustFS, and read alone on each bucket on Garage, which has per-key bucket grants and no policies; root is the writable positive control.

15.1 The boundary

The boundary is a structural closed table: every agent-mode surface reads through S3AgentClient (client.ts), which holds six reads and nothing more, over the byte transport, which sends GET and HEAD only and refuses any other method before a socket.

Read Request
listBuckets GET /?max-buckets=10000
headBucket HEAD /<bucket>, with no follow-up GET
getBucketLocation GET /<bucket>?location=
getBucketVersioning GET /<bucket>?versioning=
listBucketPage GET /<bucket>?[continuation-token=<the server's own>&]encoding-type=url&list-type=2&max-keys=<n>&prefix=, an empty prefix and no delimiter
headObject HEAD /<bucket>/<key>, the key encoded, with no Range and no follow-up GET

So no object GET, ranged read, listing by prefix, delimiter or start position, version listing, tagging, policy or ACL read, write, delete or presigned URL can be sent; the editor's HEADs read a failure's code with a follow-up GET, and the agent's never do. agent-client.test.ts pins each read's full request, and agent-boundary.test.ts pins the agent modules' imports and calls and holds every request an agent-mode handle sends, for every surface and for every command an attacker would write, to the table's request shapes.

A read policy is not that boundary, so no credential is required of the agent: measured as the reader (raw, as: "reader"), every server answers a GetObject, a ranged GetObject, a HEAD with the user metadata and the ETag, a ListObjectsV2 with a prefix or a start position, and, on Silo and RustFS, a ListObjectVersions with version ids and a GetObjectTagging with the tags, while it refuses a PutObject, a DeleteObject, a PutObjectTagging and a CreateBucket with 403; only the closed table keeps those reads off the agent's path. As root (as: "root") the PutObject and the DeleteObject act on every server, and the tagging write and the bucket creation on Silo and RustFS (Garage answers 501 and 403), and the object fingerprint moves (snapshot.rawCallsChangedIt: true). The agent's own reads, as the reader and as root, leave the fingerprint unchanged (snapshot.agentLeftUnchanged: true), and every command the table refuses sends no request at all (requests: []).

15.2 What an agent command may be

One AWS CLI command on one line, read by the console's own parser (section 5), with or without aws:

Command Takes Sends
aws s3api list-buckets nothing one ListBuckets, then one HEAD of each listed bucket (section 15.5); on a pinned connection nothing, and the pinned bucket is answered with a note
aws s3api head-bucket --bucket B --bucket one HEAD of the bucket
aws s3api get-bucket-location --bucket B --bucket one GetBucketLocation
aws s3api get-bucket-versioning --bucket B --bucket one GetBucketVersioning
aws s3 ls s3://B --recursive --summarize the bucket alone, s3://B or s3://B/, and both flags every whole-bucket page, totalled in Studio
aws s3api head-object --bucket B --key K --bucket and the exact key one HEAD of the object

Every other command is refused before anything is sent, each in agent mode's own sentence followed by the list of reads: list-objects-v2, list-object-versions, get-object-tagging, get-object, preview and every write or other operation as not one of the reads; any --prefix, --delimiter, --starting-token, --bucket-name-prefix, --start-after, continuation token or marker, and aws s3 ls with a key path, as a filter that would reveal key names; any other aws s3 ls, as a listing of keys; and every other flag and global option, --endpoint-url, --region, --output, --max-items and --page-size included, as a flag the reads do not take. A command the parser cannot read (a bad bucket or key address, a key with . or .. segments, a key past 1,024 bytes, a missing value, shell syntax) is refused with the parser's sentence followed by the reads, and a NUL with agent mode's own; no refusal quotes a key, a prefix or a token the command wrote. A pinned connection's commands may name only its bucket. An agent credential, agentUser and agentPassword, is an access key ID and secret the agent profiles sign with in place of the connection's own, so a dedicated read key can serve agent mode; agent mode requires none, since the closed table, not the key, is the boundary, while MCP requires one (section 15.7), and an agent key ID equal to the connection's own is refused, since it names the same key. The admission refuses a command longer than 4,096 characters. Each read runs under the request's time budget and the session's lifetime, and nothing of one call is kept for the next: a summary's continuation tokens live for that read alone, and every surface reads the bucket list afresh.

The bucket listing takes no name filter: it answers every listed bucket the key may read (section 15.5), whose names agent mode treats as object names, so a filter would add nothing and a count by it would act as a name oracle. A summary walks the whole bucket, each page asking for at most what the row budget still allows, and a bucket holding more objects than the row budget is refused, never answered as the part read (maxResultRows: 2 in the captures); it counts current objects, not versions, and refuses a bucket holding an object whose name is not text, which a listing answers without its size.

15.3 Metadata only

Bucket names and the region are object names; object keys, bodies, user metadata (x-amz-meta-*), tags, ETags (a content digest), version ids, owners and ACL grants, bucket policies, CORS and lifecycle rules, endpoints and presigned URLs stay off the agent's path, and a key the request names in full is answered about, never echoed. S3 declares ProviderCapabilities.metadataOnly, and every answer is projected through src/lib/db/redaction/metadata-only.ts:

Read Columns
list-buckets bucket, created (names; created null when the server gave none or the connection is pinned)
head-bucket bucket (name), exists (flag, always true: a bucket that is missing or not readable is refused), region (name, from x-amz-bucket-region, null when absent)
get-bucket-location bucket, location (names; an empty constraint is us-east-1, as S3 defines it)
get-bucket-versioning bucket, status (Enabled, Suspended or never enabled when the server's answer holds no status), mfa_delete (names, null when absent)
summary bucket (name), objects, total_size_bytes (counts)
head-object bucket (name), exists (flag, always true, as on head-bucket), size_bytes (count), last_modified, storage_class (names; each null when the server sent none or sent one that is not a time or a storage class name, never filled in or passed on as the header's text)

A read that names a bucket or a key answers a bucket or a key that does not exist, and one the connection's key may not read, in one sentence, whatever the server sent (a 403, a bare 404, a 404 behind a delete marker or one named NoSuchBucket, whatever the body holds, S3 error XML or not, compressed or not), so no named read tells a key that a bucket or an object outside its grants exists: Garage answers a bucket the key is granted nothing on with 403 and a missing one with 404, which a read passing the status on would turn into a bucket-name oracle (section 15.5). A cell that does not fit its column, a total past 2^53 - 1 among them, withholds the whole answer, naming the column and never the value, and a result past the byte budget is refused, never cut. A failure is worded by its category alone, never by the server's words, since an S3 error body can echo a key or a bucket the request did not hold: a missing or unreadable bucket or key, a refused key on the listing, a region mismatch, a read the server does not implement and any other status each have their sentence, a transport failure is worded by its kind, and a timeout is the time budget's refusal; the captures hold every refusal word for word, and their leaks lists are empty.

Each agent-mode path reads this much:

Path Handle What it reads
Investigation reads agent read-only profile one read of section 15.2's table, projected as above
Investigation grounding, plan-mode grounding and the operations workflow either agent profile the names of the buckets the key may read, from ListBuckets and one HEAD each, as aws s3api list-buckets reads them (section 15.5), without the creation date the listing carries, which the snapshot drops and aws s3api list-buckets answers, and the pinned bucket with no request; the object kind is refused with its reason, and the grounding walk skips it, since the Keys panel enumerates it
Operations readings operations profile health from the agent probe (section 7), every other reading its reason
MCP run_s3_read agent read-only profile, under the seed's agent read key one read of section 15.2's table, projected as above, with the provider's warnings as its notes (section 15.7)
MCP inspect_schema none refused by name, pointing at run_s3_read, before anything is acquired, since S3 declares no relation kind

On a handle of either agent profile the console, the Keys panel and every object and bucket source (which hold the endpoint, the ETag, the version id, user metadata, tags and a preview) are refused; an editor handle keeps every surface. Plan mode's grounding holds no key, as before, and its statement language is the console's: it drafts editor commands the user runs, and never runs one.

15.4 Agent language pack

Statement-language family: s3api. Overlay: src/lib/agent/languages/s3api/engines/s3.ts, with its client row in src/lib/agent/languages/s3api/engines/client.ts. The agent reads it through runServerPack and agentEnginePack. Both overlays are empty, so the engine resolves to the s3api family pack itself. The s3api statement-language family has no dialect-bound member. This overlay overrides no Shared member, so every Shared member is the s3api statement-language family's own. The family offers the investigation workflow, through run_read_query with one command, and the operations workflow; it offers no data analysis, since its answers carry no stored value, and no query optimization or database assessment, since S3 composes no plan and no profile, and no workflow hands an answer to the editor. The workflow classifier offers the model only these two. The editor language id stays s3, and the fence alias s3api names this engine. A pack does not open agent mode: execution is gated by AGENT_EXECUTION_ENGINES and the execution-profile check. The family declares its MCP facet in src/lib/agent/languages/s3api/mcp.ts, registered in AGENT_LANGUAGE_MCP_FACETS: run_s3_read, over the same table and projection, which MCP_STATEMENT_READ.s3 opens (section 15.7).

15.5 What a key's grants reach

The buckets an agent read reaches are the signing key's grants, enforced by the server: the agent read key's where the seed sets one, and always under MCP, the connection's own key's otherwise. Studio's reads keep what those grants allow to names, counts and flags. Measured by tests/live/s3-agent-scope.ts through each run.sh under tests/fixtures/s3/agent-scope/, on fresh Silo RELEASE.2026-09-16T00-00-00Z, RustFS 1.0.1 and Garage v2.4.1 servers, with keys made for the run: a scoped key holding scoped-policy.json (every bucket name, and the objects of two buckets, plus s3:GetObject alone on a third), an unlisted key holding the same grants without s3:ListAllMyBuckets (unlisted-policy.json), and a broad key holding reader-policy.json; on Garage, which has no policies, run.sh grants the same buckets key by key.

  • The bucket listing: the server answers a raw ListBuckets from a key holding s3:ListAllMyBuckets with every bucket name on Silo and RustFS, the buckets it is granted nothing on included, while the unlisted key is answered only its granted buckets, and Garage lists only a key's granted buckets in every case. So the agent's listing names a listed bucket only where the server answers a HEAD of it signed with the same key, as the next paragraphs describe: on Silo the scoped key's aws s3api list-buckets names scope-granted and scope-versions and counts three buckets in a note, on RustFS it names those two and scope-public and counts two, and on Garage it names the three granted buckets and counts none.
  • A bucket the key is granted nothing on, against one that does not exist: Silo and RustFS answer the scoped key 403 for both, Garage 403 and 404, and every named read answers the two, and a key in such a bucket that exists against one that does not, in one sentence (section 15.3); in each capture ungrantedBucketLikeMissing and ungrantedPresentKeyLikeAbsent hold true for every read, each of those scoped-key reads is refused with that sentence, and objectsOnlyPresentKeyLikeAbsent is false, as the next bullet says it must be.
  • A bucket whose objects the key may read but not list (s3:GetObject alone): head-object answers an object's existence and size and refuses a missing one, which the grant already lets the key learn with a GET, and every bucket read of it is refused.
  • A bucket whose policy lets anyone read and list it: Silo refuses it to a signed key granted nothing on it, while RustFS applies the bucket policy to every key, so the scoped key reads its head, location, summary and objects there; on RustFS a public bucket is in every key's reach.
  • A versioned bucket: a key behind a delete marker is refused as a missing one, and the summary counts current objects only.
  • User metadata (x-amz-meta-*), which can carry a credential, is read by no agent read: the HEAD's projection keeps size, last-modified time and storage class alone, and no capture's leaks list holds the metadata marker or a key name.

Which listed buckets an agent answer names is decided by the server per bucket, in readableBuckets (agent-metadata-read.ts), which aws s3api list-buckets and the grounding walk (agent-surface.ts) both read through. After the ListBuckets it sends a HEAD of each listed bucket, signed with the same key, and names a bucket only when that HEAD succeeds. A 403, a 404 or a redirect to another region withholds the bucket, and the answer's note counts the withheld buckets without naming them; any other answer to a HEAD refuses the whole listing, and the HEADs not yet sent are not sent. A key that lists more than S3_AGENT_BUCKET_CHECK_MAX (100) buckets is refused the listing before any HEAD is sent, in a sentence that points at head-bucket and at pinning the connection, and a pinned connection still sends nothing and answers its bucket. HEAD is the check because it is the cheapest read a granted bucket answers on every measured server, and the one every agent read of a bucket already needs: the raw HeadBucket statuses in each mcp.json (heads, per agent key) are 200 for scope-granted and scope-versions on all three servers, and 403 for scope-other on all three; scope-objects-only (s3:GetObject alone on Silo and RustFS, a read grant on Garage) is 403 on Silo and RustFS and 200 on Garage, scope-public is 200 on RustFS only, and the missing bucket is 403 on Silo and RustFS and 404 on Garage.

The request cost of one unpinned listing is one ListBuckets and one HEAD per listed bucket, at most S3_AGENT_BUCKET_CHECK_MAX HEADs, sent S3_AGENT_BUCKET_CHECK_CONCURRENCY (4, the limiter's per-provider bound) at a time under the read's own deadline. Each capture records it as listingCost: one ListBuckets and five HEADs for the scoped key on Silo and RustFS, answered through the route in 10 ms and 12 ms, and one ListBuckets and three HEADs on Garage, in 8 ms.

15.6 Tests

  • tests/unit/db/s3/agent-statement.test.ts: the plan every admitted command is read to, and every refusal before the network in agent mode's own words.
  • tests/unit/db/s3/agent-client.test.ts: the agent client's members and each read's full request.
  • tests/unit/db/s3/agent-metadata-read.test.ts: each read's requests and rows, the bucket listing's HEAD of each listed bucket with its bound, its concurrency and the note that counts a withheld bucket, the row and byte budget edges, and the wording of every failure category.
  • tests/unit/db/s3/agent-provider.test.ts: the profile, every command refused before anything is sent, and every surface of either agent profile, with no key, ETag, version id, metadata or endpoint in any answer.
  • tests/unit/db/s3/agent-boundary.test.ts: the agent modules' reach, and every request of an agent-mode handle held to the table.
  • tests/unit/db/s3/read-only-end-to-end.test.ts: the agent profiles over a loopback server, GET and HEAD only.
  • tests/live/s3-agent-boundary.ts, through each run.sh: the captures above.
  • tests/live/s3-agent-scope.ts, through each run.sh under tests/fixtures/s3/agent-scope/: what a scoped, an unlisted and a broad key reach (section 15.5).
  • tests/integration/mcp/run-s3-read.test.ts: run_s3_read through the route over a loopback listener, every request signed with the agent key, an agent key that lists three buckets but may read one answered that one with two counted, no key or value in any answer, and the open refusals.
  • tests/live/s3-mcp-agent-key.ts, through each run.sh with S3_SCOPE_LIVE set, and tests/unit/db/s3/mcp-agent-key-capture.test.ts, which holds its captures (section 15.7).

15.7 MCP read

MCP reads S3 through run_s3_read, the s3api family's MCP facet, which the statement core runs through queryReadOnly on a handle acquired under the agent read-only profile, so a call reads exactly what an agent read reads (section 15.2) and answers through the same projection (section 15.3). The tool reads and admits the command with agent mode's own reader before anything is acquired, takes no offset and no database, and carries the provider's warnings as its notes, since each is Studio's own sentence naming no stored value: a pinned connection's bucket list holds its one bucket and says so. inspect_schema refuses an S3 connection by name, pointing at run_s3_read, and list_connections lists a connection's pinned bucket as its database, a bucket name being an object name.

MCP reads only under the seed's agent read key, never under the connection's own key (MCP_AGENT_CREDENTIAL_REQUIRED.s3): an S3 seed with mcp: true and neither agentUser nor agentPassword is refused when the file loads, and a connection that reaches MCP without one, built in memory or loaded past the schema, is refused when a tool opens it, with AGENT_CREDENTIAL_REQUIRED, before anything is sent. An agent key ID equal to the connection's own is refused at load and at open, since it names the same key. No S3 key can be proven read-only from a client, so the boundary is the agent client of section 15.1, and the data scope has two layers:

  • The server's grants to the agent read key decide which buckets a call reaches; the server enforces them, and Studio reads nothing that tells it what they are.
  • Studio's projection decides what of a reachable bucket is answered, on the answer and independent of the request: names, counts and flags, never an object key it was not given, a body, user metadata, a tag, an ETag, a version id or the endpoint.

Measured by tests/live/s3-mcp-agent-key.ts through the real /api/mcp route on the servers section 15.5 measured, started by each run.sh under tests/fixtures/s3/agent-scope/ with S3_SCOPE_LIVE pointing at it, into mcp.json beside it, on 2026-11-03, from the seed file mcp-seed.json, whose keys are ${ENV} references; both of its connections sign with the broad key as their own:

  • The bucket listing: the connection's own broad key lists every bucket raw, and run_s3_read's aws s3api list-buckets under the unlisted agent key, which holds no s3:ListAllMyBuckets, answers only its granted buckets on every server, which is the proof that MCP signed with the agent key. The scoped agent key, which holds it, is listed every bucket name raw on Silo and RustFS, and the tool still names only the listed buckets whose HEAD the server answers that key (section 15.5): two on Silo with three counted in the note, three on RustFS (the public bucket among them) with two counted, and the three granted ones on Garage, so no bucket the key is granted nothing on is named on any server (the server layer, read per bucket).
  • A bucket the agent key is granted nothing on, against one that does not exist, and a key in it that exists, against one that does not: every named read answers the two alike, word for word once the name is taken out, in the one missing-or-unreadable sentence of section 15.3, on Garage too, which answers them 403 and 404 (the projection layer).
  • A public bucket: RustFS applies a bucket policy that lets anyone read and list to every signed key, so the agent key reads that bucket's head, location, totals and a named object's metadata there, never its keys; Silo and Garage refuse it as a bucket granted nothing (the server layer).
  • Every refused form (an object or key listing, a count over a prefix, an object or tagging read, a write, another endpoint) is refused before anything is acquired, and no answer holds a key name, the metadata marker, the endpoint's port or a credential; the captures' leaks lists are empty.
  • With the server paused after the acquisition, a 500 ms timeout_ms is the timeout answer within 508 ms, since the provider closes its request at the time left; a client cancel at 1,000 ms of a 3,000 ms call is answered within 1,007 ms while the request runs on until that deadline closes it, which run-s3-read.test.ts holds over a loopback listener.