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 |
Seven facts decide whether the first connection works.
- Type the server's S3 API address, not its web console's: Endpoint host and Port, or paste
http://host:portorhttps://host:portinto 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 |
- 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. - Region: Studio signs for
us-east-1unless 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. - 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.
- 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.
- 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.
- 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.
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).
| 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 |
The provider lives in its own family directory and needs no driver.
src/lib/db/providers/objectstore/s3/, the objectstore family, extends BaseDatabaseProvider directly; the console is under console/ and the preview beside it.
| 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 |
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.
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.
Each decision below states the reason for it.
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).
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.
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.
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.
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.
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.
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.
A connection is one S3 endpoint, an optional key pair, a region and an optional bucket.
| 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).
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).
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.
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 |
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.
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.
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.
| 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. |
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 PermanentRedirectfor 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.
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).
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.
The editor runs one AWS CLI read command per run, or Studio's own preview.
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.
| 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.
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.
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
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
lson a prefix reads to the end and finds nothing: No key and no folder begins with this prefix. - When
head-bucketgets no region header: The bucket exists and this connection may read it: the server sent no region for it. - When
get-bucket-versioninggets no Status: Versioning was never turned on for this bucket: S3 answers no Status. - When
get-object-taggingfinds no tags: The object has no tags. - When
get-object-taggingfinds 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-versionsholds more versions than the grid shows: The server holds more versions than this page shows: narrow --prefix to see the rest. - When
head-objectgets more headers than Studio reads: The server sent more headers than Studio reads, so some fields may be missing.
| 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.
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.
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.
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.
Buckets are objects in the tree; folders and objects are walked in the Keys panel.
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.
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:
- Metadata: size, ETag (with the
-<parts>suffix kept as the server sent it), Last-Modified, Content-Type as stored, Content-Encoding as stored, everyx-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. - 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-streamon MinIO and the same NDJSON file readsapplication/octet-streamorapplication/x-ndjsondepending on the client that wrote it. Text, JSON, NDJSON, CSV and TSV show as text or rows; Parquet shows rows throughhyparquetwith Snappy, gzip, zstd, brotli and LZ4 pages; anything else shows as a hex dump. The preview becomes one to three Source parts:preview, orschemaandrowsfor a Parquet file, thenpreview-notes. A 0-byte object, or a gzip object whose decoded content is empty, gets onepreviewpart 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 withContent-Encoding: gzip(or a.gzkey) 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.
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.
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.
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.
S3 declares no editable object kind and no write path; an object's Source tab is read-only.
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.
S3 declares no maintenance operation, and runMaintenance() refuses before any request: Studio sends an S3 server no maintenance: this version only reads.
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."
}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.
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.
The tests run with no server; the live fixtures and the live check run by hand.
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.
bun tests/run-tests.ts tests/unit/db/s3/
bun tests/run-tests.ts tests/integration/db/s3-provider.test.tsdatabase-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.
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.
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.
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.
- 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-versionsonly; 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_HOSTSor by seeding the connections and settingALLOW_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 --columnsor--schemareads 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.
--queryis not run;get-object-attributes,--start-afterand--fetch-ownerare 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-itemscounts folder rows. - On Garage,
list-object-versionsandget-object-tagginganswer 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.
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."
- AWS S3 API reference: ListBuckets, ListObjectsV2, ListObjectVersions, HeadBucket, HeadObject, GetObject, and SigV4 header-based authentication.
- The servers at the verified versions: minio/minio at commit
9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a, pgsty/silo atRELEASE.2026-09-16T00-00-00Z, Garage atv2.4.1, rustfs/rustfs at1.0.1. hyparquet1.32.0 andhyparquet-compressors1.1.2.
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.
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: []).
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.
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.
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).
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:ListAllMyBucketswith 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'saws s3api list-bucketsnamesscope-grantedandscope-versionsand counts three buckets in a note, on RustFS it names those two andscope-publicand 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
ungrantedBucketLikeMissingandungrantedPresentKeyLikeAbsenthold true for every read, each of those scoped-key reads is refused with that sentence, andobjectsOnlyPresentKeyLikeAbsentis false, as the next bullet says it must be. - A bucket whose objects the key may read but not list (
s3:GetObjectalone):head-objectanswers 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'sleakslist 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.
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 eachrun.sh: the captures above.tests/live/s3-agent-scope.ts, through eachrun.shundertests/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_readthrough 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 eachrun.shwithS3_SCOPE_LIVEset, andtests/unit/db/s3/mcp-agent-key-capture.test.ts, which holds its captures (section 15.7).
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'saws s3api list-bucketsunder the unlisted agent key, which holds nos3: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'
leakslists are empty. - With the server paused after the acquisition, a 500 ms
timeout_msis 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, whichrun-s3-read.test.tsholds over a loopback listener.