Architecture¶
Arca is a ground-up implementation of the S3 API in Rust. Rather than wrapping an existing library, every layer — from HTTP protocol handling to storage — is built from first principles, giving us full control over behavior, performance, and correctness.
This page describes the system architecture, the reasoning behind key decisions, and how the pieces fit together at runtime.
System Overview¶
Arca is a single-binary server that accepts S3-compatible HTTP requests on port 9000. It stores object data as UUID-named blob files on a local filesystem and tracks metadata (buckets, objects, credentials) in a SQLite database. A JSON sidecar file accompanies every blob, enabling full database reconstruction without any external backup.
graph LR
C["S3 Client<br/>aws-cli · boto3 · mc · rclone"] -->|"HTTP :9000"| A["Arca Server"]
A --> DB[("SQLite<br/>metadata + credentials")]
A --> FS["Filesystem<br/>UUID blobs + .meta sidecars"]
style C fill:#4527a0,stroke:#7e57c2,stroke-width:2px,color:#fff
style A fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style DB fill:#00695c,stroke:#26a69a,stroke-width:2px,color:#fff
style FS fill:#00695c,stroke:#26a69a,stroke-width:2px,color:#fff
The server is organized as a five-crate Cargo workspace with strict layered dependencies. Each crate has a single, well-defined responsibility and a minimal dependency surface.
Crate Structure¶
| Crate | Role | I/O |
|---|---|---|
| arca-core | Shared types, traits (BlobStore, MetadataStore, CredentialStore), error types |
None |
| arca-auth | AWS Signature V4 verification | None |
| arca-proto | S3 HTTP protocol adapter — Axum 0.8 router, handlers, XML ser/de, middleware | Network |
| arca-storage | Storage implementations — SqliteStore, filesystem blob store (planned) |
Disk + DB |
| arca-server | Binary crate — config, CLI, use-case layer, dependency wiring | All |
arca-core is the foundation: it defines the domain model and storage traits with zero I/O dependencies, making it independently testable and portable. Every other crate depends on it but never on each other horizontally.
Dependency Graph¶
graph TD
SERVER["arca-server<br/>binary · config · CLI · use cases"]
PROTO["arca-proto<br/>Axum router · handlers · XML · middleware"]
STORAGE["arca-storage<br/>SqliteStore · FsBlobStore"]
AUTH["arca-auth<br/>AWS SigV4 verification"]
CORE["arca-core<br/>types · traits · errors"]
SERVER --> PROTO
SERVER --> STORAGE
SERVER --> AUTH
SERVER --> CORE
PROTO --> AUTH
PROTO --> CORE
AUTH --> CORE
STORAGE --> CORE
style CORE fill:#2e7d32,stroke:#4caf50,stroke-width:2px,color:#fff
style SERVER fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style PROTO fill:#00695c,stroke:#26a69a,stroke-width:2px,color:#fff
style AUTH fill:#4527a0,stroke:#7e57c2,stroke-width:2px,color:#fff
style STORAGE fill:#bf360c,stroke:#ff7043,stroke-width:2px,color:#fff
Dependencies flow strictly downward. There are no cycles: arca-proto never imports arca-storage, and arca-storage never imports arca-proto. The binary crate (arca-server) is the only place where all crates meet — it wires the storage implementations into the protocol handlers through the trait abstractions defined in arca-core.
Layered Architecture¶
The system is organized into four distinct layers, each with clear responsibilities and boundaries:
| Layer | Crate | Responsibility |
|---|---|---|
| Protocol | arca-proto | HTTP routing, S3 XML parsing/generation, middleware (auth, virtual host) |
| Use Case | arca-server | Business logic orchestration — validates inputs, coordinates storage calls, enforces invariants |
| Domain | arca-core | Types, traits, error codes — defines what storage does, not how |
| Infrastructure | arca-storage | Concrete implementations — SQLite queries, filesystem I/O, blob management |
This separation means the business logic never depends on a specific database or filesystem layout. Swapping SQLite for Postgres, or local disk for cloud storage, requires only a new trait implementation — no changes to handlers or use cases.
Request Flow¶
Every S3 request passes through a pipeline of middleware layers before reaching business logic:
flowchart TD
REQ(["HTTP Request"]) --> MW0
subgraph MIDDLEWARE["Tower Middleware Stack"]
MW0["Request ID<br/>assign UUID · set x-amz-request-id"]
MW1["Validate<br/>header count · null bytes · metadata size"]
MW2["IP Rate Limit<br/>per-IP GCRA · X-Forwarded-For aware"]
MW3["Audit<br/>record operation · status · latency"]
MW4["Tracing<br/>method · URI · status · latency_ms"]
MW5["SigV4 Auth<br/>verify signature · inject identity"]
MW6["Credential Rate Limit<br/>per-credential GCRA"]
MW7["Virtual Host Rewrite<br/>bucket.s3.domain → /bucket/path"]
end
MW0 --> MW1 --> MW2 --> MW3 --> MW4 --> MW5 --> MW6 --> MW7
MW7 --> ROUTER["Axum Router<br/>route by method + path<br/>dispatch by query params"]
ROUTER --> UC["Use Case Layer<br/>BucketUsecase · ObjectUsecase · MultipartUsecase"]
UC --> CACHE["CachingMetadataStore<br/>LRU cache (moka)"]
UC --> BLOB["BlobStore<br/>(trait)"]
CACHE --> META["MetadataStore<br/>(trait)"]
META --> SQLITE[("SqliteStore<br/>WAL mode")]
BLOB --> FS["FsBlobStore<br/>UUID + .meta sidecar"]
FS --> DISK[("/data/blobs/")]
SQLITE --> DB[("/data/arca.db")]
style REQ fill:#4527a0,stroke:#7e57c2,stroke-width:2px,color:#fff
style MW0 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW1 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW2 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW3 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW4 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW5 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW6 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style MW7 fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style ROUTER fill:#00695c,stroke:#26a69a,stroke-width:2px,color:#fff
style UC fill:#2e7d32,stroke:#4caf50,stroke-width:2px,color:#fff
style CACHE fill:#ef6c00,stroke:#ff9800,stroke-width:2px,color:#fff
style BLOB fill:#ef6c00,stroke:#ff9800,stroke-width:2px,color:#fff
style META fill:#ef6c00,stroke:#ff9800,stroke-width:2px,color:#fff
style SQLITE fill:#bf360c,stroke:#ff7043,stroke-width:2px,color:#fff
style FS fill:#bf360c,stroke:#ff7043,stroke-width:2px,color:#fff
style DISK fill:#37474f,stroke:#78909c,stroke-width:2px,color:#fff
style DB fill:#37474f,stroke:#78909c,stroke-width:2px,color:#fff
The middleware stack is built with Tower, the standard Rust middleware framework. Each layer is independently testable and can be reordered or replaced without affecting the others. Rate limiting and request validation layers are no-ops when disabled in configuration.
Storage Architecture¶
Trait-Based Abstraction¶
All storage operations are defined as async traits in arca-core:
BlobStore— binary data operations (write, read, delete blobs)MetadataStore— structured data operations (buckets, objects, multipart uploads)CredentialStore— credential management (CRUD for access keys)UserStore— user management (CRUD for users)TeamStore— team and membership managementGrantStore— grant (policy) management, attachment to users/teams, effective policy resolution
The use-case layer depends only on these traits, never on concrete implementations. This enables:
- Testing: use cases are tested against in-memory implementations
- Flexibility: swap SQLite for Postgres without touching business logic
- Separation: storage internals are encapsulated behind clean interfaces
Metadata Caching¶
CachingMetadataStore wraps any MetadataStore implementation with an in-memory LRU cache (via the moka crate). It caches head_bucket and get_latest_object results, and invalidates entries on writes. This reduces SQLite query pressure for the most frequent operations (bucket existence checks on every request, HEAD object for conditional operations). The cache is enabled by default and configurable via [server.cache].
SQLite (Metadata + Credentials)¶
Arca uses SQLite via tokio-rusqlite, which runs all database operations on a dedicated background thread to avoid blocking the async runtime.
Key characteristics:
- WAL mode — allows concurrent readers during writes
- Version-tracked migrations — a
_migrationstable tracks applied schema changes; new migrations run automatically at startup - Single database file — stored at
{data_dir}/arca.db
Database Schema¶
The schema is managed through version-tracked migrations (currently at version 9). The entity-relationship diagram below shows all tables and their relationships:
erDiagram
users {
TEXT user_id PK
TEXT username UK "unique"
TEXT description
INTEGER is_root "0 or 1"
TEXT created_at
}
credentials {
TEXT access_key_id PK
TEXT secret_access_key
TEXT description
TEXT created_at
INTEGER active "0 or 1"
INTEGER admin "0 or 1"
TEXT user_id FK
}
teams {
TEXT team_id PK
TEXT name UK "unique"
TEXT description
TEXT created_at
}
team_members {
TEXT team_id PK,FK
TEXT user_id PK,FK
}
grants {
TEXT grant_id PK
TEXT name UK "unique"
TEXT description
TEXT document "JSON policy"
TEXT created_at
TEXT updated_at
}
user_grants {
TEXT user_id PK,FK
TEXT grant_id PK,FK
}
team_grants {
TEXT team_id PK,FK
TEXT grant_id PK,FK
}
buckets {
TEXT name PK
TEXT created_at
TEXT owner FK "default: root"
}
objects {
TEXT bucket FK
TEXT key
TEXT version_id "nullable"
TEXT blob_id
INTEGER size
TEXT etag
TEXT content_type
TEXT last_modified
TEXT metadata "JSON"
TEXT encryption_algorithm "nullable"
TEXT encryption_key_id "nullable"
TEXT owner FK "default: root"
INTEGER is_latest "0 or 1, default 1"
INTEGER is_delete_marker "0 or 1, default 0"
}
multipart_uploads {
TEXT upload_id PK
TEXT bucket
TEXT key
TEXT content_type
TEXT initiated_at
TEXT metadata "JSON"
}
parts {
TEXT upload_id PK,FK
INTEGER part_number PK
TEXT blob_id
INTEGER size
TEXT etag
}
bucket_config {
TEXT bucket PK
TEXT config_key PK
TEXT config_value
TEXT updated_at
}
users ||--o{ credentials : "has"
users ||--o{ team_members : "belongs to"
teams ||--o{ team_members : "contains"
users ||--o{ user_grants : "has"
grants ||--o{ user_grants : "attached to"
teams ||--o{ team_grants : "has"
grants ||--o{ team_grants : "attached to"
buckets ||--o{ objects : "contains"
buckets ||--o{ bucket_config : "configured by"
multipart_uploads ||--o{ parts : "has"
Authentication model: users don't log in directly. Each user has one or more credentials (access key + secret key pairs), and authentication happens via AWS SigV4 signature verification against a credential. The credential's user_id links back to the owning user, and from there the RBAC system resolves effective permissions through direct grants and team grants.
RBAC model: permissions are defined in grants, which contain an IAM-style JSON policy document. Grants can be attached directly to users (via user_grants) or to teams (via team_grants). A user's effective permissions are the union of their direct grants plus all grants from teams they belong to. Three built-in grants are seeded at database creation: AdministratorAccess, S3FullAccess, and S3ReadOnlyAccess.
Ownership: both buckets and objects track an owner field that records which user created the resource.
Encryption: the objects table has optional encryption_algorithm and encryption_key_id columns for SSE-S3 and SSE-KMS. The bucket_config table stores per-bucket settings such as default encryption configuration and versioning status.
Versioning: the objects table supports object versioning through version_id, is_latest, and is_delete_marker columns. When versioning is enabled on a bucket (via bucket_config), each write creates a new version with a unique version_id rather than overwriting the existing row. A partial unique index on (bucket, key) WHERE is_latest = 1 guarantees exactly one current version per key, while previous versions remain queryable. Delete operations insert a delete marker (a zero-size row with is_delete_marker = 1) instead of removing the object.
Filesystem (Blob Storage)¶
Object data is stored as UUID-named files under a dedicated blobs/ subdirectory, keeping the top-level data directory clean:
/data/
├── arca.db # SQLite database
└── blobs/
├── ab/cd/
│ ├── abcd1234-...uuid # Blob file (raw object bytes)
│ └── abcd1234-...uuid.meta # JSON sidecar (metadata)
└── ef/01/
├── ef012345-...uuid
└── ef012345-...uuid.meta
UUID-based naming eliminates path-traversal risks entirely — object keys (which can contain arbitrary characters like ../) are never used in filesystem paths.
Integrity guarantees:
- No duplicate UUIDs — blob files are created with
O_CREAT | O_EXCL(create_new(true)in Rust), which atomically fails if the file already exists - No duplicate latest versions — a partial unique index on
(bucket, key) WHERE is_latest = 1ensures at most one "current" version per object key. When versioning is disabled,put_objectreturns the old record so the caller can delete the orphaned blob. When versioning is enabled, previous versions are preserved alongside the new latest version - Conflict detection —
arca fsckdetects orphaned blobs (no DB record) and conflicting sidecars (multiple.metafiles claiming the samebucket + key).arca recoverresolves conflicts by keeping the newest sidecar (bycreated_at) and reporting discarded duplicates - Sidecar integrity — the
objectstable stores a SHA-256 checksum of each.metasidecar file, enablingarca fsckto detect corruption or tampering
Write Order and Disaster Recovery¶
The write order for storing an object is deliberate:
flowchart LR
A[1. Write blob file] --> B[2. Write .meta sidecar] --> C[3. Insert into SQLite]
style A fill:#1565c0,stroke:#42a5f5,stroke-width:2px,color:#fff
style B fill:#2e7d32,stroke:#4caf50,stroke-width:2px,color:#fff
style C fill:#00695c,stroke:#26a69a,stroke-width:2px,color:#fff
If the server crashes at any point:
- After step 1 only: orphaned blob, no sidecar →
arca fsckdetects and cleans up - After step 2: blob + sidecar exist but DB doesn't know →
arca recoverrebuilds the DB entry from the sidecar - After step 3: fully consistent
The arca recover command walks the blobs/ directory, reads every .meta sidecar file, and reconstructs the SQLite database from scratch. This means the filesystem is the source of truth — the database is a queryable index that can always be rebuilt.
Sidecar .meta Format¶
Every blob is accompanied by a JSON sidecar file containing all metadata needed to reconstruct the database entry:
{
"bucket": "my-bucket",
"key": "photos/2024/vacation.jpg",
"content_type": "image/jpeg",
"size": 1234567,
"etag": "d41d8cd98f00b204e9800998ecf8427e",
"user_metadata": {"x-amz-meta-author": "pietro"},
"created_at": "2026-02-27T14:30:00Z"
}
Data Integrity¶
Blob content integrity is verified via MD5 checksums:
| What | Hash | Stored in | Why this hash |
|---|---|---|---|
| Blob content | MD5 | objects.etag + .meta sidecar |
Required by the S3 protocol — the ETag for non-multipart objects is defined by AWS as the MD5 hex digest |
This gives arca fsck coverage for blob corruption: recompute MD5 of the blob file and compare with the ETag in the database.
Note that this check requires the database to be intact. During arca recover (database is lost), the sidecars are trusted by necessity since there is nothing to compare against.
Key Design Decisions¶
Configuration Migration Without Data Migration¶
A core architectural principle: any configuration change must be possible without migrating data to a new instance. This includes switching storage backends (SQLite to PostgreSQL), enabling encryption on existing data, changing node topology, and any other setting change.
Many S3-compatible servers (notably MinIO) make certain configuration choices permanent — for example, you cannot move from a multi-node erasure-coded setup to a single node without standing up a fresh instance and copying all data over. Arca explicitly rejects this pattern.
Instead, Arca provides offline CLI migration tools (e.g., arca migrate-db) that transform data and metadata in place. When designing new features, this constraint means:
- Storage formats must be evolvable — sidecars, blob layouts, and DB schemas must support incremental migration.
- Configuration changes must have a migration path — every new config option that affects data layout must include a CLI command or startup procedure that converts existing data.
- The data directory is sacred — users should never need to re-upload objects because of an infrastructure change.
No s3s Crate¶
We build our own S3 HTTP protocol adapter with Axum rather than depending on the s3s crate. This was an explicit decision to avoid pre-1.0 dependency risk and maintain full control over the protocol layer. The S3 protocol has many subtle behaviors (error formats, header handling, query-parameter routing) where we need precise control.
S3 Query-Parameter Routing¶
S3 overloads the same HTTP method + path with different query parameters. Since Axum routes by method + path only, handlers dispatch internally based on query params:
| Route | Without query params | With query params |
|---|---|---|
PUT /:bucket/*key |
PutObject | UploadPart (?partNumber=&uploadId=) |
GET /:bucket |
HeadBucket | ListObjectsV2 (?list-type=2) |
POST /:bucket/*key |
— | CreateMultipartUpload (?uploads) or CompleteMultipartUpload (?uploadId=) |
DELETE /:bucket/*key |
DeleteObject | AbortMultipartUpload (?uploadId=) |
This is the same approach used by MinIO, Ceph RGW, and RustFS.
Streaming-First I/O¶
Arca never buffers full objects in memory:
- PutObject: the request body stream is teed to an MD5 hasher and a file writer concurrently. The ETag (MD5 hex digest) is computed on-the-fly without ever holding the full object in memory.
- GetObject: a
tokio::fs::Fileis wrapped in aReaderStreamand sent directly as the response body. Range requests use seek + take for efficient partial reads. - UploadPart: same streaming pattern as PutObject, writing to a temporary part file.
This means Arca can handle multi-gigabyte objects with constant memory usage.
put_object Returns Old Record¶
MetadataStore::put_object returns Option<ObjectRecord> of the overwritten object so the caller can delete the orphaned blob. Without this, blob files would accumulate unboundedly on repeated writes to the same key.
Multipart Composite ETag¶
The composite ETag follows the S3 standard:
The binary MD5 bytes of each part are concatenated (not hex strings), then hashed again. The result is formatted as "<hex>-<count>".
Auth Body Hash Strategy¶
For streaming uploads, buffering multi-gigabyte objects for SHA-256 hashing is impractical. S3 clients send x-amz-content-sha256: UNSIGNED-PAYLOAD, indicating the body is not covered by the signature. Auth middleware verifies the request signature (headers + URI) only. Body integrity is separately guaranteed by Content-MD5 headers.
CLI Architecture¶
The arca binary exposes subcommands via clap:
| Command | Description |
|---|---|
arca serve |
Start the S3 server |
arca credential add |
Generate a new access key pair (--user selects the owning user) |
arca credential list |
List all credentials (shows the owning user) |
arca credential remove |
Delete a credential by access key ID |
arca recover |
Rebuild SQLite DB from .meta sidecar files |
arca fsck |
Check consistency between DB and filesystem |
All subcommands accept --config-path (default: /etc/arca/config.toml) to locate the configuration file, which provides the data_dir path used to find the database and blob storage.
Technology Choices¶
| Component | Choice | Rationale |
|---|---|---|
| Language | Rust | Memory safety, predictable performance, zero-cost abstractions |
| HTTP framework | Axum 0.8 + Tower | Tokio-native, excellent middleware ecosystem, type-safe extractors |
| XML | quick-xml + serde | 10-50x faster than xml-rs, first-class serde integration |
| Metadata DB | SQLite via tokio-rusqlite | Embedded, zero-config, WAL mode for concurrency; trait abstraction allows future Postgres swap |
| Auth | Custom SigV4 (hmac + sha2) | Well-documented algorithm, testable against AWS official test vectors |
| Blob storage | UUID files + .meta sidecars |
No path-traversal risks, enables disaster recovery without external backups |
| CLI | clap (derive) | Standard Rust CLI framework, type-safe argument parsing |
| Logging | tracing + tracing-subscriber | Structured, async-aware, supports JSON output for production |
What's NOT Yet Implemented¶
Object tagging, lifecycle rules, object lock, metrics endpoint, replication, multi-node / distributed mode.
These are deferred by design. The trait-based architecture ensures they can be added incrementally without architectural changes. See the roadmap for the full post-MVP plan.