@felipedsvit/s3node 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe da Silva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # s3node
2
+
3
+ An embeddable, S3-compatible object storage server for Node.js. Zero runtime
4
+ dependencies — metadata lives in the built-in `node:sqlite`, hashing in
5
+ `node:crypto`, HTTP in `node:http`.
6
+
7
+ The design rationale, the compatibility traps, and the honest limits are in
8
+ [`docs/plan.md`](docs/plan.md).
9
+
10
+ > "S3-compatible". Amazon S3 is a trademark of Amazon Web Services.
11
+
12
+ ## Install
13
+
14
+ Requires Node.js 22.5 or newer (for `node:sqlite`).
15
+
16
+ ```sh
17
+ npm install s3node
18
+ ```
19
+
20
+ ## Run as a server
21
+
22
+ ```sh
23
+ npx s3node --data-dir ./data --port 9000
24
+ ```
25
+
26
+ A credential is generated and printed on first run; pass `--access-key` /
27
+ `--secret-key` (or `S3NODE_ACCESS_KEY_ID` / `S3NODE_SECRET_ACCESS_KEY`) to keep
28
+ it stable. `s3node --help` lists every option.
29
+
30
+ ```sh
31
+ aws --endpoint-url http://127.0.0.1:9000 s3 ls
32
+ aws --endpoint-url http://127.0.0.1:9000 s3 cp ./file.bin s3://my-bucket/
33
+ ```
34
+
35
+ ## Run in-process
36
+
37
+ This is the part no Go or Rust server can do: a real S3 endpoint inside your
38
+ test process, no container, no binary to download.
39
+
40
+ ```js
41
+ import { createServer } from 's3node'
42
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
43
+
44
+ const credentials = { accessKeyId: 'AKIDTEST', secretAccessKey: 'test-secret' }
45
+ const s3node = await createServer({ dataDir: './tmp-data', credentials: [credentials] })
46
+
47
+ const client = new S3Client({
48
+ endpoint: s3node.endpoint, // http://127.0.0.1:<random port>
49
+ region: 'us-east-1',
50
+ forcePathStyle: true,
51
+ credentials,
52
+ })
53
+
54
+ await client.send(new PutObjectCommand({ Bucket: 'b', Key: 'k', Body: 'hello' }))
55
+ await s3node.close()
56
+ ```
57
+
58
+ ### Options
59
+
60
+ | Option | Default | Meaning |
61
+ |---|---|---|
62
+ | `dataDir` | *(required)* | Directory holding blobs and `metadata.sqlite` |
63
+ | `credentials` | `[]` | `{ accessKeyId, secretAccessKey }` entries |
64
+ | `port` / `host` | `0` / `127.0.0.1` | `0` picks a free port |
65
+ | `region` | `us-east-1` | Region reported to clients |
66
+ | `virtualHostDomain` | `null` | Base domain to enable `bucket.domain` addressing |
67
+ | `minPartSize` | `5 MiB` | Minimum size of a non-final multipart part |
68
+ | `logger` | `null` | `{ error(entry) }`; logs include the canonical request on signature failures |
69
+
70
+ ## Supported API
71
+
72
+ **Service** — ListBuckets.
73
+
74
+ **Bucket** — CreateBucket, DeleteBucket, HeadBucket, GetBucketLocation,
75
+ GetBucketVersioning, ListObjectsV2, ListObjects (V1), DeleteObjects.
76
+
77
+ **Object** — PutObject, GetObject, HeadObject, DeleteObject, CopyObject, with
78
+ `Range`, conditional headers (`If-Match`, `If-None-Match`, `If-Modified-Since`,
79
+ `If-Unmodified-Since`), `x-amz-meta-*`, and response header overrides.
80
+
81
+ **Multipart** — CreateMultipartUpload, UploadPart, CompleteMultipartUpload,
82
+ AbortMultipartUpload, ListParts, ListMultipartUploads.
83
+
84
+ **Auth** — SigV4 in the `Authorization` header and in presigned URLs, across all
85
+ four payload modes: literal SHA-256, `UNSIGNED-PAYLOAD`,
86
+ `STREAMING-AWS4-HMAC-SHA256-PAYLOAD`, and `STREAMING-UNSIGNED-PAYLOAD-TRAILER` —
87
+ including per-chunk signature verification and `x-amz-checksum-*` trailers.
88
+
89
+ **Not implemented** — versioning, bucket policy/ACL, CORS, lifecycle, tagging,
90
+ server-side encryption, event notifications, POST form uploads. These return
91
+ `NotImplemented` rather than silently succeeding.
92
+
93
+ ## Design notes
94
+
95
+ Three decisions do most of the work; each is argued in `docs/plan.md`.
96
+
97
+ **`aws-chunked` decoding.** The aws-cli does not send object bytes raw — it
98
+ frames them with per-chunk signatures. A server that writes the request body
99
+ straight to disk stores the framing too, corrupting every CLI upload without
100
+ raising an error. `src/auth/chunked.js` strips the framing and verifies the
101
+ signature chain.
102
+
103
+ **Blob names are decoupled from object keys.** A key may contain `../`; it never
104
+ becomes a filesystem path. That removes path traversal by construction, along
105
+ with the case-folding and name-length problems of key-as-path layouts.
106
+
107
+ **Metadata in SQLite, not the filesystem.** `ListObjectsV2` with a prefix is an
108
+ ordered range scan. The primary key index makes it `O(log n + k)`; a `readdir`
109
+ over the bucket is `O(n)` and unordered. Keys are stored as `BLOB` so SQLite
110
+ orders them by UTF-8 bytes — JavaScript string comparison uses UTF-16 code units
111
+ and disagrees on surrogate pairs.
112
+
113
+ The write path is ordered so that a crash can only ever leave an orphan blob,
114
+ never metadata pointing at missing data: stream to a temp file, `fsync`,
115
+ `rename`, `fsync` the parent directory, and only then commit the metadata row.
116
+
117
+ ## Tests
118
+
119
+ ```sh
120
+ npm test # 153 unit and HTTP-level tests, no network needed
121
+ npm run test:interop # drives the real @aws-sdk/client-s3 against an in-process server
122
+ ```
123
+
124
+ The interop suite is the meaningful compatibility signal: the AWS SDK builds the
125
+ requests, so it cannot accidentally agree with a bug in our own signing code. It
126
+ covers the two shapes that break most S3-compatible servers — `aws-chunked`
127
+ bodies and the CRC32 trailer the SDK has sent by default since v3.729.0.
128
+
129
+ SigV4 is additionally pinned to the known-answer vectors published in the AWS
130
+ documentation, in `test/sigv4.test.js`.
131
+
132
+ ## Limits
133
+
134
+ Single node. Durability is delegated to the filesystem underneath (RAID/ZFS) —
135
+ there is no erasure coding and no multi-node replication, and that is a
136
+ deliberate scope decision rather than a gap: it is where Node loses to Go and
137
+ Rust. Node also has no `sendfile` binding, so every read pays extra copies. See
138
+ `docs/plan.md` sections 6 and 10 for the numbers to measure and the criteria for
139
+ abandoning the approach.
140
+
141
+ ## License
142
+
143
+ ISC.
package/bin/s3node.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+ import { resolve } from 'node:path'
4
+ import { createServer } from '../src/index.js'
5
+ import { generateCredential } from '../src/auth/credentials.js'
6
+
7
+ const USAGE = `
8
+ s3node — S3-compatible object storage server
9
+
10
+ Usage:
11
+ s3node [options]
12
+
13
+ Options:
14
+ --data-dir <path> Where objects and metadata live (default: ./s3node-data)
15
+ --port <number> Port to listen on (default: 9000)
16
+ --host <address> Address to bind (default: 127.0.0.1)
17
+ --region <name> Region reported to clients (default: us-east-1)
18
+ --access-key <id> Access key id (env: S3NODE_ACCESS_KEY_ID)
19
+ --secret-key <secret> Secret access key (env: S3NODE_SECRET_ACCESS_KEY)
20
+ --virtual-host <domain> Base domain for virtual-host style addressing
21
+ --quiet Do not print request errors
22
+ --help Show this message
23
+
24
+ If no credential is supplied, one is generated and printed at startup.
25
+ `
26
+
27
+ const { values } = parseArgs({
28
+ options: {
29
+ 'data-dir': { type: 'string' },
30
+ port: { type: 'string' },
31
+ host: { type: 'string' },
32
+ region: { type: 'string' },
33
+ 'access-key': { type: 'string' },
34
+ 'secret-key': { type: 'string' },
35
+ 'virtual-host': { type: 'string' },
36
+ quiet: { type: 'boolean', default: false },
37
+ help: { type: 'boolean', default: false },
38
+ },
39
+ })
40
+
41
+ if (values.help) {
42
+ process.stdout.write(`${USAGE}\n`)
43
+ process.exit(0)
44
+ }
45
+
46
+ const accessKeyId = values['access-key'] ?? process.env.S3NODE_ACCESS_KEY_ID
47
+ const secretAccessKey = values['secret-key'] ?? process.env.S3NODE_SECRET_ACCESS_KEY
48
+
49
+ let credential
50
+ let generated = false
51
+ if (accessKeyId && secretAccessKey) {
52
+ credential = { accessKeyId, secretAccessKey }
53
+ } else if (accessKeyId || secretAccessKey) {
54
+ process.stderr.write('Both --access-key and --secret-key must be provided together.\n')
55
+ process.exit(1)
56
+ } else {
57
+ credential = generateCredential()
58
+ generated = true
59
+ }
60
+
61
+ const dataDir = resolve(values['data-dir'] ?? './s3node-data')
62
+
63
+ const server = await createServer({
64
+ dataDir,
65
+ port: Number(values.port ?? 9000),
66
+ host: values.host ?? '127.0.0.1',
67
+ region: values.region ?? 'us-east-1',
68
+ virtualHostDomain: values['virtual-host'] ?? null,
69
+ credentials: [credential],
70
+ logger: values.quiet ? null : {
71
+ error(entry) {
72
+ process.stderr.write(`${JSON.stringify(entry)}\n`)
73
+ },
74
+ },
75
+ })
76
+
77
+ process.stdout.write(
78
+ `s3node listening on ${server.endpoint}\n` +
79
+ ` data dir ${dataDir}\n` +
80
+ ` region ${values.region ?? 'us-east-1'}\n` +
81
+ ` access key ${credential.accessKeyId}\n` +
82
+ (generated
83
+ ? ` secret key ${credential.secretAccessKey}\n\n` +
84
+ 'This credential was generated for this run. Pass --access-key/--secret-key\n' +
85
+ '(or S3NODE_ACCESS_KEY_ID / S3NODE_SECRET_ACCESS_KEY) to keep it stable.\n'
86
+ : '') +
87
+ `\nExample:\n` +
88
+ ` AWS_ACCESS_KEY_ID=${credential.accessKeyId} \\\n` +
89
+ ` AWS_SECRET_ACCESS_KEY=${generated ? credential.secretAccessKey : '<secret>'} \\\n` +
90
+ ` aws --endpoint-url ${server.endpoint} s3 ls\n`,
91
+ )
92
+
93
+ let closing = false
94
+ for (const signal of ['SIGINT', 'SIGTERM']) {
95
+ process.on(signal, async () => {
96
+ if (closing) return
97
+ closing = true
98
+ await server.close()
99
+ process.exit(0)
100
+ })
101
+ }
package/docs/plan.md ADDED
@@ -0,0 +1,340 @@
1
+ # s3node — servidor de object storage S3-compatible em Node.js
2
+
3
+ > Documento de análise e roadmap. Julho de 2026.
4
+
5
+ ## Contexto
6
+
7
+ Construir um servidor de armazenamento de objetos compatível com a API do Amazon S3, em Node.js.
8
+
9
+ **Por que agora:** o MinIO — padrão de facto para S3 self-hosted por uma década — teve seu repositório GitHub **arquivado em 12 de fevereiro de 2026**, encerrando a community edition e empurrando usuários para o AIStor comercial. Abriu-se um vácuo real. Ao mesmo tempo, o ecossistema Node **não tem** nenhum servidor S3 moderno e mantido: `s3rver` está parado há 5 anos e se declara ferramenta de teste, e o Zenko CloudServer (Scality) — o único Node de produção — ainda documenta Node 10.x + yarn 1.17.
10
+
11
+ ---
12
+
13
+ ## 1. Veredito e escopo travado
14
+
15
+ **Escopo decidido: produção single-node + embarcável.** Um servidor real para um nó, que também roda in-process via `npm i`. Durabilidade delegada ao RAID/ZFS embaixo — **sem erasure coding** (inviável em JS/WASM, ver 6.4). Distribuição multi-node fica fora, e essa é uma decisão consciente, não uma omissão: é exatamente onde o Node perde para Go/Rust.
16
+
17
+ A ideia é **viável e bem posicionada no tempo**, desde que o escopo seja honesto.
18
+
19
+ - **Viável:** a API do S3 é HTTP + XML + HMAC-SHA256. Nada nela exige uma linguagem de sistema. Object storage é dominado por I/O, não por CPU — o ponto forte do Node.
20
+ - **Bem posicionada:** vácuo pós-MinIO + zero concorrência séria em Node.
21
+ - **Com um teto:** o Node tem limites estruturais (seção 6) que tornam inviável competir em throughput bruto ou em durabilidade distribuída com erasure coding.
22
+
23
+ Regra de ouro do projeto: **compatibilidade de protocolo é o produto**. Um servidor 100% rápido e 80% compatível é inútil — os clientes (aws-cli, rclone, Terraform, s3fs) falham de formas silenciosas e destrutivas. Um servidor com 70% da velocidade e 99% de compatibilidade é um produto.
24
+
25
+ Nome: usar **"S3-compatible"**, nunca "S3". A API em si é reimplementável (MinIO, Ceph e Garage fazem isso abertamente), mas "S3" é marca registrada da AWS.
26
+
27
+ ---
28
+
29
+ ## 2. Superfície da API — o que "igual ao S3" realmente significa
30
+
31
+ A API do S3 tem centenas de operações. O que faz clientes reais funcionarem é um subconjunto bem definido. Prioridade por impacto:
32
+
33
+ ### P0 — sem isso nada funciona
34
+
35
+ | Operação | Rota |
36
+ |---|---|
37
+ | ListBuckets | `GET /` |
38
+ | CreateBucket / DeleteBucket / HeadBucket | `PUT` / `DELETE` / `HEAD` `/{bucket}` |
39
+ | ListObjectsV2 | `GET /{bucket}?list-type=2` |
40
+ | PutObject | `PUT /{bucket}/{key}` |
41
+ | GetObject | `GET /{bucket}/{key}` |
42
+ | HeadObject | `HEAD /{bucket}/{key}` |
43
+ | DeleteObject | `DELETE /{bucket}/{key}` |
44
+ | GetBucketLocation | `GET /{bucket}?location` |
45
+
46
+ Meta: `aws s3 ls` e `aws s3 cp` funcionam.
47
+
48
+ ### P1 — sem isso ferramentas reais quebram
49
+
50
+ - **Multipart completo**: `POST ?uploads` (Create), `PUT ?partNumber=N&uploadId=X` (UploadPart), `POST ?uploadId=X` (Complete), `DELETE ?uploadId=X` (Abort), `GET /{bucket}?uploads` (List), `GET ?uploadId=X` (ListParts). Todo upload maior que 8 MiB do aws-cli usa isso.
51
+ - **DeleteObjects** em lote: `POST /{bucket}?delete` (máx. 1000 chaves).
52
+ - **CopyObject** via header `x-amz-copy-source`, e **UploadPartCopy**.
53
+ - **Range GET** (`Range: bytes=`) e headers condicionais (`If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`).
54
+ - **ListObjects V1** (`GET /{bucket}` sem `list-type`, paginação por `marker`) — rclone e ferramentas antigas ainda usam.
55
+ - **Presigned URLs** (SigV4 via query string).
56
+
57
+ Meta: `rclone sync` e `mc mirror` passam.
58
+
59
+ ### P2 — o que separa brinquedo de produto
60
+
61
+ Versionamento (`?versioning`, `?versions`, `versionId`), bucket policy + subconjunto de IAM, `?cors`, `?lifecycle`, `?tagging`, `?acl`, POST form upload (upload direto do browser com policy assinada), SSE-C / SSE-S3, notificações de evento (webhook), `GetObjectAttributes`.
62
+
63
+ ### Fora de escopo (declarar explicitamente)
64
+
65
+ Object Lock/WORM compliance, Replication cross-region, Glacier/tiering, S3 Select, Access Points, Requester Pays, SigV4a.
66
+
67
+ ---
68
+
69
+ ## 3. Engenharia reversa — a metodologia
70
+
71
+ Esta é a parte operacional. Não adivinhe o formato do wire; **observe-o**.
72
+
73
+ 1. **Captura de tráfego real.** Rodar `aws-cli`, `@aws-sdk/client-s3`, `boto3`, `rclone` e `mc` contra um servidor de referência (fork `pgsty/minio`, SeaweedFS, ou a AWS real) através do `mitmproxy`. Salvar pares request/response como *golden fixtures* e transformá-los em testes de replay. Esta é a fonte de verdade — mais confiável que a documentação.
74
+
75
+ 2. **`aws --debug` como debugger de assinatura.** O aws-cli imprime o `CanonicalRequest` e o `StringToSign` que ele calculou. Fazer o servidor logar os seus e diffar as duas strings. Isso reduz um bug de SigV4 de horas para minutos — é o loop de debug mais valioso do projeto. Construir isso na semana 1.
76
+
77
+ 3. **Ceph `s3-tests` como métrica objetiva.** É a suíte de conformância S3 de facto (boto3/pytest), usada por MinIO, Ceph e Garage. **A KPI principal do projeto deve ser "% de s3-tests passando"**, medida em CI a cada commit. Sem essa métrica, "compatibilidade" vira opinião.
78
+
79
+ 4. **Matriz de interoperabilidade em CI.** Containers rodando: `aws-cli`, `@aws-sdk/client-s3` (versão mais nova, **não** pinada), `boto3`, `rclone`, `mc`, `s5cmd`, backend S3 do Terraform, `s3fs-fuse`. Cada um com um cenário de smoke test.
80
+
81
+ 5. **Teste diferencial.** Mesma requisição enviada ao seu servidor e a um servidor de referência; diffar status, headers e corpo XML. Pega divergências que nenhuma suíte cobre.
82
+
83
+ ---
84
+
85
+ ## 4. Armadilhas de protocolo — onde 90% das implementações falham
86
+
87
+ Estas não são detalhes. Cada uma delas causa **corrupção silenciosa de dados** ou incompatibilidade total.
88
+
89
+ ### 4.1 `aws-chunked` — a armadilha nº 1
90
+
91
+ O aws-cli, por padrão, **não envia o corpo do objeto cru**. Envia com framing de assinatura por chunk:
92
+
93
+ ```
94
+ <tamanho-em-hex>;chunk-signature=<64 hex chars>\r\n
95
+ <dados-do-chunk>\r\n
96
+ ...
97
+ 0;chunk-signature=<sig-final>\r\n\r\n
98
+ ```
99
+
100
+ Se o servidor gravar o corpo direto no disco, **grava o framing junto com os dados e corrompe todo objeto enviado pelo CLI** — sem nenhum erro. Sinalizadores:
101
+
102
+ - `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD` (chunks assinados) ou `STREAMING-UNSIGNED-PAYLOAD-TRAILER` (chunks sem assinatura, checksum no trailer).
103
+ - `Content-Encoding: aws-chunked`.
104
+ - **`Content-Length` é o tamanho *codificado*.** O tamanho real do objeto vem em `x-amz-decoded-content-length` — usar este para o metadado de tamanho.
105
+ - As assinaturas de chunk formam uma **cadeia**: a de cada chunk é calculada sobre a anterior, com a assinatura do header como semente.
106
+
107
+ ### 4.2 Checksums CRC32 padrão — armadilha nova e ativa
108
+
109
+ A partir do **`@aws-sdk/client-s3` v3.729.0** (jan/2025), o SDK calcula um **CRC32 automaticamente** em uploads quando nenhum checksum é fornecido, e o envia via `x-amz-trailer: x-amz-checksum-crc32`. Servidores que não implementam isso respondem `NotImplemented: Header 'x-amz-checksum-crc32' not implemented` — **isso quebrou o Cloudflare R2** e vários outros serviços compatíveis.
110
+
111
+ Implicação: suportar trailers e a família `x-amz-checksum-*` (CRC32, CRC32C, SHA1, SHA256) **não é opcional em 2026**. Testar sempre contra a versão mais recente do SDK, nunca pinada.
112
+
113
+ ### 4.3 ETag
114
+
115
+ - PUT simples: `"<md5-hex-do-conteúdo>"`.
116
+ - Multipart: `"<md5-hex-da-concatenação-binária-dos-MD5-de-cada-parte>-<N>"`, onde N é o número de partes.
117
+
118
+ `rclone` e `aws s3 sync` **validam isso** para decidir se um arquivo mudou. ETag errado = sync infinito ou, pior, arquivos considerados idênticos quando não são. MD5 é criptograficamente morto mas é contrato de wire — obrigatório.
119
+
120
+ ### 4.4 Ordenação de chaves
121
+
122
+ `ListObjectsV2` deve ordenar por **bytes UTF-8**, não por code units UTF-16. Comparação de string em JavaScript (`<`, `sort()`, `localeCompare`) diverge da ordem de bytes em surrogate pairs (emoji, CJK estendido). Comparar `Buffer`s, ou delegar a ordenação ao SQLite armazenando a chave como `BLOB` (comparação `memcmp` = ordem de bytes).
123
+
124
+ ### 4.5 Codificação de URL na assinatura
125
+
126
+ Na assinatura SigV4, o S3 é a **exceção** entre os serviços AWS: a URI canônica é codificada **uma vez**, não duas. Espaço vira `%20` (nunca `+`), `/` permanece literal, e `!'()*` precisam ser percent-encoded (`encodeURIComponent` não faz isso). Errar aqui = todo objeto com espaço ou acento no nome falha com `SignatureDoesNotMatch`.
127
+
128
+ ### 4.6 Header `Host`
129
+
130
+ O SigV4 assina o header `Host`. Um reverse proxy que reescreve `Host` (ou termina TLS mudando o hostname) **quebra todas as assinaturas**. Resolver bucket a partir de `Host` primeiro (virtual-host style: `bucket.dominio/key`), com fallback para path style (`dominio/bucket/key`). Documentar explicitamente a configuração de proxy e o tratamento de `X-Forwarded-Host`.
131
+
132
+ ### 4.7 Códigos de erro XML
133
+
134
+ ```xml
135
+ <Error><Code>NoSuchKey</Code><Message>...</Message><Resource>...</Resource><RequestId>...</RequestId></Error>
136
+ ```
137
+
138
+ Os SDKs fazem dispatch em `<Code>` e a **lógica de retry depende dele**: `SlowDown`, `InternalError` e `RequestTimeout` são retentáveis; `NoSuchKey` e `AccessDenied` não. Código errado faz o cliente ou desistir cedo demais ou entrar em retry infinito.
139
+
140
+ ### 4.8 Formatos menores que quebram clientes
141
+
142
+ - `Last-Modified`: RFC 1123. No XML: ISO 8601 com milissegundos (`2009-10-12T17:50:30.000Z`).
143
+ - Namespace XML: `http://s3.amazonaws.com/doc/2006-03-01/`.
144
+ - `x-amz-request-id` e `x-amz-id-2` em **toda** resposta.
145
+ - `Expect: 100-continue`: o aws-cli envia. Registrar o handler `checkContinue` do `node:http` para **autenticar antes do corpo** — senão o cliente sobe 5 GB antes de receber 403.
146
+ - `encoding-type=url`, `CommonPrefixes` com `delimiter`, `continuation-token` opaco.
147
+
148
+ ---
149
+
150
+ ## 5. Pontos fortes do Node
151
+
152
+ 1. **Streams com backpressure de primeira classe.** `pipeline(req, hashers, fs.createWriteStream())` propaga erro e limpa recursos corretamente. Mover bytes entre socket e disco é exatamente o que o libuv faz bem.
153
+ 2. **Alta concorrência com pouca memória por conexão.** Object storage é workload de I/O; milhares de conexões lentas em paralelo é o cenário ideal do event loop.
154
+ 3. **`node:crypto` é OpenSSL nativo.** MD5, SHA-256, HMAC e AES-GCM rodam em C, não em JS.
155
+ 4. **Node 24 traz `node:sqlite` embutido** (`DatabaseSync`, `StatementSync`, `Session`, `backup`) — banco de metadados transacional com **zero dependências**. Verificado neste ambiente (Node v24.18.0).
156
+ 5. **Embarcabilidade — a vantagem real.** `npm i s3node` e subir um servidor no mesmo processo do teste, sem baixar binário Go/Rust, sem Docker. Nenhum concorrente oferece isso hoje (o `s3rver` oferecia e morreu).
157
+ 6. **Programabilidade — o diferencial de produto.** Hooks JS por requisição: transformação de objeto no upload, autenticação customizada, eventos, roteamento por tenant. "Object storage programável" é algo que MinIO e Garage não fazem bem, e é natural em Node.
158
+ 7. **Iteração rápida em conformância.** Como compatibilidade é o produto, velocidade de correção importa mais que velocidade de execução.
159
+
160
+ ---
161
+
162
+ ## 6. Pontos fracos — estruturais, não contornáveis
163
+
164
+ 1. **Sem `sendfile` / zero-copy.** Verificado: o módulo `fs` do Node não expõe `sendfile`. Todo GET faz kernel → userspace → kernel, com duas cópias extras e pressão de GC nos buffers. Go (`http.ServeContent`) e Rust usam `sendfile` direto. **Este é um teto estrutural de throughput.** Mitigações: `highWaterMark` grande (1 MiB), offload de leituras quentes para nginx via `X-Accel-Redirect`, ou addon nativo.
165
+
166
+ 2. **Hashing consome CPU do event loop.** `hash.update()` é síncrono na thread principal. MD5 + SHA-256 + CRC32 sobre cada byte satura um core bem antes de saturar um NVMe. Mitigação: calcular **apenas** os digests que a requisição realmente exige, e mover para worker threads acima de um limiar de tamanho.
167
+
168
+ 3. **Single-thread.** Usar todos os cores exige `cluster` com `SO_REUSEPORT` — o que reabre o problema do banco de metadados compartilhado (WAL do SQLite com múltiplos escritores gera `SQLITE_BUSY`). Alternativa: um processo/worker único de metadados com RPC.
169
+
170
+ 4. **Erasure coding é inviável.** Reed-Solomon em JS puro ou WASM é lento demais. Isso **elimina** a estratégia de durabilidade do MinIO. Durabilidade fica dependente de RAID/ZFS embaixo, ou de replicação simples (mais cara em disco).
171
+
172
+ 5. **Cauda de latência por GC.** Pausas de GC sob alto throughput geram picos de p99. Inimigo: churn de alocação. Evitar `Buffer.concat` e manipulação de string no caminho quente.
173
+
174
+ 6. **Sem consenso distribuído prático.** Raft em JS existe, mas nenhuma implementação é battle-tested. Multi-node com consistência forte é território de Go/Rust.
175
+
176
+ **Leitura honesta do teto:** com clustering, o Node deve sustentar workloads de 10–25 GbE confortavelmente. A 100 GbE ou saturando NVMe, o Node vira o gargalo antes do hardware. Esses números são estimativas de ordem de grandeza — **precisam ser medidos, não assumidos** (ver seção 10).
177
+
178
+ ---
179
+
180
+ ## 7. Pontos críticos de segurança
181
+
182
+ Este é um serviço que guarda credenciais, é exposto à internet e controla acesso a dados. Os itens abaixo não são melhorias incrementais — cada um é uma falha que vaza ou destrói dados.
183
+
184
+ - **Path traversal em chaves.** Uma chave de objeto pode conter `../`. **Nunca** mapear a chave diretamente para um caminho de filesystem. O armazenamento content-addressed (seção 8) elimina essa classe inteira de bug por construção — este é um dos principais motivos para adotá-lo.
185
+
186
+ - **XXE e billion-laughs no parsing XML.** Os corpos de `CompleteMultipartUpload`, `Delete` e bucket policy são XML controlado pelo atacante. Desabilitar DTD e expansão de entidades (`fast-xml-parser` com `processEntities: false`), limitar o tamanho do corpo (ex.: 1 MiB) e a profundidade de aninhamento.
187
+
188
+ - **Limites de lote.** `DeleteObjects` tem máximo de 1000 chaves por especificação. Validar no servidor — não confiar no cliente.
189
+
190
+ - **Comparação de assinatura.** Usar `crypto.timingSafeEqual`, nunca `===`. Comparação de string vaza a assinatura por timing.
191
+
192
+ - **Janela de replay.** Rejeitar requisições com skew de relógio maior que 15 minutos. Presigned URLs: validar `X-Amz-Expires` contra o máximo de 7 dias e contra o relógio do servidor.
193
+
194
+ - **Credenciais em repouso.** O SigV4 exige o secret key em texto claro no servidor para recomputar o HMAC — não há como armazenar apenas um hash. Portanto o credential store é ativo de valor máximo: cifrar em repouso com uma master key, permissões restritas de arquivo, e nunca logar.
195
+
196
+ - **Avaliação de bucket policy / ACL.** Um bug aqui é vazamento público de dados. Default-deny; deny explícito sempre vence allow; testar a matriz de decisão exaustivamente.
197
+
198
+ - **Exaustão de recursos.** Limites de tamanho de objeto, de conexões por IP e rate limiting. Sem isso, um cliente enche o disco.
199
+
200
+ - **Integridade do header `Host`.** Ver 4.6 — além de quebrar assinaturas, a resolução de bucket via `Host` é superfície de injeção se confiar cegamente em `X-Forwarded-Host`.
201
+
202
+ ---
203
+
204
+ ## 8. Arquitetura recomendada
205
+
206
+ ### Camada HTTP
207
+
208
+ `node:http` direto com router customizado. O roteamento do S3 é atípico — subrecursos vêm da query string (`?uploads`, `?acl`, `?delete`, `?versioning`), o que se encaixa mal em routers convencionais. Fastify é opção, mas exige desabilitar todo body parsing (corpos S3 são bytes crus) e o ganho fica pequeno. **Nunca bufferizar corpos** — `req` é stream, vai direto para o disco.
209
+
210
+ ### Metadados — SQLite via `node:sqlite`
211
+
212
+ Zero dependências, transacional, WAL mode. Tabela `objects` com PK `(bucket, key BLOB, version_id)`.
213
+
214
+ **Por que banco e não filesystem para listagem:** `ListObjectsV2` com prefixo e paginação é uma *range scan ordenada*. Em SQLite: `WHERE bucket=? AND key > ? AND key < <limite-superior-do-prefixo> ORDER BY key LIMIT n` — custo O(log n + k) por índice. Com filesystem, é `readdir` sobre o bucket inteiro, sem ordem garantida: um bucket com 10M objetos torna a listagem inviável. Bônus: `BLOB` resolve a ordenação por bytes da seção 4.4 de graça.
215
+
216
+ Escape hatch se a contenção de escrita do SQLite doer: `lmdb` (escritas em batch, muito rápido).
217
+
218
+ ### Dados — blobs content-addressed
219
+
220
+ Blobs em `data/<aa>/<bb>/<nome>` com fanout de 2 níveis (256×256 diretórios, evita diretórios gigantes). Nome do blob desacoplado da chave do objeto — mata path traversal e conflitos de nome (chave `a/b` e `a/b/c` coexistindo, limite de 255 bytes por componente, filesystems case-insensitive no macOS/Windows, nomes reservados do Windows).
221
+
222
+ Começar com blobs nomeados por UUID. **Deduplicação por SHA-256 vem depois** — exige refcounting e é fonte fértil de bugs de perda de dados no início.
223
+
224
+ ### Caminho de escrita durável
225
+
226
+ A ordem desta sequência é crítica para correção. Executar exatamente assim:
227
+
228
+ 1. Stream do corpo para `tmp/<uuid>`, **no mesmo filesystem** do destino final (senão `rename` não é atômico).
229
+ 2. Calcular os hashes necessários em passagem única, via Transform stream.
230
+ 3. Validar `Content-MD5`, `x-amz-content-sha256` e trailers `x-amz-checksum-*`.
231
+ 4. `fsync` no arquivo, depois fechar.
232
+ 5. `rename()` de tmp para o caminho final do blob.
233
+ 6. `fsync` no **diretório pai** — sem isso o rename pode não sobreviver a queda de energia.
234
+ 7. **Só então** commitar a linha de metadados na transação SQLite.
235
+
236
+ Um crash entre os passos 6 e 7 deixa um blob órfão — inofensivo, o GC varre depois. **Inverter a ordem** (metadados antes do blob) produz metadados apontando para blob inexistente = perda de dados observável pelo cliente.
237
+
238
+ ### Multipart
239
+
240
+ Cada parte é um blob próprio; `Complete` grava um **manifesto**, sem concatenar fisicamente. Concatenar reescreveria o objeto inteiro no Complete (custo altíssimo) e destruiria a eficiência de Range GET e de `partNumber`. Contrapartida: a leitura precisa de concatenação virtual (stream sobre múltiplos arquivos) e o GC fica mais complexo.
241
+
242
+ Regras a respeitar: partes fora de ordem, em paralelo, e reenviadas (última escrita vence por `partNumber`); mínimo 5 MiB exceto a última; máximo 10.000 partes; uploads abandonados precisam de GC por lifecycle; `CompleteMultipartUpload` concorrente no mesmo `uploadId` precisa ser serializado/idempotente.
243
+
244
+ ### SigV4
245
+
246
+ Cadeia de derivação da signing key:
247
+
248
+ ```
249
+ kDate = HMAC("AWS4" + secret, yyyymmdd)
250
+ kRegion = HMAC(kDate, region)
251
+ kService = HMAC(kRegion, "s3")
252
+ kSigning = HMAC(kService, "aws4_request")
253
+ ```
254
+
255
+ `kSigning` muda apenas diariamente — **cachear por `(accessKey, date, region)`** é um ganho de CPU relevante no caminho quente.
256
+
257
+ Quatro modos de payload a suportar: `UNSIGNED-PAYLOAD`, SHA-256 hex literal, `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` e `STREAMING-UNSIGNED-PAYLOAD-TRAILER`.
258
+
259
+ ### Dependências
260
+
261
+ Manter mínimo — a história de "embarcável" depende disso.
262
+
263
+ | Função | Escolha |
264
+ |---|---|
265
+ | HTTP | `node:http` |
266
+ | Metadados | `node:sqlite` (builtin) |
267
+ | Hashes | `node:crypto` (builtin) |
268
+ | CRC32/CRC32C | `@aws-crypto/crc32` ou WASM (não há builtin) |
269
+ | XML saída | serializer próprio (caminho quente, XML do S3 é simples) |
270
+ | XML entrada | `fast-xml-parser` com entidades desabilitadas |
271
+ | Testes | `node:test` + Ceph s3-tests + clientes reais em CI |
272
+
273
+ ---
274
+
275
+ ## 9. Roadmap
276
+
277
+ **P0 — Fundação. ✅ Implementado.** Esqueleto HTTP, SigV4 completo (os 4 modos de payload, incluindo decodificação `aws-chunked` e trailers), CRUD de bucket, Put/Get/Head/Delete de objeto, ListObjectsV2, XML de erro. O logger emite o CanonicalRequest e o StringToSign calculados quando uma assinatura falha, para o diff da seção 3.2.
278
+
279
+ **P1 — Ferramentas reais. ✅ Implementado.** Multipart completo, DeleteObjects, CopyObject, Range GET, headers condicionais, ListObjects V1, presigned URLs.
280
+ **Portão pendente:** validar contra `rclone sync` e `mc mirror` — nenhum dos dois está disponível neste ambiente. O que foi validado: **23/23 no `@aws-sdk/client-s3` v3.1095.0 real**, incluindo `aws-chunked` com trailer CRC32 padrão, multipart de 12 MiB via `lib-storage`, presigned GET/PUT e range cruzando fronteira de parte (`npm run test:interop`).
281
+
282
+ **P2 — Produto (a fazer).** Versionamento, bucket policy + subconjunto de IAM, CORS, lifecycle, tagging, POST form upload, SSE-C/SSE-S3, notificações de evento. Hoje esses subrecursos respondem `NotImplemented` em vez de fingir sucesso.
283
+ **Portão:** ≥ 80% do Ceph s3-tests — ainda não executado.
284
+
285
+ **P3 — Escala vertical (depois).** `cluster` com `SO_REUSEPORT`, metadados em worker thread, offload de leitura, backends de gateway (proxy para outro S3/Azure/GCS). Replicação assíncrona para réplica quente/backup — **não** como estratégia de consistência distribuída, que está fora de escopo.
286
+
287
+ ---
288
+
289
+ ## 10. Como validar (e critérios de abandono)
290
+
291
+ **Medir, não assumir.** Antes do P2, produzir números reais:
292
+
293
+ - Throughput de GET de objeto grande (single e multi-core), comparado a SeaweedFS no mesmo hardware. Se a diferença for maior que 5×, o teto do Node é pior que o estimado e o posicionamento precisa mudar.
294
+ - QPS de objeto pequeno — provavelmente limitado pelo banco de metadados, não pelo HTTP. Confirmar quem é o gargalo antes de otimizar.
295
+ - p99 de latência sob carga sustentada, para quantificar as pausas de GC.
296
+ - Teste de crash: matar o processo com `kill -9` durante uploads e verificar que nenhum metadado aponta para blob ausente.
297
+
298
+ **Critérios de abandono, definidos agora enquanto o julgamento é frio:**
299
+
300
+ - Se o Ceph s3-tests não chegar a 60% até o fim do P1, o custo de conformância foi subestimado.
301
+ - Se o throughput de GET single-node ficar abaixo de 1 GB/s por core, a proposta de valor precisa recuar para "ferramenta de dev/teste" em vez de "servidor de produção".
302
+
303
+ ---
304
+
305
+ ## 11. Posicionamento
306
+
307
+ Não é "MinIO em Node". É o **servidor S3-compatible embarcável e programável** — `npm i`, sobe no processo, hooks em JavaScript.
308
+
309
+ | Concorrente | Situação (2026) |
310
+ |---|---|
311
+ | MinIO | Repo arquivado em fev/2026, community edition morta |
312
+ | SeaweedFS | Apache 2.0, ~31,7k stars, virou padrão do Kubeflow Pipelines |
313
+ | Garage | Rust, foco em geo-distribuído leve |
314
+ | RustFS | Rust, ~4k stars, mira drop-in do MinIO, imaturo |
315
+ | Ceph RGW | Pesado, só faz sentido se já roda Ceph |
316
+ | Zenko CloudServer | Node, mantido, mas documenta Node 10.x + yarn 1.17; foco enterprise |
317
+ | s3rver | Node, parado há 5 anos, auto-declarado test-only |
318
+
319
+ O nicho está vazio e é defensável: nenhum dos concorrentes em Go/Rust pode rodar dentro do seu processo Node, e nenhum expõe middleware em JavaScript.
320
+
321
+ **Licença:** Apache 2.0 ou MIT. AGPL foi exatamente o caminho que corroeu a confiança da comunidade no MinIO.
322
+
323
+ ---
324
+
325
+ ## 12. Notas de implementação
326
+
327
+ - O campo `devEngines.packageManager: pnpm` do `package.json` original fazia `npx`/`npm` falharem com `EBADDEVENGINES` dentro do repositório. Foi removido, e `engines.node` passou a `>=22.5.0` (requisito do `node:sqlite`).
328
+ - Zero dependências de runtime. O parser de XML de entrada é próprio e minúsculo: não processa DTD, declaração de entidade nem CDATA, o que torna XXE e billion-laughs impossíveis por construção em vez de por configuração — mais forte que desabilitar essas opções no `fast-xml-parser`.
329
+ - CRC32 e CRC32C são table-driven in-tree, pelo mesmo motivo.
330
+ - **Bug encontrado pelo SDK real, não pelos testes próprios:** o servidor devolvia o `x-amz-checksum-crc32` do objeto inteiro numa resposta 206 de range. O SDK valida o checksum contra os bytes que recebeu e falhava corretamente. Correção: omitir os headers `x-amz-checksum-*` em respostas parciais. É exatamente o tipo de divergência que só teste contra cliente de verdade pega — e a justificativa concreta para a metodologia da seção 3.
331
+
332
+ ---
333
+
334
+ ## 13. Fontes
335
+
336
+ - [MinIO Community Edition arquivada (fev/2026)](https://thecloudsupportengineer.com/the-end-of-an-era-minio-community-edition-is-archived-whats-next/) · [MinIO Is Dead, Long Live MinIO](https://blog.vonng.com/en/db/minio-resurrect/) · [MinIO encerra desenvolvimento community](https://faun.dev/co/news/devopslinks/minio-ends-community-development-positions-aistor-as-the-future/)
337
+ - [Anúncio da mudança de integridade padrão no S3 — aws-sdk-js-v3 #6810](https://github.com/aws/aws-sdk-js-v3/issues/6810) · [v3.729.0 quebra compatibilidade S3 do R2](https://community.cloudflare.com/t/aws-sdk-client-s3-v3-729-0-breaks-uploadpart-and-putobject-r2-s3-api-compatibility/758637) · [Data Integrity Protections for Amazon S3](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)
338
+ - [Alternativas ao MinIO em 2026](https://akmatori.com/blog/minio-alternatives-2026-comparison) · [Self-hosted S3 depois do MinIO](https://productimpossible.com/articles/self-hosted-s3-after-minio/)
339
+ - [Zenko CloudServer (Scality)](https://github.com/scality/cloudserver) · [s3rver](https://www.npmjs.com/package/s3rver)
340
+ - [Ceph s3-tests como suíte de conformância](https://medium.com/@peeyushjhorar/s3-compatibility-tests-using-ceph-s3-library-54b048ade135)
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@felipedsvit/s3node",
3
+ "version": "0.1.2",
4
+ "description": "Embeddable, S3-compatible object storage server for Node.js",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "s3node": "bin/s3node.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "bin",
16
+ "docs",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22.5.0"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test \"test/*.test.js\"",
25
+ "test:watch": "node --test --watch \"test/*.test.js\"",
26
+ "test:interop": "node test/interop/aws-sdk.mjs",
27
+ "start": "node bin/s3node.js"
28
+ },
29
+ "devDependencies": {
30
+ "@aws-sdk/client-s3": "^3.1095.0",
31
+ "@aws-sdk/lib-storage": "^3.1095.0",
32
+ "@aws-sdk/s3-request-presigner": "^3.1095.0"
33
+ },
34
+ "keywords": [
35
+ "s3",
36
+ "s3-compatible",
37
+ "object-storage",
38
+ "storage",
39
+ "sigv4"
40
+ ],
41
+ "author": "",
42
+ "license": "ISC",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/felipedsvit/s3node.git"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }