diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 927e9c2..5dd23a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: # There is deliberately no SQLite job. The runtime measures its guest into # PCR16 before it will serve, and that reads the NSM — which a hosted runner # does not have, so the binary refuses to start there at all. The workload - # lives on in scripts/ci-sqlite.sh for a machine with an enclave. + # runs in the emulator instead: docs/STORAGE.md, "Running SQLite". e2e: name: the whole stack, in an emulated enclave runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index ec77297..208f31f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,18 +11,6 @@ dependencies = [ "gimli", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -1020,18 +1008,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake3" version = "1.8.6" @@ -2016,9 +1992,11 @@ dependencies = [ "aws-lc-rs", "aws-sdk-cloudwatchlogs", "aws-sdk-kms", + "aws-sdk-s3", "aws-sdk-ssm", "aws-sigv4", "aws-smithy-http-client", + "aws-smithy-runtime-api", "aws-smithy-types", "base64 0.22.1", "blake3", @@ -2040,6 +2018,7 @@ dependencies = [ "hyper-util", "nitro-attestation", "nitro-nsm", + "parking_lot", "pem", "prost", "rcgen 0.14.8", @@ -2047,10 +2026,14 @@ dependencies = [ "rustls 0.23.40", "rustls-acme", "rustls-webpki 0.103.13", - "s3fs-core", "serde", "serde_json", + "siphasher", "spki", + "tempfile", + "testcontainers", + "testcontainers-modules", + "thiserror 1.0.69", "tokio", "tokio-rustls 0.26.4", "tokio-stream", @@ -2241,12 +2224,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.32" @@ -2487,15 +2464,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -2527,15 +2495,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -3782,12 +3741,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.6" @@ -4209,36 +4162,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "s3fs-core" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "aws-config", - "aws-lc-rs", - "aws-sdk-s3", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bitvec", - "blake3", - "bytes", - "criterion", - "fastrand", - "futures", - "hashlink", - "parking_lot", - "percent-encoding", - "siphasher", - "testcontainers", - "testcontainers-modules", - "thiserror 1.0.69", - "tokio", - "tokio-util", - "tracing", - "zeroize", -] - [[package]] name = "same-file" version = "1.0.6" @@ -4664,12 +4587,6 @@ dependencies = [ "winx", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.13.5" @@ -4942,7 +4859,6 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", - "futures-util", "pin-project-lite", "tokio", ] @@ -6304,15 +6220,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x509-cert" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 9ae2765..95b8653 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "crates/nitro-attestation", "crates/nitro-nsm", - "crates/s3fs-core", "runtime", ] @@ -12,7 +11,7 @@ version = "0.1.0" edition = "2021" rust-version = "1.88" license = "Apache-2.0" -repository = "https://github.com/joshua/s3-wasi-fs" +repository = "https://github.com/BitspendPayment/enclave-runtime" [workspace.dependencies] tokio = { version = "1", default-features = false } @@ -23,20 +22,15 @@ anyhow = "1" async-trait = "0.1" futures = "0.3" parking_lot = "0.12" -fastrand = "2" siphasher = "1" -hashlink = "0.9" -bitvec = "1" -percent-encoding = "2" # Scrubs key material on drop. Enclave memory is unreadable from outside, but # key bytes should not outlive their use inside it either. zeroize = { version = "1", features = ["zeroize_derive"] } -# Merkle-tree block checksums and the root-record hash chain. BLAKE3 is the -# hot path (every block read verifies one), so its SIMD backends matter. +# The anchor chain's hashes and the boot's state root. blake3 = "1" -# AEAD (AES-256-GCM), Ed25519 root signatures, HKDF key derivation. aws-lc-rs -# is the AWS-native, FIPS-capable stack — the same primitives KMS speaks. +# AES-256-GCM, Ed25519 anchor signatures, HKDF key derivation. aws-lc-rs is +# the AWS-native, FIPS-capable stack — the same primitives KMS speaks. aws-lc-rs = { version = "1", default-features = false, features = ["aws-lc-sys"] } aws-config = { version = "1", default-features = false } diff --git a/README.md b/README.md index 3d238db..1863150 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ The runtime links the standard WASI 0.2 interfaces and serves each one from insi | Hash-table seeding | `wasi:random/insecure-seed` | A fresh NSM draw for every instance | | An HTTP handler | `wasi:http/incoming-handler` | TLS terminated **inside** the enclave, behind the passkey gate; the runtime sets the caller's tenant in `x-enclave-tenant` | | An outbound HTTP request | `wasi:http/outgoing-handler` | **Denied by default.** Exact origins named in the measured image are allowed, verified against web PKI roots compiled into the image | -| `std::fs` | `wasi:filesystem` | A per-tenant directory in an encrypted, S3-backed store ([how](docs/STORAGE.md)) | +| `std::fs` | `wasi:filesystem` | A per-tenant ZFS dataset on an encrypted disk, anchored in S3 ([how](docs/STORAGE.md)) | | `println!`, `eprintln!` | `wasi:cli/stdout`, `stderr` | Bounded, structured log records tagged `guest`, optionally forwarded to CloudWatch | -| `std::env::var` | `wasi:cli/environment` | A curated environment: `AWS_*` and `S3FS_*` are never inherited, and the image's values are covered by PCR0 | +| `std::env::var` | `wasi:cli/environment` | The settings the guest file carries, measured into PCR16; nothing from the runtime's environment | | stdin, raw sockets | `wasi:cli/stdin`, `wasi:sockets` | Stdin is closed. Sockets are linked so guests instantiate, but every address is refused | `wasi:clocks/monotonic-clock` and `wasi:random/insecure` keep wasmtime's host-backed defaults. They measure durations and drive non-cryptographic generators; they never supply a timestamp or a secret. @@ -63,7 +63,7 @@ An enclave has no NTP. The hypervisor sets its system clock, and the parent inst | Push signing | The SigV4 date on every call to the push service. AWS checks it, which makes this the one place an outside party checks the enclave's clock; skew shows up as a refused signature, retried | | Notification queue | Device enrollment times and retry backoff | -A read that fails mid-run returns the last good reading and logs a warning. A few milliseconds stale is harmless; a zero timestamp means 1970 dates, expired certificates and rejected tokens. The production image sets `S3FS_CLOCK_SOURCE=ptp`, so a missing device stops the boot, while `auto` falls back to the host clock with a warning. The QEMU image uses the host clock because QEMU has no PTP device. +A read that fails mid-run returns the last good reading and logs a warning. A few milliseconds stale is harmless; a zero timestamp means 1970 dates, expired certificates and rejected tokens. A production build always reads PTP, so a missing device stops the boot. The emulator's `testing` build uses the host clock, because QEMU has no PTP device. ### One entropy source @@ -73,10 +73,9 @@ A read that fails mid-run returns the last good reading and logs a warning. A fe |---|---| | Guest `wasi:random/random` | Every byte, straight from the device (256 bytes per NSM request) | | Guest `wasi:random/insecure-seed` | A fresh seed per instance, so no two instances share a hash seed | -| Tenant IDs | 16 bytes at registration, so the host cannot predict a tenant's directory before it exists | | Authentication | Registration and challenge IDs, and the 32-byte single-use tokens that admit a request | -If the device fails, `wasi:random` stops the process rather than return predictable bytes. The clock can serve a stale reading because a stale timestamp is still a real one and a caller can notice. Predictable bytes look random to the guest, and a key built from them can never be detected downstream. The production image sets `S3FS_RANDOM_SOURCE=nsm`, and `auto` falls back to kernel entropy with an error-level log. The TLS key comes from the kernel pool through `aws-lc-rs`. Inside an enclave the NSM is that pool's only seed, and requiring `/dev/nsm` at boot proves the runtime is in an enclave. +If the device fails, `wasi:random` stops the process rather than return predictable bytes. The clock can serve a stale reading because a stale timestamp is still a real one and a caller can notice. Predictable bytes look random to the guest, and a key built from them can never be detected downstream. A production build always reads the NSM. The TLS key comes from the kernel pool through `aws-lc-rs`. Inside an enclave the NSM is that pool's only seed, and requiring `/dev/nsm` at boot proves the runtime is in an enclave. `enclave-runtime --self-check` prints the chosen clock, its skew against `CLOCK_REALTIME` and a sanity check of the entropy source, then exits without touching storage. @@ -136,7 +135,7 @@ How notify draws on the rest of the runtime: - **Storage.** Device tokens live in `/runtime/devices` in the encrypted store, outside every tenant's scope. A guest enrolls a token and can only ever get a count back. - **Delivery** is best effort and bounded. Repeat wakes for one `(tenant, category)` coalesce, and a full queue drops and counts instead of blocking the guest. Transient failures back off, and tokens the service reports gone are pruned. Each tenant can enroll 8 devices; enrolling a ninth evicts the oldest. -A parent that borrows the role can send wake signals through that one application and nothing more. A wake carries no data, and the app's response to any wake is to fetch over the attested channel. Set `S3FS_PUSH_APP_ID` (measured, not secret). See [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md). +A parent that borrows the role can send wake signals through that one application and nothing more. A wake carries no data, and the app's response to any wake is to fetch over the attested channel. Set `ENCLAVE_PUSH_APP_ID` (measured, not secret). See [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md). ### `enclave:streams` — let a service talk first @@ -178,7 +177,7 @@ forget: func(id: string) -> result<_, string>; export run-task: func(task-id: string, payload: list) -> result, string>; ``` -An approved request can schedule work for later, optionally recurring at intervals of one second or more. `run-at` is Unix milliseconds on the PTP clock. Task records live in the encrypted store and survive restarts. Each run gets a fresh instance, holds the tenant's lock and has a deadline. Failed runs are retried a bounded number of times, and results are kept for `status`. Execution is at least once, so deduplicate by the run ID (`::`). Payloads and results are capped at 64 KiB. Enable with `S3FS_BACKGROUND_TASKS=true`; see [docs/BACKGROUND_TASKS.md](docs/BACKGROUND_TASKS.md). +An approved request can schedule work for later, optionally recurring at intervals of one second or more. `run-at` is Unix milliseconds on the PTP clock. Task records live in the encrypted store and survive restarts. Each run gets a fresh instance, holds the tenant's lock and has a deadline. Failed runs are retried a bounded number of times, and results are kept for `status`. Execution is at least once, so deduplicate by the run ID (`::`). Payloads and results are capped at 64 KiB. They run when the guest exports `run-task`; see [docs/BACKGROUND_TASKS.md](docs/BACKGROUND_TASKS.md). ### Who may call what @@ -198,7 +197,7 @@ Background work cannot grant itself more work, connections or devices. It can on ### Instances are disposable -A healthy HTTP instance may be reused for the same tenant, but `run-task` and `on-message` always get fresh ones, and any instance can be discarded after a trap, an eviction or a restart. Keep durable state in files and sync or close them before returning, because files a discarded instance left open are released unflushed. A tenant's requests, tasks and message callbacks run one at a time; different tenants run concurrently. +Every request, `run-task` and `on-message` call gets a fresh instance, dropped when the call ends, so nothing in memory survives from one call to the next. Keep durable state in files and sync or close them before returning, because files an instance left open are released unflushed when it is dropped. A tenant's requests, tasks and message callbacks run one at a time; different tenants run concurrently. ### All together: a background job wakes its owner @@ -245,7 +244,7 @@ The harness boots the real runtime image in QEMU's `nitro-enclave` machine with rustup target add wasm32-wasip2 sudo modprobe vsock_loopback -docker build -t s3fs-qemu-nitro:latest deploy/qemu-nitro # built from source; ~680 MB +docker build -t enclave-qemu-nitro:latest deploy/qemu-nitro # built from source; ~680 MB cargo install vhost-device-vsock --version 0.3.0 --locked \ --root target/qemu-nitro/tools @@ -285,7 +284,7 @@ deploy/qemu-nitro/dev-enclave.sh \ --guest-env SERVICE_URL=http://192.168.127.254:7070 ``` -The guest must export `wasi:http/incoming-handler`, and may use the [WIT capabilities](wit/). The emulator image enables background tasks by default, which requires the guest to export `run-task`. For an HTTP-only guest, add `--guest-env S3FS_BACKGROUND_TASKS=false`. `192.168.127.254` reaches the development host through gvproxy, on every port but the runtime's own; otherwise a guest reaches the public internet and nothing else. +The guest must export `wasi:http/incoming-handler`, and may use the [WIT capabilities](wit/). Background tasks run if the guest exports `run-task`. `192.168.127.254` reaches the development host through gvproxy, on every port but the runtime's own; otherwise a guest reaches the public internet and nothing else. There is no bare laptop mode: the runtime measures its guest into PCR16 before it boots, and that needs an NSM. Host integration tests use a fake NSM; QEMU provides an emulated one. @@ -301,10 +300,11 @@ flowchart TB Sched["Task scheduler"] --> Guest Held["Held connections"] --> Guest Dev["PTP clock + NSM"] -.-> Guest - Guest --> FS["Encrypted filesystem"] + Guest --> FS["ZFS pool on dm-crypt"] Guest --> Notify["Notifier"] end - FS -->|opaque slabs, signed roots| S3[("S3")] + FS -->|ciphertext blocks over vsock| Disk[("Parent's disk")] + FS -->|signed anchors| S3[("S3, Object Lock")] Dev -.->|recipient attestation| KMS["KMS + SSM"] Held <-->|attested SSE + POST| Svc["Public services"] Notify -->|data-only wake| Push["AWS push → FCM"] @@ -322,12 +322,12 @@ AWS Nitro attestation, KMS, S3's authenticated responses, Object Lock and the ap The image names where the guest lives, an object key in the roots bucket covered by PCR0, and the runtime measures what it actually fetched: ```text -fetch component → SHA-256 → extend PCR16 → lock → re-read the register → KMS key release → mount +fetch component → SHA-256 → extend PCR16 → lock → re-read the register → KMS key release → unlock and import the pool ``` The runtime refuses to start if PCR16 was already extended or locked, or if the register after extending or locking does not hold what a client computes from the component (`nitro-attest --measure` and the `guest-release` build compute it). A guest-only update changes PCR16 and the client pins without an image rebuild; changes to runtime code or measured configuration change PCR0. -With `--master-key-source kms`, the runtime places an enclave recipient key in an NSM attestation request. It calls `GenerateDataKey` at genesis and `Decrypt` on resume, and accepts only `CiphertextForRecipient`, never a plaintext fallback. SSM holds the KMS ciphertext, and the roots bucket holds a pointer that is checked by digest. **The KMS key policy is what enforces release:** it must constrain both `kms:GenerateDataKey` and `kms:Decrypt` on both `kms:RecipientAttestation:PCR0` and `kms:RecipientAttestation:PCR16`. **And nobody may be able to change it.** Before every use the runtime reads the key, its policy and its grants from KMS and refuses unless the key is single-region, generated by KMS and customer managed, has no grants, and its policy allows only reading the key, deleting it, and those two actions pinned to this enclave's PCR0 and PCR16 — no `PutKeyPolicy`, no `CreateGrant`, no wildcards ([`keys/policy.rs`](runtime/src/keys/policy.rs)). Such a policy is set once (it needs `BypassPolicyLockoutSafetyCheck`) and is then permanent: what remains to an attacker is deleting the key, which stops the enclave and reveals nothing. `--master-key-source static` is for development and does not protect the key from its operator. +The runtime places an enclave recipient key in an NSM attestation request. It calls `GenerateDataKey` at genesis and `Decrypt` on resume, and accepts only `CiphertextForRecipient`, never a plaintext fallback. SSM holds the KMS ciphertext, and the roots bucket holds a pointer that is checked by digest. **The KMS key policy is what enforces release:** it must constrain both `kms:GenerateDataKey` and `kms:Decrypt` on both `kms:RecipientAttestation:PCR0` and `kms:RecipientAttestation:PCR16`. **And nobody may be able to change it.** Before every use the runtime reads the key, its policy and its grants from KMS and refuses unless the key is single-region, generated by KMS and customer managed, has no grants, and its policy allows only reading the key, deleting it, and those two actions pinned to this enclave's PCR0 and PCR16 — no `PutKeyPolicy`, no `CreateGrant`, no wildcards ([`keys/policy.rs`](runtime/src/keys/policy.rs)). Such a policy is set once (it needs `BypassPolicyLockoutSafetyCheck`) and is then permanent: what remains to an attacker is deleting the key, which stops the enclave and reveals nothing. The emulator's `testing` build takes a static key instead, which does not protect it from its operator. The store's own identity is checked at boot too: genesis, resume, upgrade or refusal, decided by an attested origin receipt ([details](docs/STORAGE.md#state-identity-at-boot)). @@ -358,41 +358,49 @@ The approval binds method, path and query, not the body or each message of a str ## Storage -`wasi:filesystem` is backed by a copy-on-write block store. Blocks are encrypted with AES-256-GCM, checksummed with BLAKE3 and bound to their position in the tree, which hangs off Ed25519-signed, hash-chained root records. S3 holds opaque slabs rather than one object per path, so the storage operator sees neither filenames nor plaintext. Each tenant sees its own directory as `/`, and the runtime, not the guest, keeps `..` and symlinks from leaving it. SQLite runs unmodified in rollback-journal mode ([`guest-sqlite`](examples/guest-sqlite/)). +Everything the enclave keeps is on one ZFS pool, on a disk the parent serves over vsock and cannot read: dm-crypt sits underneath, under a key derived from the master secret. Each tenant gets its own dataset as `/`, through `wasmtime-wasi`'s filesystem, and nothing above it is reachable. The runtime's own records (passkeys, tasks, streams, devices) live beside the tenants in `/runtime`. SQLite runs unmodified in rollback-journal mode ([`guest-sqlite`](examples/guest-sqlite/)). -The store is single-writer, has no garbage collection yet, and commits through S3, so batch writes. The block tree, commit protocol, state origins, SQLite notes and embedding `s3fs-core` on its own are in [docs/STORAGE.md](docs/STORAGE.md); WASI/POSIX differences are in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md). +Rollback is what ZFS alone cannot stop, since the parent holds the disk. So before anything is acknowledged — a response's end, a registration, a task's outcome — the pool is synced and its state anchored. A random value written into the pool is signed, chained and published to an Object-Locked bucket. Boot imports the pool as of the newest anchor and refuses one that does not hold its value. The anchor, the boot checks, the parent side and the limits are in [docs/STORAGE.md](docs/STORAGE.md); WASI differences are in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md). ## Configuration -`cargo run -p enclave-runtime --bin enclave-runtime -- --help` is the definitive reference. Configuration that Nix bakes into the image is measured: changing a bucket, relying party, allowed origin, push application or logging destination changes PCR0, and changing a guest's settings changes PCR16. The `S3FS_` prefix is left over from the filesystem's original name. +Every setting is baked into the image and measured into PCR0, so changing one means building a new image, just as changing a constant would. A production binary therefore reads only what differs between deployments, and `--help` lists exactly these: -| Area | Main flags / environment | +| Setting | What it names | |---|---| -| Trusted devices | `--clock-source ptp\|host\|auto`, `--ptp-device`; `--random-source nsm\|host\|auto`, `--nsm-device`; `--self-check` | -| Component | `--guest-object` / `S3FS_GUEST_OBJECT`, or `--guest-path` / `S3FS_GUEST_PATH` | -| Key source | Required `--master-key-source kms\|static`; `--kms-key-id`, `--master-key-parameter`, `--environment` | -| Listener and TLS | `--http-listen`, `--tls`, repeatable `--tls-domain`, `--acme-contact`, `--acme-directory` | -| WebAuthn | `--webauthn-rp-id`, `--webauthn-origin`, repeatable `--webauthn-allowed-origin` (including `android:apk-key-hash:…`) | -| Guest environment | The settings the guest file carries, over `--no-inherit-env` and repeatable `--guest-env NAME=VALUE` or `--guest-env NAME` | -| Background tasks | `--background-tasks true`, concurrency, total-record and per-tenant limits. One attempt may run 600 s, which is not a setting | -| Notifications | `S3FS_PUSH_APP_ID`, an AWS End User Messaging Push application | -| Logging | `S3FS_GUEST_LOG_GROUP`, `S3FS_GUEST_LOG_STREAM`; `RUST_LOG` (`guest=warn` filters guest output) | -| Store | `--bucket`, `--roots-bucket`, `--bucket-prefix`, `--fs-id`, `--region`, `--endpoint`, `--min-root-seq` | +| `ENCLAVE_ROOTS_BUCKET`, `ENCLAVE_BUCKET_PREFIX` | The Object-Locked bucket, and a key prefix so deployments can share one | +| `ENCLAVE_ID` | The filesystem id, which salts every key | +| `ENCLAVE_KMS_KEY_ID`, `ENCLAVE_MASTER_KEY_PARAMETER` | The KMS key that releases the master secret, and the SSM parameter holding its ciphertext | +| `ENCLAVE_ROOT_RETENTION_SECS`, `ENCLAVE_MIN_ROOT_SEQ` | How long anchors are locked (ten years by default), and a freshness floor from outside the store | +| `ENCLAVE_TLS_DOMAINS` | The certificate's domains. Let's Encrypt issues it over TLS-ALPN-01 | +| `ENCLAVE_WEBAUTHN_RP_ID`, `ENCLAVE_WEBAUTHN_ALLOWED_ORIGINS` | The relying party, whose origin is `https://`, and further origins such as `android:apk-key-hash:…` | +| `ENCLAVE_PUSH_APP_ID` | An AWS End User Messaging Push application, or empty for none | +| `ENCLAVE_GUEST_LOG_GROUP` | A CloudWatch group for guest output, written to its `guest` stream, or empty for the console only | +| `AWS_REGION` | The region | + +Everything else is fixed: +- KMS releases the master key. +- Time comes from PTP and randomness from the NSM. +- The network is gvproxy. +- The server listens on :443 with an ACME certificate. +- The guest is `guest/guest.wasm` in the roots bucket. +- Background tasks run when the guest exports `run-task`. +- A guest's environment is exactly the settings its file carries ([`guest-env.py`](deploy/qemu-nitro/guest-env.py) writes them), measured into PCR16 with its code. Nothing from the runtime's environment reaches it. +- `RUST_LOG` sets the runtime's own logging; `guest=warn` filters guest output. + +The emulator's alternatives exist only in the `testing` build, which the emulator image uses: a static master key, unsigned receipts, the host clock, MinIO, Pebble, and stubs for push and logs. A production binary has none of these settings and none of that code. | Runtime limit | Default | |---|---:| | Unspent interaction token lifetime | 60 s | | Maximum interaction lifetime | 300 s | | Request progress timeout | 30 s | -| Cached tenants / idle timeout | 64 / 900 s | -| Requests before instance recycling | 10,000 | -| Background concurrency / attempt timeout | 1 / 30 s | +| Tenant locks held / idle timeout | 64 / 900 s | +| Background concurrency / attempt timeout | 1 / 600 s | | Background records, global / per tenant | 1,024 / 64 | | Held connections per tenant / message size | 8 / 256 KiB | | Enrolled devices per tenant | 8 | -By default the guest inherits the runtime's environment minus `AWS_*` and `S3FS_*`. Anywhere that environment is not deliberately curated, use `--no-inherit-env` and name each value. - ## Build, test, and deploy ```bash @@ -416,7 +424,7 @@ cargo build --release -p enclave-runtime --features testing --bin passkey-client **Deploying.** Nix builds the measured enclave image; Packer ([`deploy/ami`](deploy/ami/)) builds the parent AMI; OpenTofu ([`deploy/tofu`](deploy/tofu/)) defines buckets, retention, parent resources and logs. ```bash -nix build .#eif # result/s3fs.eif, result/pcr.json +nix build .#eif # result/enclave.eif, result/pcr.json nix build .#guest-release --out-link guest-release # guest.wasm, guest-pcr16.json nix build .#eif --rebuild # rebuild and compare ``` @@ -439,13 +447,14 @@ Reproducibility, and which inputs remain pinned AWS prebuilt artifacts, are cove | Upgrades | A key policy nobody can edit names one PCR0 and one PCR16, so a new runtime or guest cannot open a store made by the old one. A hand-over from the running enclave to an approved successor is not built yet | | Delivery | Tasks, held connections, notifications and logs each have their own retry and durability contract; none gives exactly-once side effects | | Authorization scope | A passkey approves one interaction on one route, not every payload byte or stream message. Per-message approval needs a host import that is not built yet | -| Single active writer | No distributed writer coordination, scheduler fencing or multi-enclave failover | -| Garbage collection | Not implemented; history and orphaned slabs accumulate | -| Freshness | A cold mount needs an external root-sequence floor (`--min-root-seq`) to rule out rollback | +| Single active writer | One enclave imports the pool; no failover | +| Parent disk on Nitro | tofu creates the pool's EBS volume and the AMI serves it with stock nbdkit (`deploy/ami/units/nbdkit.service`); not yet proven on hardware. The emulator serves a disk image with `deploy/qemu-nitro/nbd-stub.py` | +| Anchors | Serial across tenants; each is a pool sync and an S3 round trip | +| Freshness | A cold boot needs an external anchor floor (`--min-root-seq`) to rule out a store hiding its newest anchors | | Resource isolation | Cache and queue limits exist; per-guest memory quotas and public-service admission control do not | | Upgrades | A new PCR16 is not a migration. The guest owns its schema and queued-payload evolution | -The [roadmap](docs/ROADMAP.md) records milestone history and the planned garbage collector. +The [roadmap](docs/ROADMAP.md) records milestone history. ## Repository guide @@ -455,7 +464,8 @@ The [roadmap](docs/ROADMAP.md) records milestone history and the planned garbage | [`wit`](wit/) | Capability contracts: [`notify`](wit/notify/notify.wit), [`stream`](wit/stream/stream.wit), [`tasks`](wit/tasks/tasks.wit) | | [`crates/nitro-nsm`](crates/nitro-nsm/) | NSM device access: entropy, PCRs, attestation requests | | [`crates/nitro-attestation`](crates/nitro-attestation/) | Attestation verification and the `nitro-attest` client | -| [`crates/s3fs-core`](crates/s3fs-core/) | The storage engine ([docs/STORAGE.md](docs/STORAGE.md)) | +| [`runtime/src/zfs.rs`](runtime/src/zfs.rs), [`nix/kernel-zfs.nix`](nix/kernel-zfs.nix) | Storage: the pool, its disk, the anchor ([docs/STORAGE.md](docs/STORAGE.md)) | +| [`runtime/src/store`](runtime/src/store/) | The roots bucket's S3 backend, the key hierarchy and signing | | [`examples`](examples/) | HTTP (tasks and notify), bidirectional gRPC, and SQLite guests | | [`deploy/qemu-nitro`](deploy/qemu-nitro/) | Development enclave, emulator self-test, end-to-end harness | | [`deploy/nix`](deploy/nix/), [`nix`](nix/) | Measured configuration and reproducible EIF assembly | diff --git a/crates/s3fs-core/Cargo.toml b/crates/s3fs-core/Cargo.toml deleted file mode 100644 index 1bddf53..0000000 --- a/crates/s3fs-core/Cargo.toml +++ /dev/null @@ -1,47 +0,0 @@ -[package] -name = "s3fs-core" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Async S3-backed filesystem engine. Backend trait + in-memory + (planned) AWS S3 backends, MPU state machine, buffer pool, inode cache, public Fs handle." - -[features] -default = [] -# When enabled, builds the AWS S3 backend (pulls in aws-sdk-s3 + rustls). -aws = ["dep:aws-config", "dep:aws-sdk-s3", "dep:aws-smithy-runtime-api", "dep:aws-smithy-types"] - -[dependencies] -tokio = { workspace = true, features = ["sync", "rt", "macros", "io-util", "time"] } -tokio-util = { version = "0.7", features = ["rt"] } -bytes = { workspace = true } -tracing = { workspace = true } -thiserror = { workspace = true } -async-trait = { workspace = true } -futures = { workspace = true } -parking_lot = { workspace = true } -fastrand = { workspace = true } -siphasher = { workspace = true } -hashlink = { workspace = true } -bitvec = { workspace = true } -percent-encoding = { workspace = true } -blake3 = { workspace = true } -zeroize = { workspace = true } -aws-lc-rs = { workspace = true } - -aws-config = { workspace = true, optional = true, features = ["behavior-version-latest", "rt-tokio"] } -aws-sdk-s3 = { workspace = true, optional = true, features = ["rt-tokio", "rustls", "behavior-version-latest"] } -aws-smithy-runtime-api = { workspace = true, optional = true } -aws-smithy-types = { workspace = true, optional = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "macros", "io-util", "time"] } -anyhow = { workspace = true } -testcontainers = "0.27" -testcontainers-modules = { version = "0.15", features = ["minio"] } -criterion = { version = "0.5", features = ["async_tokio"] } - -[[bench]] -name = "fs_hot_paths" -harness = false diff --git a/crates/s3fs-core/benches/fs_hot_paths.rs b/crates/s3fs-core/benches/fs_hot_paths.rs deleted file mode 100644 index 9f0e65d..0000000 --- a/crates/s3fs-core/benches/fs_hot_paths.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Hot-path benchmarks against the in-memory backend. -//! -//! Numbers here are relative only — there is no network, so what they measure -//! is the engine's own cost: encryption, hashing, the copy-on-write rebuild, -//! and the commit protocol. That is exactly the part worth watching, because -//! against real S3 the round trips dominate everything else. -//! -//! ```bash -//! cargo bench -p s3fs-core -//! ``` - -use std::sync::Arc; - -use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion, Throughput}; -use tokio::runtime::Runtime; - -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::backend::Backend; -use s3fs_core::{Config, Fs, MasterSecret, OpenFlags}; - -fn runtime() -> Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime") -} - -async fn fresh_fs(record_size: usize) -> Arc { - let config = Config::builder() - .record_size(record_size) - .root_retention(None) - .build(); - Fs::create( - Arc::new(MemoryBackend::new()) as Arc, - Arc::new(MemoryBackend::new()) as Arc, - &MasterSecret::from_bytes([1u8; 32]), - [0u8; 16], - Arc::new(config), - ) - .await - .expect("creating the filesystem") -} - -/// Write and commit a whole file: the full path through encryption, the -/// indirect-tree rebuild, slab packing, and a signed root record. -fn write_and_commit(c: &mut Criterion) { - let rt = runtime(); - let mut group = c.benchmark_group("write_and_commit"); - - for size in [1024usize, 64 * 1024, 1024 * 1024] { - group.throughput(Throughput::Bytes(size as u64)); - group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| { - let data = vec![0xabu8; size]; - b.to_async(&rt).iter_batched( - || data.clone(), - |data| async move { - let fs = fresh_fs(128 * 1024).await; - let h = fs.open("/bench", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &data).await.unwrap(); - fs.close(&h).await.unwrap(); - }, - BatchSize::SmallInput, - ); - }); - } - group.finish(); -} - -/// Commit cost as a function of how many records the transaction group dirties. -/// -/// The interesting shape: slab packing means the number of PUTs stays flat, so -/// this should scale with bytes rather than with block count. -fn commit_by_dirty_blocks(c: &mut Criterion) { - let rt = runtime(); - let mut group = c.benchmark_group("commit_by_dirty_blocks"); - let record = 4096usize; - - for blocks in [1usize, 16, 256] { - group.throughput(Throughput::Elements(blocks as u64)); - group.bench_with_input( - BenchmarkId::from_parameter(blocks), - &blocks, - |b, &blocks| { - b.to_async(&rt).iter(|| async move { - let fs = fresh_fs(record).await; - let h = fs.open("/bench", OpenFlags::create_new()).await.unwrap(); - // One byte per record, so every record is dirtied but the - // volume of data stays small. - for i in 0..blocks { - fs.pwrite(&h, (i * record) as u64, b"x").await.unwrap(); - } - fs.close(&h).await.unwrap(); - }); - }, - ); - } - group.finish(); -} - -/// Random reads over a committed file, warm cache. Measures verification cost: -/// a BLAKE3 pass plus an AEAD open per block, plus the indirect-tree walk. -fn random_reads(c: &mut Criterion) { - let rt = runtime(); - let file_size = 8 * 1024 * 1024usize; - - let fs = rt.block_on(async { - let fs = fresh_fs(128 * 1024).await; - let h = fs.open("/bench", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &vec![0x5au8; file_size]).await.unwrap(); - fs.close(&h).await.unwrap(); - fs - }); - - let mut group = c.benchmark_group("random_read"); - for len in [4096usize, 64 * 1024] { - group.throughput(Throughput::Bytes(len as u64)); - group.bench_with_input(BenchmarkId::from_parameter(len), &len, |b, &len| { - let fs = fs.clone(); - b.to_async(&rt).iter(|| { - let fs = fs.clone(); - async move { - let h = fs.open("/bench", OpenFlags::read_only()).await.unwrap(); - let offset = fastrand::u64(0..(file_size - len) as u64); - fs.pread(&h, offset, len).await.unwrap(); - fs.close(&h).await.unwrap(); - } - }); - }); - } - group.finish(); -} - -/// Directory lookup as the directory grows. The separator index means this -/// should stay roughly flat rather than degrading with entry count. -fn directory_lookup(c: &mut Criterion) { - let rt = runtime(); - let mut group = c.benchmark_group("directory_lookup"); - - for entries in [10usize, 1_000, 10_000] { - let fs = rt.block_on(async { - let fs = fresh_fs(4096).await; - let root = fs.root(); - for i in 0..entries { - let h = fs - .open(&format!("/entry-{i:06}"), OpenFlags::create_new()) - .await - .unwrap(); - fs.close(&h).await.unwrap(); - } - let _ = root; - fs - }); - - group.bench_with_input( - BenchmarkId::from_parameter(entries), - &entries, - |b, &entries| { - let fs = fs.clone(); - b.to_async(&rt).iter(|| { - let fs = fs.clone(); - async move { - let name = format!("entry-{:06}", fastrand::usize(0..entries)); - fs.lookup_at(&fs.root(), &name).await.unwrap(); - } - }); - }, - ); - } - group.finish(); -} - -criterion_group!( - benches, - write_and_commit, - commit_by_dirty_blocks, - random_reads, - directory_lookup -); -criterion_main!(benches); diff --git a/crates/s3fs-core/src/config.rs b/crates/s3fs-core/src/config.rs deleted file mode 100644 index 8850217..0000000 --- a/crates/s3fs-core/src/config.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Engine configuration. -//! -//! The tuning knobs that used to live here — multipart part schedules, buffer -//! pool budgets, upload parallelism — belonged to the old path-to-key engine -//! and are gone with it. What remains is the mount's own shape plus -//! [`StoreConfig`], which owns everything about the block store. - -use std::sync::Arc; - -use crate::store::StoreConfig; - -/// Default cap on symlink hops during one path resolution. -pub const DEFAULT_MAX_SYMLINK_DEPTH: u32 = 40; - -/// Engine configuration. -#[derive(Debug, Clone)] -pub struct Config { - /// Path the guest sees as its preopen root. - pub mount_path: String, - /// How many symlinks one resolution may follow before giving up. Bounds - /// the work a malicious or merely circular symlink graph can cause. - pub max_symlink_depth: u32, - /// Block store settings. - pub store: StoreConfig, -} - -impl Default for Config { - fn default() -> Self { - Config { - mount_path: "/".to_string(), - max_symlink_depth: DEFAULT_MAX_SYMLINK_DEPTH, - store: StoreConfig::default(), - } - } -} - -impl Config { - pub fn builder() -> ConfigBuilder { - ConfigBuilder { - config: Config::default(), - } - } - - pub fn record_size(&self) -> usize { - self.store.record_size - } - - pub fn shared(self) -> Arc { - Arc::new(self) - } -} - -/// Fluent builder for [`Config`]. -#[derive(Debug)] -pub struct ConfigBuilder { - config: Config, -} - -impl ConfigBuilder { - pub fn mount_path(mut self, path: impl Into) -> Self { - self.config.mount_path = path.into(); - self - } - - /// Key prefix inside both buckets. - pub fn bucket_prefix(mut self, prefix: impl Into) -> Self { - let mut prefix = prefix.into(); - // A prefix is a path component, not a substring: without the - // separator, prefix "a" would also match keys under "ab/". - if !prefix.is_empty() && !prefix.ends_with('/') { - prefix.push('/'); - } - self.config.store.prefix = prefix; - self - } - - pub fn record_size(mut self, bytes: usize) -> Self { - self.config.store.record_size = bytes; - self - } - - pub fn max_symlink_depth(mut self, depth: u32) -> Self { - self.config.max_symlink_depth = depth; - self - } - - pub fn block_cache_bytes(mut self, bytes: u64) -> Self { - self.config.store.block_cache_bytes = bytes; - self - } - - pub fn root_retention(mut self, retention: Option) -> Self { - self.config.store.root_retention = retention; - self - } - - pub fn build(self) -> Config { - self.config - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_are_usable() { - let c = Config::default(); - assert_eq!(c.mount_path, "/"); - assert_eq!(c.max_symlink_depth, DEFAULT_MAX_SYMLINK_DEPTH); - c.store.validate().unwrap(); - } - - /// A prefix names a directory-like scope. Without the separator, prefix - /// "tenant" would also cover keys belonging to "tenant-other". - #[test] - fn bucket_prefix_gains_a_trailing_separator() { - let c = Config::builder().bucket_prefix("tenant").build(); - assert_eq!(c.store.prefix, "tenant/"); - - let c = Config::builder().bucket_prefix("tenant/").build(); - assert_eq!(c.store.prefix, "tenant/"); - - let c = Config::builder().bucket_prefix("").build(); - assert_eq!(c.store.prefix, "", "an empty prefix stays empty"); - } - - #[test] - fn builder_sets_every_field() { - let c = Config::builder() - .mount_path("/mnt") - .record_size(8192) - .max_symlink_depth(8) - .block_cache_bytes(1024) - .root_retention(None) - .build(); - - assert_eq!(c.mount_path, "/mnt"); - assert_eq!(c.record_size(), 8192); - assert_eq!(c.max_symlink_depth, 8); - assert_eq!(c.store.block_cache_bytes, 1024); - assert_eq!(c.store.root_retention, None); - c.store.validate().unwrap(); - } -} diff --git a/crates/s3fs-core/src/crypto/aead.rs b/crates/s3fs-core/src/crypto/aead.rs deleted file mode 100644 index 594edd3..0000000 --- a/crates/s3fs-core/src/crypto/aead.rs +++ /dev/null @@ -1,280 +0,0 @@ -//! Authenticated encryption for stored blocks (AES-256-GCM). -//! -//! Two invariants carry the security of this module. Both are enforced by the -//! types rather than by convention, because getting either wrong is silent. -//! -//! **Nonces never repeat.** A nonce is `txg ‖ block_seq`, and a txg number is -//! consumed before a commit is attempted and never reused — not even when the -//! commit fails. Reusing one under a fixed key would leak the XOR of two -//! plaintexts and, worse for GCM, hand out the authentication subkey. -//! -//! **Every block is bound to its position.** The AAD covers the object id, -//! tree level, block index, and birth txg. A block that is genuine, correctly -//! encrypted, and correctly checksummed still fails to open if it is served -//! from a different offset, a different file, or a different level of the -//! indirect tree. Without this, a bucket operator could shuffle blocks between -//! positions and every individual integrity check would still pass. - -use aws_lc_rs::aead::{Aad as LcAad, LessSafeKey, Nonce, NONCE_LEN}; - -use crate::errors::{FsError, FsResult}; - -/// AEAD tag length for AES-256-GCM, in bytes. Sealed output is -/// `plaintext.len() + TAG_LEN`. -pub const TAG_LEN: usize = 16; - -/// Length of the encoded [`BlockAad`]. -const AAD_LEN: usize = 8 + 1 + 8 + 8; - -/// A 96-bit AES-GCM nonce, constructed only from a `(txg, block_seq)` pair. -/// -/// There is deliberately no way to build one from arbitrary bytes: every -/// nonce in the system is a function of a monotonically increasing txg, which -/// is what makes non-repetition an argument about txg allocation rather than -/// about every call site. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct BlockNonce([u8; NONCE_LEN]); - -impl BlockNonce { - /// `txg` (8 bytes, big-endian) followed by `block_seq` (4 bytes, - /// big-endian): the index of this block within its transaction group. - /// - /// Uniqueness argument: `txg` is strictly increasing across commits and - /// never reused, and `block_seq` is unique within a commit. So the pair is - /// unique over the lifetime of the key. - pub fn new(txg: u64, block_seq: u32) -> Self { - let mut n = [0u8; NONCE_LEN]; - n[..8].copy_from_slice(&txg.to_be_bytes()); - n[8..].copy_from_slice(&block_seq.to_be_bytes()); - BlockNonce(n) - } - - pub const fn as_bytes(&self) -> &[u8; NONCE_LEN] { - &self.0 - } - - pub const fn from_bytes(bytes: [u8; NONCE_LEN]) -> Self { - BlockNonce(bytes) - } - - fn to_lc(self) -> Nonce { - // Safe by the uniqueness argument above. - Nonce::assume_unique_for_key(self.0) - } -} - -/// Additional authenticated data binding a block to its position in the tree. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BlockAad { - /// Object the block belongs to. - pub objid: u64, - /// 0 for data blocks, ≥1 for indirect blocks. - pub level: u8, - /// Index of this block within its level. - pub block_index: u64, - /// The txg that wrote it. - pub birth_txg: u64, -} - -impl BlockAad { - fn encode(&self) -> [u8; AAD_LEN] { - let mut b = [0u8; AAD_LEN]; - b[0..8].copy_from_slice(&self.objid.to_be_bytes()); - b[8] = self.level; - b[9..17].copy_from_slice(&self.block_index.to_be_bytes()); - b[17..25].copy_from_slice(&self.birth_txg.to_be_bytes()); - b - } -} - -/// Encrypt `plaintext`, returning `ciphertext ‖ tag`. -pub fn seal( - key: &LessSafeKey, - nonce: BlockNonce, - aad: BlockAad, - plaintext: &[u8], -) -> FsResult> { - let mut buf = Vec::with_capacity(plaintext.len() + TAG_LEN); - buf.extend_from_slice(plaintext); - key.seal_in_place_append_tag(nonce.to_lc(), LcAad::from(aad.encode()), &mut buf) - .map_err(|_| FsError::Integrity("block seal failed"))?; - Ok(buf) -} - -/// Decrypt `ciphertext ‖ tag` in place, returning the plaintext. -/// -/// Fails with [`FsError::Integrity`] if the tag does not verify — which covers -/// a corrupted block, a forged block, and a genuine block replayed at the -/// wrong position. -pub fn open( - key: &LessSafeKey, - nonce: BlockNonce, - aad: BlockAad, - ciphertext: &[u8], -) -> FsResult> { - if ciphertext.len() < TAG_LEN { - return Err(FsError::Integrity("block shorter than AEAD tag")); - } - let mut buf = ciphertext.to_vec(); - let len = key - .open_in_place(nonce.to_lc(), LcAad::from(aad.encode()), &mut buf) - .map_err(|_| FsError::Integrity("block authentication failed"))? - .len(); - buf.truncate(len); - Ok(buf) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::crypto::keys::{KeyMaterial, MasterSecret}; - - fn material(master: u8) -> KeyMaterial { - KeyMaterial::derive(&MasterSecret::from_bytes([master; 32]), [0u8; 16]).unwrap() - } - - fn key() -> KeyMaterial { - material(7) - } - - fn aad() -> BlockAad { - BlockAad { - objid: 42, - level: 0, - block_index: 3, - birth_txg: 100, - } - } - - #[test] - fn round_trip() { - let k = key(); - let pt = b"the quick brown fox".to_vec(); - let ct = seal(k.block_key(), BlockNonce::new(100, 0), aad(), &pt).unwrap(); - assert_eq!(ct.len(), pt.len() + TAG_LEN); - assert_ne!( - &ct[..pt.len()], - &pt[..], - "plaintext must not appear in output" - ); - - let out = open(k.block_key(), BlockNonce::new(100, 0), aad(), &ct).unwrap(); - assert_eq!(out, pt); - } - - #[test] - fn empty_plaintext_round_trips() { - let k = key(); - let ct = seal(k.block_key(), BlockNonce::new(1, 0), aad(), b"").unwrap(); - assert_eq!(ct.len(), TAG_LEN); - assert_eq!( - open(k.block_key(), BlockNonce::new(1, 0), aad(), &ct).unwrap(), - b"" - ); - } - - #[test] - fn tampered_ciphertext_fails() { - let k = key(); - let mut ct = seal(k.block_key(), BlockNonce::new(100, 0), aad(), b"payload").unwrap(); - ct[0] ^= 0x01; - assert!(matches!( - open(k.block_key(), BlockNonce::new(100, 0), aad(), &ct), - Err(FsError::Integrity(_)) - )); - } - - #[test] - fn tampered_tag_fails() { - let k = key(); - let mut ct = seal(k.block_key(), BlockNonce::new(100, 0), aad(), b"payload").unwrap(); - let last = ct.len() - 1; - ct[last] ^= 0x01; - assert!(open(k.block_key(), BlockNonce::new(100, 0), aad(), &ct).is_err()); - } - - #[test] - fn truncated_input_fails_cleanly() { - let k = key(); - assert!(matches!( - open( - k.block_key(), - BlockNonce::new(1, 0), - aad(), - &[0u8; TAG_LEN - 1] - ), - Err(FsError::Integrity("block shorter than AEAD tag")) - )); - } - - /// The position-binding property: a block that is genuine in every other - /// respect must not open at a different location in the tree. - #[test] - fn block_cannot_be_relocated() { - let k = key(); - let ct = seal(k.block_key(), BlockNonce::new(100, 0), aad(), b"payload").unwrap(); - - for wrong in [ - BlockAad { objid: 43, ..aad() }, - BlockAad { level: 1, ..aad() }, - BlockAad { - block_index: 4, - ..aad() - }, - BlockAad { - birth_txg: 101, - ..aad() - }, - ] { - assert!( - open(k.block_key(), BlockNonce::new(100, 0), wrong, &ct).is_err(), - "block opened under the wrong AAD: {wrong:?}" - ); - } - } - - #[test] - fn wrong_nonce_fails() { - let k = key(); - let ct = seal(k.block_key(), BlockNonce::new(100, 0), aad(), b"payload").unwrap(); - assert!(open(k.block_key(), BlockNonce::new(100, 1), aad(), &ct).is_err()); - assert!(open(k.block_key(), BlockNonce::new(101, 0), aad(), &ct).is_err()); - } - - #[test] - fn wrong_key_fails() { - let ct = seal( - key().block_key(), - BlockNonce::new(100, 0), - aad(), - b"payload", - ) - .unwrap(); - let other = material(8); - assert!(open(other.block_key(), BlockNonce::new(100, 0), aad(), &ct).is_err()); - } - - #[test] - fn nonce_layout_is_txg_then_seq() { - let n = BlockNonce::new(0x0102_0304_0506_0708, 0x090a_0b0c); - assert_eq!( - n.as_bytes(), - &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c] - ); - } - - /// The uniqueness argument reduced to a property test: distinct - /// `(txg, seq)` pairs must produce distinct nonces. - #[test] - fn distinct_txg_seq_pairs_give_distinct_nonces() { - let mut seen = std::collections::HashSet::new(); - for txg in 0..64u64 { - for seq in 0..64u32 { - assert!( - seen.insert(*BlockNonce::new(txg, seq).as_bytes()), - "nonce collision at txg={txg} seq={seq}" - ); - } - } - } -} diff --git a/crates/s3fs-core/src/crypto/mod.rs b/crates/s3fs-core/src/crypto/mod.rs deleted file mode 100644 index 3181955..0000000 --- a/crates/s3fs-core/src/crypto/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Cryptographic primitives for the block store. -//! -//! Three jobs, three submodules: -//! -//! - [`hash`] — BLAKE3 digests. Every block pointer carries the hash of the -//! block it points at, which is what makes the object tree a Merkle tree. -//! - [`aead`] — AES-256-GCM over stored blocks, with nonces derived from the -//! txg and additional data binding each block to its position in the tree. -//! - [`sign`] — Ed25519 over root records, so a root cannot be minted or -//! re-pointed by anyone holding only bucket write access. -//! -//! [`keys`] ties them together: one master secret, HKDF-separated per purpose. -//! -//! Primitives come from `aws-lc-rs` (the same stack KMS speaks, FIPS-capable) -//! except BLAKE3, which has no aws-lc equivalent and is chosen for the block -//! checksum because it is the hot path — every block read verifies one. - -pub mod aead; -pub mod hash; -pub mod keys; -pub mod sign; - -pub use aead::{BlockAad, BlockNonce, TAG_LEN}; -pub use hash::{Hash256, HASH_LEN}; -pub use keys::{KeyMaterial, MasterSecret, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN}; diff --git a/crates/s3fs-core/src/fs.rs b/crates/s3fs-core/src/fs.rs deleted file mode 100644 index 7d577d9..0000000 --- a/crates/s3fs-core/src/fs.rs +++ /dev/null @@ -1,1550 +0,0 @@ -//! `Fs` — POSIX semantics over the block store. -//! -//! Every mutation is one transaction group, and therefore one root record: a -//! `mkdir` either happened or did not, and there is no window in which half of -//! it is visible. That is a direct consequence of the store's commit protocol -//! rather than anything this layer arranges, and it is why several operations -//! that the previous engine could only approximate are now exact: -//! -//! - **`rename` is atomic.** It moves a directory entry inside one commit. -//! There is no copy-then-delete window, no background queue, and a directory -//! rename costs the same as a file rename instead of `O(entries)` copies. -//! - **`stat` cannot go stale.** Attributes come from the dnode on every call. -//! The previous engine cached them behind a TTL that was never checked. -//! - **Identity is real.** `(objid, gen)` is stable across mounts. -//! -//! ## Buffering -//! -//! Writes accumulate in the handle as whole records and reach the store on -//! `sync`, `set_size`, or `close` — the transaction-group model the plan -//! specifies. Reads consult the handle's dirty records first, so a writer sees -//! its own unsynced writes; another handle does not, and on a crash they are -//! lost. What cannot happen is a torn or partially-applied state. - -use std::collections::BTreeMap; -use std::num::NonZeroU64; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::SystemTime; - -use bytes::Bytes; -use parking_lot::Mutex as SyncMutex; -use std::collections::{HashMap, HashSet}; -use tokio::sync::Mutex; - -use crate::backend::Backend; -use crate::config::Config; -use crate::crypto::{KeyMaterial, MasterSecret}; -use crate::errors::{FsError, FsResult}; -use crate::inode::{now_nanos, to_nanos, Attrs, DirEntry, Inode, InodeKind}; -use crate::path::{validate_segment, validate_symlink_target}; -use crate::store::blockstore::BlockStore; -use crate::store::dir::{DirTxn, Dirent}; -use crate::store::dnode::{blocks_for_size, Dnode, DnodeKind, INLINE_CAP, ROOT_OBJID}; -use crate::store::indirect::{block_logical_len, commit_object, read_data_block}; -use crate::store::objset::ObjectSet; -use crate::store::Store; - -/// Default permissions for objects this layer creates. -const DEFAULT_FILE_MODE: u32 = 0o100644; -const DEFAULT_DIR_MODE: u32 = 0o040755; -const DEFAULT_SYMLINK_MODE: u32 = 0o120777; - -/// Stable handle ID for an open file. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct HandleId(pub NonZeroU64); - -impl HandleId { - pub fn get(self) -> u64 { - self.0.get() - } -} - -/// Open-time flags. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct OpenFlags { - pub read: bool, - pub write: bool, - pub append: bool, - pub truncate: bool, - pub create: bool, - pub exclusive: bool, -} - -impl OpenFlags { - pub fn read_only() -> Self { - Self { - read: true, - ..Self::default() - } - } - pub fn write_only() -> Self { - Self { - write: true, - ..Self::default() - } - } - pub fn read_write() -> Self { - Self { - read: true, - write: true, - ..Self::default() - } - } - pub fn create_new() -> Self { - Self { - read: true, - write: true, - create: true, - exclusive: true, - ..Self::default() - } - } -} - -/// Unsynced state of one open file. -#[derive(Debug)] -struct HandleState { - /// Size as this handle sees it, including unsynced growth. - size: u64, - /// Whole records awaiting commit, keyed by record index. - dirty: BTreeMap>, - /// Timestamps or size changed without any data changing. - meta_dirty: bool, - atime_nanos: u64, - mtime_nanos: u64, -} - -/// An open file. -#[derive(Debug)] -pub struct FileHandle { - pub id: HandleId, - pub inode: Arc, - pub flags: OpenFlags, - state: Mutex, -} - -impl FileHandle { - /// Size including writes not yet committed. - pub async fn size(&self) -> u64 { - self.state.lock().await.size - } -} - -/// Objects that have lost their last name but are still open. -/// -/// POSIX keeps an unlinked file alive until the last descriptor closes. The -/// dnode stays allocated with `nlink == 0`, unreachable by name — path -/// resolution goes through directory entries, and there are none left — so the -/// only way to it is a handle that already existed. When the last one closes, -/// it is freed. -/// -/// A crash in that window leaks the dnode: allocated, nameless, unreachable. -/// That is garbage, not corruption, and it is the same trade a real filesystem -/// makes with its orphan inode list. -#[derive(Debug, Default)] -struct OpenTracker { - /// Open handle count per object id. - counts: HashMap, - /// Objects awaiting their last close. - orphans: HashSet, -} - -impl OpenTracker { - fn opened(&mut self, objid: u64) { - *self.counts.entry(objid).or_insert(0) += 1; - } - - /// Record a close. Returns `true` if this was the last handle on an - /// orphaned object, meaning the caller must now free it. - fn closed(&mut self, objid: u64) -> bool { - let remaining = match self.counts.get_mut(&objid) { - Some(n) => { - *n = n.saturating_sub(1); - *n - } - None => 0, - }; - if remaining == 0 { - self.counts.remove(&objid); - return self.orphans.remove(&objid); - } - false - } - - fn is_open(&self, objid: u64) -> bool { - self.counts.contains_key(&objid) - } - - fn mark_orphan(&mut self, objid: u64) { - self.orphans.insert(objid); - } -} - -/// The filesystem. -#[derive(Debug)] -pub struct Fs { - store: Arc, - pub config: Arc, - handles: SyncMutex>>, - open: SyncMutex, - next_handle: AtomicU64, -} - -impl Fs { - /// Mount an existing filesystem. Fails with [`FsError::NoFilesystem`] if - /// the store is empty — see [`Store::open_existing`] for why that is not a - /// cue to create one. - /// - /// `roots` is the Object Lock bucket holding the anchor chain; `data` - /// holds the slabs. They may be the same bucket, but splitting them is - /// what lets dead copy-on-write blocks stay reclaimable while the anchor - /// stays immutable. - /// - /// `fs_uuid` is the HKDF salt. It is not a secret, but it must be supplied - /// rather than discovered: the keys that verify a root record are derived - /// from it, so reading it out of the store would mean trusting the store - /// to tell us which key to check its own signature with. - pub async fn mount( - data: Arc, - roots: Arc, - master: &MasterSecret, - fs_uuid: [u8; 16], - config: Arc, - min_root_seq: Option, - ) -> FsResult> { - let keys = Arc::new(KeyMaterial::derive(master, fs_uuid)?); - let store = Store::open_existing( - data, - roots, - keys, - Arc::new(config.store.clone()), - min_root_seq, - ) - .await?; - Ok(Fs::from_store(Arc::new(store), config)) - } - - /// Create a filesystem in an empty store. - /// - /// Separate from [`Fs::mount`] on purpose: creating one is an assertion - /// that no filesystem should already exist here, and the caller is the only - /// party that can make it. - pub async fn create( - data: Arc, - roots: Arc, - master: &MasterSecret, - fs_uuid: [u8; 16], - config: Arc, - ) -> FsResult> { - let keys = Arc::new(KeyMaterial::derive(master, fs_uuid)?); - let store = Store::create(data, roots, keys, Arc::new(config.store.clone())).await?; - Ok(Fs::from_store(Arc::new(store), config)) - } - - pub fn from_store(store: Arc, config: Arc) -> Arc { - Arc::new(Fs { - store, - config, - handles: SyncMutex::new(HashMap::new()), - open: SyncMutex::new(OpenTracker::default()), - next_handle: AtomicU64::new(1), - }) - } - - pub fn store(&self) -> &Arc { - &self.store - } - - /// The mount's root directory. - pub fn root(&self) -> Arc { - Inode::new(ROOT_OBJID, 0) - } - - pub fn get_handle(&self, id: HandleId) -> Option> { - self.handles.lock().get(&id.get()).cloned() - } - - fn blocks(&self) -> Arc { - self.store.blocks().clone() - } - - async fn objset(&self) -> FsResult { - self.store.objset().await - } - - async fn dnode(&self, objid: u64) -> FsResult { - self.objset() - .await? - .get_allocated(&self.blocks(), objid) - .await - } - - // -- resolution --------------------------------------------------------- - - /// Resolve `path` relative to `base`, following symlinks except optionally - /// the final component. - pub async fn lookup_at(&self, base: &Arc, path: &str) -> FsResult> { - self.resolve(base, path, true).await - } - - pub async fn lookup_at_no_follow(&self, base: &Arc, path: &str) -> FsResult> { - self.resolve(base, path, false).await - } - - async fn resolve( - &self, - base: &Arc, - path: &str, - follow_final: bool, - ) -> FsResult> { - self.resolve_within(ROOT_OBJID, base, path, follow_final) - .await - } - - /// Resolve without leaving the subtree rooted at `scope`. - /// - /// This is the capability boundary between two tenants of one filesystem. - /// See [`resolve_in`] for what it forecloses and why those are the only - /// three routes out. - async fn resolve_within( - &self, - scope: u64, - base: &Arc, - path: &str, - follow_final: bool, - ) -> FsResult> { - let objset = self.objset().await?; - let blocks = self.blocks(); - let objid = resolve_in( - &objset, - &blocks, - scope, - base.objid(), - path, - follow_final, - self.config.max_symlink_depth, - ) - .await?; - let d = objset.get_allocated(&blocks, objid).await?; - Ok(Inode::new(objid, d.gen)) - } - - /// [`Fs::lookup_at`], confined to `scope`. - pub async fn lookup_within( - &self, - scope: &Arc, - base: &Arc, - path: &str, - ) -> FsResult> { - self.resolve_within(scope.objid(), base, path, true).await - } - - /// [`Fs::lookup_at_no_follow`], confined to `scope`. - pub async fn lookup_within_no_follow( - &self, - scope: &Arc, - base: &Arc, - path: &str, - ) -> FsResult> { - self.resolve_within(scope.objid(), base, path, false).await - } - - /// [`Fs::parent_of`], confined to `scope`. - /// - /// The scope is its own parent, so a descriptor at the top of a tenant's - /// subtree cannot be walked upwards out of it — `..` is not the only way - /// to ask for a parent, and this is the other one. - pub async fn parent_within( - &self, - scope: &Arc, - ino: &Arc, - ) -> FsResult> { - if ino.objid() == scope.objid() { - return Ok(scope.clone()); - } - self.parent_of(ino).await - } - - // -- metadata ----------------------------------------------------------- - - pub async fn stat(&self, ino: &Arc) -> FsResult { - Attrs::from_dnode(&self.dnode(ino.objid()).await?) - } - - /// The directory containing `ino`. The root is its own parent. - pub async fn parent_of(&self, ino: &Arc) -> FsResult> { - let d = self.dnode(ino.objid()).await?; - Ok(Inode::new(d.parent_objid, 0)) - } - - /// Resolve a path to the object itself, for callers that need identity - /// rather than attributes. - pub async fn resolve_for_hash( - &self, - base: &Arc, - path: &str, - follow: bool, - ) -> FsResult> { - self.resolve(base, path, follow).await - } - - pub async fn stat_at(&self, base: &Arc, path: &str, follow: bool) -> FsResult { - let ino = self.resolve(base, path, follow).await?; - self.stat(&ino).await - } - - /// Directory contents, in name order. - pub async fn read_dir(&self, dir: &Arc) -> FsResult> { - let blocks = self.blocks(); - let d = self.dnode(dir.objid()).await?; - if d.kind != DnodeKind::Dir { - return Err(FsError::NotDirectory); - } - let mut txn = DirTxn::load(&blocks, &d).await?; - txn.list() - .await? - .into_iter() - .map(|e| { - Ok(DirEntry { - name: e.name, - kind: InodeKind::from_dnode(e.kind)?, - objid: e.objid, - }) - }) - .collect() - } - - // -- namespace mutation -------------------------------------------------- - - pub async fn mkdir(&self, base: &Arc, name: &str) -> FsResult> { - validate_segment(name)?; - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - let parent = objset.get_allocated(&blocks, base.objid()).await?; - require_dir(&parent)?; - let mut dir = DirTxn::load(&blocks, &parent).await?; - if dir.lookup(name).await?.is_some() { - return Err(FsError::AlreadyExists); - } - - let now = now_nanos(); - let objid = txn.reserve_objid()?; - let mut child = Dnode::new(objid, DnodeKind::Dir, parent.record_shift, now); - child.mode = DEFAULT_DIR_MODE; - child.parent_objid = parent.objid; - - dir.insert(Dirent { - name: name.to_string(), - objid, - kind: DnodeKind::Dir, - }) - .await?; - let mut new_parent = dir.finish(txn.writer()).await?; - touch(&mut new_parent, now); - - txn.stage(new_parent); - txn.stage(child); - txn.commit().await?; - Ok(Inode::new(objid, 0)) - } - - pub async fn symlink_at( - &self, - base: &Arc, - name: &str, - target: &str, - ) -> FsResult> { - validate_segment(name)?; - validate_symlink_target(target)?; - - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - let parent = objset.get_allocated(&blocks, base.objid()).await?; - require_dir(&parent)?; - let mut dir = DirTxn::load(&blocks, &parent).await?; - if dir.lookup(name).await?.is_some() { - return Err(FsError::AlreadyExists); - } - - let now = now_nanos(); - let objid = txn.reserve_objid()?; - let mut link = Dnode::new(objid, DnodeKind::Symlink, parent.record_shift, now); - link.mode = DEFAULT_SYMLINK_MODE; - link.parent_objid = parent.objid; - link.size = target.len() as u64; - - let bytes = target.as_bytes(); - if bytes.len() <= INLINE_CAP { - // Almost every target fits here, so readlink costs no block read. - link.inline = bytes.to_vec(); - } else { - let record = parent.record_size(); - let dirty: BTreeMap> = bytes - .chunks(record) - .enumerate() - .map(|(i, c)| (i as u64, c.to_vec())) - .collect(); - link = commit_object(&blocks, txn.writer(), &link, dirty, bytes.len() as u64).await?; - } - - dir.insert(Dirent { - name: name.to_string(), - objid, - kind: DnodeKind::Symlink, - }) - .await?; - let mut new_parent = dir.finish(txn.writer()).await?; - touch(&mut new_parent, now); - - txn.stage(new_parent); - txn.stage(link); - txn.commit().await?; - Ok(Inode::new(objid, 0)) - } - - pub async fn readlink_at(&self, base: &Arc, name: &str) -> FsResult { - let ino = self.resolve(base, name, false).await?; - let d = self.dnode(ino.objid()).await?; - if d.kind != DnodeKind::Symlink { - return Err(FsError::Invalid("not a symlink")); - } - read_symlink(&self.blocks(), &d).await - } - - /// Add a second name for an existing object. - /// - /// Only files and symlinks: a hard link to a directory would make the - /// namespace a graph rather than a tree, and the parent link in the dnode - /// has room for exactly one answer. - /// - /// This was permanently unsupported under the previous engine, which had - /// no way to express shared identity across two S3 keys. Here a directory - /// entry is already just an object id, so a link is one more entry and an - /// increment of `nlink`. - pub async fn link_at( - &self, - old_base: &Arc, - old_path: &str, - new_base: &Arc, - new_name: &str, - follow: bool, - ) -> FsResult<()> { - self.link_within(&self.root(), old_base, old_path, new_base, new_name, follow) - .await - } - - /// [`Fs::link_at`], confined to `scope`. - /// - /// The *source* is the one that matters here: a hard link is a second name - /// for an existing inode, so linking to something outside the scope would - /// pull it inside permanently — an escape that survives the request that - /// made it. - #[allow(clippy::too_many_arguments)] - pub async fn link_within( - &self, - scope: &Arc, - old_base: &Arc, - old_path: &str, - new_base: &Arc, - new_name: &str, - follow: bool, - ) -> FsResult<()> { - validate_segment(new_name)?; - let target = self - .resolve_within(scope.objid(), old_base, old_path, follow) - .await?; - - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - let mut victim = objset.get_allocated(&blocks, target.objid()).await?; - if victim.kind == DnodeKind::Dir { - return Err(FsError::NotPermitted); - } - - let parent = objset.get_allocated(&blocks, new_base.objid()).await?; - require_dir(&parent)?; - let mut dir = DirTxn::load(&blocks, &parent).await?; - if dir.lookup(new_name).await?.is_some() { - return Err(FsError::AlreadyExists); - } - - let now = now_nanos(); - dir.insert(Dirent { - name: new_name.to_string(), - objid: victim.objid, - kind: victim.kind, - }) - .await?; - victim.nlink = victim.nlink.saturating_add(1); - victim.ctime_nanos = now; - - let mut new_parent = dir.finish(txn.writer()).await?; - touch(&mut new_parent, now); - txn.stage(new_parent); - txn.stage(victim); - txn.commit().await.map(|_| ()) - } - - pub async fn unlink(&self, base: &Arc, name: &str) -> FsResult<()> { - self.remove_entry(base, name, false).await - } - - pub async fn rmdir(&self, base: &Arc, name: &str) -> FsResult<()> { - self.remove_entry(base, name, true).await - } - - async fn remove_entry(&self, base: &Arc, name: &str, want_dir: bool) -> FsResult<()> { - validate_segment(name)?; - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - let parent = objset.get_allocated(&blocks, base.objid()).await?; - require_dir(&parent)?; - let mut dir = DirTxn::load(&blocks, &parent).await?; - let entry = dir.lookup(name).await?.ok_or(FsError::NotFound)?; - - let is_dir = entry.kind == DnodeKind::Dir; - match (want_dir, is_dir) { - (true, false) => return Err(FsError::NotDirectory), - (false, true) => return Err(FsError::IsDirectory), - _ => {} - } - - let mut child = objset.get_allocated(&blocks, entry.objid).await?; - if is_dir { - let child_dir = DirTxn::load(&blocks, &child).await?; - if !child_dir.is_empty() { - return Err(FsError::NotEmpty); - } - } - - dir.remove(name).await?; - let now = now_nanos(); - let mut new_parent = dir.finish(txn.writer()).await?; - touch(&mut new_parent, now); - - child.nlink = child.nlink.saturating_sub(1); - child.ctime_nanos = now; - if child.nlink == 0 { - // Last name gone. Keep the object alive if a handle still holds - // it — POSIX promises an unlinked file stays readable until the - // last descriptor closes. With no entries left, the only way to - // reach it is a handle that already exists, so no new opener can - // find it in the meantime. - let mut open = self.open.lock(); - if open.is_open(child.objid) { - open.mark_orphan(child.objid); - } else { - // Its blocks become unreachable copy-on-write garbage, which - // lifecycle policy on the data bucket reclaims. Nothing is - // deleted here. - child = Dnode::free(child.objid, child.record_shift); - } - } - - txn.stage(new_parent); - txn.stage(child); - txn.commit().await.map(|_| ()).map(|_| ()) - } - - /// Move an entry. Atomic: one directory-entry move inside one commit. - pub async fn rename( - &self, - from_base: &Arc, - from_name: &str, - to_base: &Arc, - to_name: &str, - ) -> FsResult<()> { - validate_segment(from_name)?; - validate_segment(to_name)?; - let (src_id, dst_id) = (from_base.objid(), to_base.objid()); - if src_id == dst_id && from_name == to_name { - return Ok(()); - } - - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - let now = now_nanos(); - - let src_dnode = objset.get_allocated(&blocks, src_id).await?; - require_dir(&src_dnode)?; - let mut src_dir = DirTxn::load(&blocks, &src_dnode).await?; - let entry = src_dir.lookup(from_name).await?.ok_or(FsError::NotFound)?; - - // A directory may not move into itself: the subtree would keep its - // parent link into a directory that now lives inside it, and nothing - // from the root could reach any of it again. Walk up from the - // destination; the root is its own parent, which ends the walk. - if entry.kind == DnodeKind::Dir { - let mut cursor = dst_id; - loop { - if cursor == entry.objid { - return Err(FsError::Invalid("cannot rename a directory into itself")); - } - let parent = objset.get_allocated(&blocks, cursor).await?.parent_objid; - if parent == cursor { - break; - } - cursor = parent; - } - } - - // Same directory: one transaction over one structure, or the two would - // each rebuild from the same base and the second would erase the first. - let mut dst_dir = if src_id == dst_id { - None - } else { - let d = objset.get_allocated(&blocks, dst_id).await?; - require_dir(&d)?; - Some(DirTxn::load(&blocks, &d).await?) - }; - - let existing = match &mut dst_dir { - Some(d) => d.lookup(to_name).await?, - None => src_dir.lookup(to_name).await?, - }; - if let Some(victim) = existing { - if victim.objid == entry.objid { - return Ok(()); // already linked here - } - let mut victim_dnode = objset.get_allocated(&blocks, victim.objid).await?; - match (entry.kind == DnodeKind::Dir, victim.kind == DnodeKind::Dir) { - (true, false) => return Err(FsError::NotDirectory), - (false, true) => return Err(FsError::IsDirectory), - (true, true) => { - if !DirTxn::load(&blocks, &victim_dnode).await?.is_empty() { - return Err(FsError::NotEmpty); - } - } - (false, false) => {} - } - match &mut dst_dir { - Some(d) => d.remove(to_name).await?, - None => src_dir.remove(to_name).await?, - }; - victim_dnode.nlink = victim_dnode.nlink.saturating_sub(1); - victim_dnode.ctime_nanos = now; - if victim_dnode.nlink == 0 { - // Same contract as unlink: a replaced file that is still open - // survives until its last handle closes. - let mut open = self.open.lock(); - if open.is_open(victim_dnode.objid) { - open.mark_orphan(victim_dnode.objid); - } else { - victim_dnode = Dnode::free(victim_dnode.objid, victim_dnode.record_shift); - } - } - txn.stage(victim_dnode); - } - - src_dir.remove(from_name).await?; - let moved = Dirent { - name: to_name.to_string(), - objid: entry.objid, - kind: entry.kind, - }; - match &mut dst_dir { - Some(d) => d.insert(moved).await?, - None => src_dir.insert(moved).await?, - } - - // A directory carries its parent in its dnode, so moving one across - // directories has to update that link or `..` would still point home. - if entry.kind == DnodeKind::Dir && src_id != dst_id { - let mut child = objset.get_allocated(&blocks, entry.objid).await?; - child.parent_objid = dst_id; - child.ctime_nanos = now; - txn.stage(child); - } - - let mut new_src = src_dir.finish(txn.writer()).await?; - touch(&mut new_src, now); - txn.stage(new_src); - if let Some(d) = dst_dir { - let mut new_dst = d.finish(txn.writer()).await?; - touch(&mut new_dst, now); - txn.stage(new_dst); - } - txn.commit().await.map(|_| ()) - } - - // -- files --------------------------------------------------------------- - - pub async fn open_at( - &self, - base: &Arc, - path: &str, - flags: OpenFlags, - ) -> FsResult> { - self.open_within(&self.root(), base, path, flags).await - } - - /// [`Fs::open_at`], confined to `scope`. - /// - /// Both halves have to be confined, not just the lookup: a path that does - /// not resolve may still be *created*, and creating - /// `../someone-else/file` would be an escape that writes rather than - /// reads. - pub async fn open_within( - &self, - scope: &Arc, - base: &Arc, - path: &str, - flags: OpenFlags, - ) -> FsResult> { - if !flags.read && !flags.write { - return Err(FsError::Invalid("open with no read or write")); - } - - let existing = match self.resolve_within(scope.objid(), base, path, true).await { - Ok(ino) => Some(ino), - Err(FsError::NotFound) if flags.create => None, - Err(e) => return Err(e), - }; - - let inode = match existing { - Some(ino) => { - if flags.exclusive { - return Err(FsError::AlreadyExists); - } - ino - } - None => self.create_file(scope.objid(), base, path).await?, - }; - - let d = self.dnode(inode.objid()).await?; - if d.kind == DnodeKind::Dir && flags.write { - return Err(FsError::IsDirectory); - } - - let handle = self.register_handle(inode, flags, &d); - if flags.truncate && flags.write { - self.set_size(&handle, 0).await?; - } - Ok(handle) - } - - pub async fn open(&self, path: &str, flags: OpenFlags) -> FsResult> { - self.open_at(&self.root(), path, flags).await - } - - /// Create an empty regular file at `path`, which must not exist. - async fn create_file(&self, scope: u64, base: &Arc, path: &str) -> FsResult> { - let (parent_path, name) = split_last(path)?; - let parent = if parent_path.is_empty() { - base.clone() - } else { - self.resolve_within(scope, base, parent_path, true).await? - }; - validate_segment(name)?; - - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - let parent_dnode = objset.get_allocated(&blocks, parent.objid()).await?; - require_dir(&parent_dnode)?; - let mut dir = DirTxn::load(&blocks, &parent_dnode).await?; - // Another commit may have created it between our lookup and this - // transaction. The whole create is inside the transaction, so losing - // that race is visible rather than silently overwriting. - if dir.lookup(name).await?.is_some() { - return Err(FsError::AlreadyExists); - } - - let now = now_nanos(); - let objid = txn.reserve_objid()?; - let mut file = Dnode::new(objid, DnodeKind::File, parent_dnode.record_shift, now); - file.mode = DEFAULT_FILE_MODE; - file.parent_objid = parent_dnode.objid; - - dir.insert(Dirent { - name: name.to_string(), - objid, - kind: DnodeKind::File, - }) - .await?; - let mut new_parent = dir.finish(txn.writer()).await?; - touch(&mut new_parent, now); - - txn.stage(new_parent); - txn.stage(file); - txn.commit().await?; - Ok(Inode::new(objid, 0)) - } - - fn register_handle(&self, inode: Arc, flags: OpenFlags, d: &Dnode) -> Arc { - let raw = self.next_handle.fetch_add(1, Ordering::Relaxed); - let id = HandleId(NonZeroU64::new(raw).expect("handle ids start at 1")); - let handle = Arc::new(FileHandle { - id, - inode, - flags, - state: Mutex::new(HandleState { - size: d.size, - dirty: BTreeMap::new(), - meta_dirty: false, - atime_nanos: d.atime_nanos, - mtime_nanos: d.mtime_nanos, - }), - }); - self.handles.lock().insert(raw, handle.clone()); - self.open.lock().opened(handle.inode.objid()); - handle - } - - pub async fn close(&self, handle: &Arc) -> FsResult<()> { - let result = if handle.flags.write { - self.sync(handle).await - } else { - Ok(()) - }; - self.abandon(handle).await?; - result - } - - /// Close without flushing: whatever the handle has not synced is thrown - /// away, exactly as on a crash. - /// - /// For a handle whose owner went away without closing it. Its buffer is a - /// write nobody is waiting on, and flushing it whenever the release gets - /// round to it would land it on top of anything committed in the - /// meantime — an update undone by one that came before it. - pub async fn abandon(&self, handle: &Arc) -> FsResult<()> { - let objid = handle.inode.objid(); - self.handles.lock().remove(&handle.id.get()); - - // If this was the last handle on an object whose last name is already - // gone, now is when it actually goes away. - let reap = self.open.lock().closed(objid); - if reap { - self.free_orphan(objid).await?; - } - Ok(()) - } - - /// Free an object that lost its last name while it was open. - async fn free_orphan(&self, objid: u64) -> FsResult<()> { - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - let d = match objset.get_allocated(&blocks, objid).await { - Ok(d) => d, - // Already gone: another path freed it, or the mount was - // reformatted underneath us. Nothing to do. - Err(FsError::NotFound) => return Ok(()), - Err(e) => return Err(e), - }; - if d.nlink > 0 { - // It was linked again between the unlink and this close, so it is - // reachable by name once more and must not be freed. - return Ok(()); - } - txn.stage(Dnode::free(objid, d.record_shift)); - txn.commit().await.map(|_| ()) - } - - pub async fn pread(&self, handle: &FileHandle, offset: u64, len: usize) -> FsResult { - if !handle.flags.read { - return Err(FsError::BadDescriptor); - } - let st = handle.state.lock().await; - if offset >= st.size || len == 0 { - return Ok(Bytes::new()); - } - let end = offset.saturating_add(len as u64).min(st.size); - - // A view of the object at the size this handle sees, so records past - // the committed end read as holes rather than as past-EOF. - let mut view = self.dnode(handle.inode.objid()).await?; - view.size = st.size; - let record = view.record_size() as u64; - let blocks = self.blocks(); - - let mut out = Vec::with_capacity((end - offset) as usize); - let mut pos = offset; - while pos < end { - let index = pos / record; - let within = (pos % record) as usize; - let take = ((record - within as u64).min(end - pos)) as usize; - - let block = match st.dirty.get(&index) { - Some(buf) => Bytes::from(buf.clone()), - None => read_data_block(&blocks, &view, index).await?, - }; - let slice = block.get(within..).unwrap_or(&[]); - let n = take.min(slice.len()); - out.extend_from_slice(&slice[..n]); - // A short record means a hole at the tail of the object; the rest - // of the requested span is zeros. - out.resize(out.len() + (take - n), 0); - pos += take as u64; - } - Ok(Bytes::from(out)) - } - - pub async fn pwrite(&self, handle: &FileHandle, offset: u64, data: &[u8]) -> FsResult { - if !handle.flags.write { - return Err(FsError::BadDescriptor); - } - if data.is_empty() { - return Ok(0); - } - let mut st = handle.state.lock().await; - let offset = if handle.flags.append { st.size } else { offset }; - - let mut view = self.dnode(handle.inode.objid()).await?; - view.size = st.size; - let record = view.record_size(); - let blocks = self.blocks(); - - let mut written = 0usize; - while written < data.len() { - let pos = offset + written as u64; - let index = pos / record as u64; - let within = (pos % record as u64) as usize; - let take = (record - within).min(data.len() - written); - - let mut buf = match st.dirty.remove(&index) { - Some(buf) => buf, - None => read_data_block(&blocks, &view, index).await?.to_vec(), - }; - // Records are held at full width while dirty and trimmed to the - // object's real tail length at commit, so partial writes into a - // hole do not have to reason about the boundary. - buf.resize(record, 0); - buf[within..within + take].copy_from_slice(&data[written..written + take]); - st.dirty.insert(index, buf); - written += take; - } - - st.size = st.size.max(offset + data.len() as u64); - st.mtime_nanos = now_nanos(); - Ok(written) - } - - pub async fn sync(&self, handle: &FileHandle) -> FsResult<()> { - let mut st = handle.state.lock().await; - if st.dirty.is_empty() && !st.meta_dirty { - return Ok(()); - } - self.flush(handle.inode.objid(), &mut st).await - } - - async fn flush(&self, objid: u64, st: &mut HandleState) -> FsResult<()> { - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - // Re-read rather than trusting the copy taken at open: another commit - // may have advanced this object since. - let base = objset.get_allocated(&blocks, objid).await?; - let record = base.record_size(); - - // A copy, not a take: if the commit fails the buffers must still be - // here for the retry, or a sync that timed out once loses the file. - let mut dirty = st.dirty.clone(); - let nblocks = blocks_for_size(st.size, record); - dirty.retain(|index, _| *index < nblocks); - for (index, buf) in dirty.iter_mut() { - buf.resize(block_logical_len(st.size, record, *index), 0); - } - - // Shrinking has to rewrite the block the new end lands in, not merely - // record a smaller size. Blocks past the end are dropped by the - // copy-on-write rebuild, but the boundary block keeps whatever it held - // — so a later grow would read those bytes back out from under the - // truncation instead of the zeros POSIX promises. - if st.size < base.size && nblocks > 0 { - let index = nblocks - 1; - if let std::collections::btree_map::Entry::Vacant(slot) = dirty.entry(index) { - let want = block_logical_len(st.size, record, index); - let mut buf = read_data_block(&blocks, &base, index).await?.to_vec(); - if buf.len() > want { - buf.truncate(want); - slot.insert(buf); - } - } - } - - let mut updated = commit_object(&blocks, txn.writer(), &base, dirty, st.size).await?; - updated.mtime_nanos = st.mtime_nanos; - updated.atime_nanos = st.atime_nanos; - updated.ctime_nanos = now_nanos(); - - txn.stage(updated); - txn.commit().await?; - st.dirty.clear(); - st.meta_dirty = false; - Ok(()) - } - - pub async fn set_size(&self, handle: &FileHandle, new_size: u64) -> FsResult<()> { - if !handle.flags.write { - return Err(FsError::BadDescriptor); - } - let mut st = handle.state.lock().await; - st.size = new_size; - st.meta_dirty = true; - st.mtime_nanos = now_nanos(); - self.flush(handle.inode.objid(), &mut st).await - } - - pub async fn set_times( - &self, - handle: &FileHandle, - atime: Option, - mtime: Option, - ) -> FsResult<()> { - // The same gate `pwrite` and `set_size` apply, and it was missing here. - // A timestamp is metadata, but setting one is still a *commit*: `flush` - // publishes a new signed root record under Object Lock retention. A - // handle opened read-only that can advance the anchor chain is not - // read-only in any sense worth the name. - // - // POSIX would settle this by ownership rather than by the descriptor's - // open mode — `futimens` on an `O_RDONLY` fd is legal for the owner. - // There is no owner here: [`Attrs`] carries `mode` but no uid, so - // "are you allowed?" has no answer other than what the handle was - // opened for. `set_times_at` stays ungated for the same reason, having - // no handle to ask. - if !handle.flags.write { - return Err(FsError::BadDescriptor); - } - let mut st = handle.state.lock().await; - if let Some(t) = atime { - st.atime_nanos = to_nanos(t); - } - if let Some(t) = mtime { - st.mtime_nanos = to_nanos(t); - } - st.meta_dirty = true; - self.flush(handle.inode.objid(), &mut st).await - } - - pub async fn set_times_at( - &self, - base: &Arc, - path: &str, - atime: Option, - mtime: Option, - follow: bool, - ) -> FsResult<()> { - let ino = self.resolve(base, path, follow).await?; - let mut txn = self.store.begin().await?; - let blocks = txn.blocks().clone(); - let objset = txn.objset().clone(); - - let mut d = objset.get_allocated(&blocks, ino.objid()).await?; - if let Some(t) = atime { - d.atime_nanos = to_nanos(t); - } - if let Some(t) = mtime { - d.mtime_nanos = to_nanos(t); - } - d.ctime_nanos = now_nanos(); - txn.stage(d); - txn.commit().await.map(|_| ()) - } -} - -// -- snapshots --------------------------------------------------------------- - -impl Fs { - /// The newest `limit` committed states, most recent first. - /// - /// Every root record is a snapshot. Copy-on-write means the blocks a past - /// root names were never overwritten, so taking one costs nothing and - /// keeping one costs only whatever storage its blocks already occupy. - pub async fn snapshots(&self, limit: usize) -> FsResult> { - Ok(self - .store - .list_snapshots(limit) - .await? - .into_iter() - .map(|r| SnapshotInfo { - seq: r.seq, - txg: r.txg, - timestamp: crate::inode::from_nanos(r.timestamp_nanos), - merkle_root: *r.merkle_root(), - }) - .collect()) - } - - /// Open a past state for reading. - pub async fn open_snapshot(&self, seq: u64) -> FsResult { - let snapshot = self.store.snapshot(seq).await?; - Ok(SnapshotFs { - objset: snapshot.objset().clone(), - blocks: self.blocks(), - config: self.config.clone(), - info: SnapshotInfo { - seq: snapshot.root.seq, - txg: snapshot.root.txg, - timestamp: crate::inode::from_nanos(snapshot.root.timestamp_nanos), - merkle_root: *snapshot.root.merkle_root(), - }, - }) - } -} - -/// Identifying details of one committed state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SnapshotInfo { - /// Root sequence number. Also its key in the roots bucket. - pub seq: u64, - pub txg: u64, - pub timestamp: SystemTime, - /// The hash covering the entire filesystem at this point. - pub merkle_root: crate::crypto::Hash256, -} - -/// A past state of the filesystem, opened read-only. -/// -/// Reads verify exactly as the live mount does — same checksums, same -/// position-binding — because a snapshot is not a copy of anything. It is the -/// same blocks, reached through an older root. -#[derive(Debug)] -pub struct SnapshotFs { - objset: ObjectSet, - blocks: Arc, - config: Arc, - info: SnapshotInfo, -} - -impl SnapshotFs { - pub fn info(&self) -> SnapshotInfo { - self.info - } - - pub fn root(&self) -> Arc { - Inode::new(ROOT_OBJID, 0) - } - - pub async fn lookup(&self, base: &Arc, path: &str) -> FsResult> { - let objid = resolve_in( - &self.objset, - &self.blocks, - // A snapshot is read-only and whole: there is no tenant to confine - // to, and confining to one would hide the rest of a snapshot from - // the operator reading it. - ROOT_OBJID, - base.objid(), - path, - true, - self.config.max_symlink_depth, - ) - .await?; - let d = self.objset.get_allocated(&self.blocks, objid).await?; - Ok(Inode::new(objid, d.gen)) - } - - pub async fn stat(&self, ino: &Arc) -> FsResult { - Attrs::from_dnode(&self.objset.get_allocated(&self.blocks, ino.objid()).await?) - } - - pub async fn read_dir(&self, dir: &Arc) -> FsResult> { - let d = self.objset.get_allocated(&self.blocks, dir.objid()).await?; - require_dir(&d)?; - DirTxn::load(&self.blocks, &d) - .await? - .list() - .await? - .into_iter() - .map(|e| { - Ok(DirEntry { - name: e.name, - kind: InodeKind::from_dnode(e.kind)?, - objid: e.objid, - }) - }) - .collect() - } - - /// Read a byte range of a file as it was at this snapshot. - pub async fn read(&self, ino: &Arc, offset: u64, len: usize) -> FsResult { - let d = self.objset.get_allocated(&self.blocks, ino.objid()).await?; - if d.kind == DnodeKind::Dir { - return Err(FsError::IsDirectory); - } - if offset >= d.size || len == 0 { - return Ok(Bytes::new()); - } - let end = offset.saturating_add(len as u64).min(d.size); - let record = d.record_size() as u64; - - let mut out = Vec::with_capacity((end - offset) as usize); - let mut pos = offset; - while pos < end { - let index = pos / record; - let within = (pos % record) as usize; - let take = ((record - within as u64).min(end - pos)) as usize; - let block = read_data_block(&self.blocks, &d, index).await?; - let slice = block.get(within..).unwrap_or(&[]); - let n = take.min(slice.len()); - out.extend_from_slice(&slice[..n]); - out.resize(out.len() + (take - n), 0); - pos += take as u64; - } - Ok(Bytes::from(out)) - } -} - -// -- helpers ---------------------------------------------------------------- - -fn require_dir(d: &Dnode) -> FsResult<()> { - if d.kind == DnodeKind::Dir { - Ok(()) - } else { - Err(FsError::NotDirectory) - } -} - -fn touch(d: &mut Dnode, now: u64) { - d.mtime_nanos = now; - d.ctime_nanos = now; -} - -/// Split a path into its parent portion and final component. -fn split_last(path: &str) -> FsResult<(&str, &str)> { - let trimmed = path.trim_end_matches('/'); - if trimmed.is_empty() { - return Err(FsError::Invalid("empty path")); - } - Ok(match trimmed.rsplit_once('/') { - Some((parent, name)) => (parent, name), - None => ("", trimmed), - }) -} - -async fn read_symlink(blocks: &BlockStore, d: &Dnode) -> FsResult { - let bytes = if !d.inline.is_empty() { - d.inline.clone() - } else { - let mut out = Vec::with_capacity(d.size as usize); - for i in 0..d.block_count() { - out.extend_from_slice(&read_data_block(blocks, d, i).await?); - } - out - }; - String::from_utf8(bytes).map_err(|_| FsError::IllegalByteSequence) -} - -/// Walk `path` from `start`, following symlinks. -/// -/// Recursion is bounded by `depth`, which is decremented on every symlink hop -/// rather than every component, so a chain of links cannot outlast it. -/// Walk `path`, never leaving the subtree rooted at `scope`. -/// -/// `scope` is what makes this a capability rather than a convention. Every way -/// a path can name something above where it started is redirected to `scope` -/// instead of to the filesystem root: -/// -/// - an **absolute path** starts at `scope`, so `/etc/passwd` means -/// `/etc/passwd` and cannot mean anything else; -/// - **`..`** at `scope` stays at `scope`, so no number of them walks out; -/// - an **absolute symlink target** resolves from `scope` too, so a guest -/// cannot manufacture an escape by writing one. -/// -/// Those three are the whole attack surface, and they hold inductively: a walk -/// begins at `scope` or below, and none of the three can take it higher. -/// -/// Pass [`ROOT_OBJID`] for an unscoped filesystem, which is what a guest with -/// the whole store as its preopen gets. -async fn resolve_in( - objset: &ObjectSet, - blocks: &BlockStore, - scope: u64, - start: u64, - path: &str, - follow_final: bool, - depth: u32, -) -> FsResult { - if depth == 0 { - return Err(FsError::Loop); - } - let mut current = if path.starts_with('/') { scope } else { start }; - - let components: Vec<&str> = path - .split('/') - .filter(|c| !c.is_empty() && *c != ".") - .collect(); - - for (i, comp) in components.iter().enumerate() { - let last = i + 1 == components.len(); - - if *comp == ".." { - // Clamped at the scope, not at the mount. Without this a tenant - // holding `/tenants/a` would reach `/tenants` — and from there - // every other tenant — with two components of ordinary path. - if current == scope { - continue; - } - let d = objset.get_allocated(blocks, current).await?; - // The root is its own parent, so `..` stops at the mount rather - // than escaping it. - current = d.parent_objid; - continue; - } - validate_segment(comp)?; - - let dir_dnode = objset.get_allocated(blocks, current).await?; - require_dir(&dir_dnode)?; - let mut dir = DirTxn::load(blocks, &dir_dnode).await?; - let entry = dir.lookup(comp).await?.ok_or(FsError::NotFound)?; - current = entry.objid; - - if entry.kind == DnodeKind::Symlink && (!last || follow_final) { - let link = objset.get_allocated(blocks, current).await?; - let target = read_symlink(blocks, &link).await?; - let from = if target.starts_with('/') { - scope - } else { - dir_dnode.objid - }; - current = Box::pin(resolve_in( - objset, - blocks, - scope, - from, - &target, - true, - depth - 1, - )) - .await?; - } - } - Ok(current) -} - -#[cfg(test)] -mod tests; - -#[cfg(test)] -mod scope_tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::config::Config; - use crate::crypto::MasterSecret; - - /// Two tenants under `/tenants`, plus a secret at the root that neither - /// should ever reach. - async fn tenanted() -> (Arc, Arc, Arc) { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([3u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); - - let root = fs.root(); - fs.mkdir(&root, "runtime-only").await.unwrap(); - let tenants = fs.mkdir(&root, "tenants").await.unwrap(); - let alice = fs.mkdir(&tenants, "alice").await.unwrap(); - let bob = fs.mkdir(&tenants, "bob").await.unwrap(); - fs.mkdir(&bob, "bobs-private").await.unwrap(); - (fs, alice, bob) - } - - /// Inside her own subtree Alice works normally. - #[tokio::test] - async fn a_scope_does_not_restrict_what_is_inside_it() { - let (fs, alice, _) = tenanted().await; - fs.mkdir(&alice, "work").await.unwrap(); - assert!(fs.lookup_within(&alice, &alice, "work").await.is_ok()); - assert!(fs.lookup_within(&alice, &alice, "/work").await.is_ok()); - assert!(fs - .lookup_within(&alice, &alice, "work/../work") - .await - .is_ok()); - } - - /// An absolute path means the *scope's* root, not the filesystem's. This - /// is the first of the three escapes and the most obvious to try. - #[tokio::test] - async fn an_absolute_path_cannot_leave_the_scope() { - let (fs, alice, _) = tenanted().await; - assert!(fs - .lookup_within(&alice, &alice, "/tenants/bob") - .await - .is_err()); - assert!(fs - .lookup_within(&alice, &alice, "/runtime-only") - .await - .is_err()); - // And it is not that the names are unknown — unscoped they resolve. - let root = fs.root(); - assert!(fs.lookup_at(&root, "/tenants/bob").await.is_ok()); - } - - /// The second escape: no number of `..` walks out. - #[tokio::test] - async fn dot_dot_cannot_climb_out_of_the_scope() { - let (fs, alice, _) = tenanted().await; - for path in ["..", "../..", "../bob", "../../runtime-only", "../../.."] { - assert!( - fs.lookup_within(&alice, &alice, path).await.is_err() - || fs - .lookup_within(&alice, &alice, path) - .await - .map(|i| i.objid()) - .unwrap() - == alice.objid(), - "{path} escaped the scope" - ); - } - } - - /// The third, and the one a guest can build for itself: an absolute - /// symlink resolves from the scope too, so writing one buys nothing. - #[tokio::test] - async fn an_absolute_symlink_cannot_leave_the_scope() { - let (fs, alice, _) = tenanted().await; - fs.symlink_at(&alice, "escape", "/tenants/bob/bobs-private") - .await - .unwrap(); - assert!(fs.lookup_within(&alice, &alice, "escape").await.is_err()); - - fs.symlink_at(&alice, "up", "../../runtime-only") - .await - .unwrap(); - assert!(fs.lookup_within(&alice, &alice, "up").await.is_err()); - } - - /// `..` is not the only way to ask for a parent. A descriptor at the top - /// of a scope must not be walkable upwards either. - #[tokio::test] - async fn the_scope_is_its_own_parent() { - let (fs, alice, _) = tenanted().await; - let parent = fs.parent_within(&alice, &alice).await.unwrap(); - assert_eq!(parent.objid(), alice.objid()); - // Unscoped, the same call does reach `/tenants` — which is exactly the - // difference the scope makes. - assert_ne!(fs.parent_of(&alice).await.unwrap().objid(), alice.objid()); - } - - /// Two tenants, same relative path, different files. The separation is the - /// resolver's, not the guest's. - #[tokio::test] - async fn two_scopes_name_different_files() { - let (fs, alice, bob) = tenanted().await; - fs.mkdir(&alice, "data").await.unwrap(); - fs.mkdir(&bob, "data").await.unwrap(); - let a = fs.lookup_within(&alice, &alice, "/data").await.unwrap(); - let b = fs.lookup_within(&bob, &bob, "/data").await.unwrap(); - assert_ne!(a.objid(), b.objid()); - } -} diff --git a/crates/s3fs-core/src/fs/tests.rs b/crates/s3fs-core/src/fs/tests.rs deleted file mode 100644 index cb4da27..0000000 --- a/crates/s3fs-core/src/fs/tests.rs +++ /dev/null @@ -1,1226 +0,0 @@ -//! POSIX-semantics tests for [`Fs`]. -//! -//! The store layer is covered in `store::*`; these exercise the filesystem -//! behaviour built on top of it, and in particular the semantics that the -//! previous path-to-key engine could not provide. - -use super::*; -use crate::backend::memory::MemoryBackend; - -/// Two buckets that survive a remount, mirroring the deployment split. -struct Harness { - data: Arc, - roots: Arc, - config: Arc, -} - -impl Harness { - fn new() -> Self { - Harness { - data: Arc::new(MemoryBackend::new()), - roots: Arc::new(MemoryBackend::new()), - // Small records so multi-record files are cheap to write, and no - // retention so a test can inspect the buckets freely. - config: Arc::new( - Config::builder() - .record_size(4096) - .root_retention(None) - .build(), - ), - } - } - - /// Create on first use, mount thereafter. `Fs::mount` no longer formats an - /// empty store, so tests that want a filesystem have to say so. - async fn mount(&self) -> Arc { - let secret = MasterSecret::from_bytes([21u8; 32]); - match Fs::mount( - self.data.clone(), - self.roots.clone(), - &secret, - [9u8; 16], - self.config.clone(), - None, - ) - .await - { - Err(crate::FsError::NoFilesystem) => Fs::create( - self.data.clone(), - self.roots.clone(), - &secret, - [9u8; 16], - self.config.clone(), - ) - .await - .unwrap(), - other => other.unwrap(), - } - } -} - -async fn fs() -> Arc { - Harness::new().mount().await -} - -async fn write_file(fs: &Fs, path: &str, data: &[u8]) { - let h = fs.open(path, OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, data).await.unwrap(); - fs.close(&h).await.unwrap(); -} - -async fn read_file(fs: &Fs, path: &str) -> Vec { - let h = fs.open(path, OpenFlags::read_only()).await.unwrap(); - let size = h.size().await; - let out = fs.pread(&h, 0, size as usize).await.unwrap(); - fs.close(&h).await.unwrap(); - out.to_vec() -} - -async fn names(fs: &Fs, dir: &Arc) -> Vec { - fs.read_dir(dir) - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .collect() -} - -// ---- mount and namespace --------------------------------------------------- - -#[tokio::test] -async fn a_fresh_mount_has_an_empty_root_directory() { - let fs = fs().await; - let root = fs.root(); - assert_eq!(fs.stat(&root).await.unwrap().kind, InodeKind::Directory); - assert_eq!(names(&fs, &root).await, Vec::::new()); -} - -#[tokio::test] -async fn mkdir_then_list() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "b").await.unwrap(); - fs.mkdir(&root, "a").await.unwrap(); - - assert_eq!(names(&fs, &root).await, vec!["a", "b"]); - let a = fs.lookup_at(&root, "a").await.unwrap(); - assert_eq!(fs.stat(&a).await.unwrap().kind, InodeKind::Directory); -} - -#[tokio::test] -async fn mkdir_refuses_a_name_already_in_use() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "dup").await.unwrap(); - assert!(matches!( - fs.mkdir(&root, "dup").await, - Err(FsError::AlreadyExists) - )); -} - -#[tokio::test] -async fn nested_directories_resolve() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - let b = fs.mkdir(&a, "b").await.unwrap(); - fs.mkdir(&b, "c").await.unwrap(); - - let c = fs.lookup_at(&root, "a/b/c").await.unwrap(); - assert_eq!(fs.stat(&c).await.unwrap().kind, InodeKind::Directory); - assert!(matches!( - fs.lookup_at(&root, "a/nope/c").await, - Err(FsError::NotFound) - )); -} - -#[tokio::test] -async fn dotdot_walks_up_and_stops_at_the_root() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - fs.mkdir(&a, "b").await.unwrap(); - write_file(&fs, "/marker", b"x").await; - - let up = fs.lookup_at(&root, "a/b/../..").await.unwrap(); - assert_eq!(up.objid(), root.objid()); - - // `..` past the root must not escape the mount. - let clamped = fs.lookup_at(&root, "../../../marker").await.unwrap(); - assert_eq!(fs.stat(&clamped).await.unwrap().size, 1); -} - -#[tokio::test] -async fn dot_components_are_ignored() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "a").await.unwrap(); - write_file(&fs, "/a/f", b"hi").await; - assert_eq!(read_file(&fs, "/./a/./f").await, b"hi"); -} - -// ---- files ----------------------------------------------------------------- - -#[tokio::test] -async fn write_then_read_back() { - let fs = fs().await; - write_file(&fs, "/hello.txt", b"hello world").await; - assert_eq!(read_file(&fs, "/hello.txt").await, b"hello world"); -} - -#[tokio::test] -async fn a_writer_sees_its_own_unsynced_writes() { - let fs = fs().await; - let h = fs.open("/f", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, b"abcdef").await.unwrap(); - assert_eq!( - fs.pread(&h, 2, 3).await.unwrap(), - Bytes::from_static(b"cde") - ); - fs.close(&h).await.unwrap(); -} - -#[tokio::test] -async fn writes_spanning_many_records_round_trip() { - let fs = fs().await; - // 10 records at 4 KiB, written in one call. - let data: Vec = (0..40960u32).map(|i| (i % 251) as u8).collect(); - write_file(&fs, "/big", &data).await; - assert_eq!(read_file(&fs, "/big").await, data); -} - -#[tokio::test] -async fn a_write_in_the_middle_leaves_the_rest_intact() { - let fs = fs().await; - let data = vec![0xaau8; 12288]; - write_file(&fs, "/f", &data).await; - - let h = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.pwrite(&h, 5000, b"PATCH").await.unwrap(); - fs.close(&h).await.unwrap(); - - let mut expected = data.clone(); - expected[5000..5005].copy_from_slice(b"PATCH"); - assert_eq!(read_file(&fs, "/f").await, expected); -} - -#[tokio::test] -async fn sparse_writes_read_back_as_zeros() { - let fs = fs().await; - let h = fs.open("/sparse", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 100_000, b"end").await.unwrap(); - fs.close(&h).await.unwrap(); - - let out = read_file(&fs, "/sparse").await; - assert_eq!(out.len(), 100_003); - assert!(out[..100_000].iter().all(|&b| b == 0)); - assert_eq!(&out[100_000..], b"end"); -} - -#[tokio::test] -async fn reads_past_the_end_return_nothing() { - let fs = fs().await; - write_file(&fs, "/f", b"12345").await; - let h = fs.open("/f", OpenFlags::read_only()).await.unwrap(); - assert_eq!(fs.pread(&h, 5, 10).await.unwrap().len(), 0); - assert_eq!( - fs.pread(&h, 3, 10).await.unwrap(), - Bytes::from_static(b"45") - ); -} - -#[tokio::test] -async fn append_writes_at_the_end() { - let fs = fs().await; - write_file(&fs, "/log", b"one").await; - let h = fs - .open( - "/log", - OpenFlags { - write: true, - append: true, - ..Default::default() - }, - ) - .await - .unwrap(); - // The offset is ignored for an append handle. - fs.pwrite(&h, 0, b"-two").await.unwrap(); - fs.close(&h).await.unwrap(); - assert_eq!(read_file(&fs, "/log").await, b"one-two"); -} - -#[tokio::test] -async fn exclusive_create_refuses_an_existing_file() { - let fs = fs().await; - write_file(&fs, "/f", b"x").await; - assert!(matches!( - fs.open("/f", OpenFlags::create_new()).await, - Err(FsError::AlreadyExists) - )); -} - -#[tokio::test] -async fn opening_a_missing_file_without_create_fails() { - let fs = fs().await; - assert!(matches!( - fs.open("/nope", OpenFlags::read_only()).await, - Err(FsError::NotFound) - )); -} - -#[tokio::test] -async fn truncate_on_open_empties_the_file() { - let fs = fs().await; - write_file(&fs, "/f", b"content").await; - let h = fs - .open( - "/f", - OpenFlags { - read: true, - write: true, - truncate: true, - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!(h.size().await, 0); - fs.close(&h).await.unwrap(); - assert_eq!(read_file(&fs, "/f").await, b""); -} - -#[tokio::test] -async fn set_size_truncates_and_grows() { - let fs = fs().await; - write_file(&fs, "/f", b"0123456789").await; - - let h = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.set_size(&h, 4).await.unwrap(); - fs.close(&h).await.unwrap(); - assert_eq!(read_file(&fs, "/f").await, b"0123"); - - // Growing is free: the new region is a hole, at any size. - let h = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.set_size(&h, 1_000_000).await.unwrap(); - fs.close(&h).await.unwrap(); - - let out = read_file(&fs, "/f").await; - assert_eq!(out.len(), 1_000_000); - assert_eq!(&out[..4], b"0123"); - assert!(out[4..].iter().all(|&b| b == 0)); -} - -#[tokio::test] -async fn a_regrown_file_does_not_resurrect_old_bytes() { - let fs = fs().await; - write_file(&fs, "/f", b"secretsecret").await; - let h = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.set_size(&h, 2).await.unwrap(); - fs.set_size(&h, 12).await.unwrap(); - fs.close(&h).await.unwrap(); - - let out = read_file(&fs, "/f").await; - assert_eq!(&out[..2], b"se"); - assert!(out[2..].iter().all(|&b| b == 0), "stale data reappeared"); -} - -// ---- durability ------------------------------------------------------------ - -#[tokio::test] -async fn committed_state_survives_a_remount() { - let h = Harness::new(); - let fs = h.mount().await; - fs.mkdir(&fs.root(), "dir").await.unwrap(); - write_file(&fs, "/dir/file", b"durable").await; - drop(fs); - - let fs = h.mount().await; - assert_eq!(read_file(&fs, "/dir/file").await, b"durable"); - assert_eq!(names(&fs, &fs.root()).await, vec!["dir"]); -} - -/// Dropping a handle without syncing loses the writes — but the filesystem is -/// still a consistent, verified state, never a torn one. -/// A sync that fails on a timeout keeps the buffered data, so the retry -/// (or the close) actually writes it. -#[tokio::test] -async fn a_failed_sync_keeps_the_writes_for_the_retry() { - let h = Harness::new(); - let fs = h.mount().await; - let handle = fs.open("/f", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&handle, 0, b"survives").await.unwrap(); - - h.data.fail_next_put(crate::FsError::IoTimeout); - assert!(matches!( - fs.sync(&handle).await, - Err(crate::FsError::IoTimeout) - )); - fs.sync(&handle).await.unwrap(); - fs.close(&handle).await.unwrap(); - - let fs = h.mount().await; - assert_eq!(read_file(&fs, "/f").await, b"survives"); -} - -#[tokio::test] -async fn unsynced_writes_are_lost_but_leave_a_consistent_state() { - let h = Harness::new(); - let fs = h.mount().await; - write_file(&fs, "/f", b"committed").await; - - let handle = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.pwrite(&handle, 0, b"UNCOMMITTED").await.unwrap(); - drop(handle); // no close, no sync - drop(fs); - - let fs = h.mount().await; - assert_eq!(read_file(&fs, "/f").await, b"committed"); -} - -#[tokio::test] -async fn every_mutation_advances_the_root_sequence() { - let fs = fs().await; - let start = fs.store().root().await.unwrap().seq; - - fs.mkdir(&fs.root(), "a").await.unwrap(); - write_file(&fs, "/a/f", b"x").await; - - let end = fs.store().root().await.unwrap().seq; - assert!(end > start, "mutations must produce new anchored roots"); -} - -// ---- rename ---------------------------------------------------------------- - -#[tokio::test] -async fn rename_within_a_directory() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/old", b"data").await; - - fs.rename(&root, "old", &root, "new").await.unwrap(); - assert_eq!(names(&fs, &root).await, vec!["new"]); - assert_eq!(read_file(&fs, "/new").await, b"data"); - assert!(matches!( - fs.lookup_at(&root, "old").await, - Err(FsError::NotFound) - )); -} - -#[tokio::test] -async fn rename_across_directories() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - let b = fs.mkdir(&root, "b").await.unwrap(); - write_file(&fs, "/a/f", b"moved").await; - - fs.rename(&a, "f", &b, "g").await.unwrap(); - assert_eq!(names(&fs, &a).await, Vec::::new()); - assert_eq!(names(&fs, &b).await, vec!["g"]); - assert_eq!(read_file(&fs, "/b/g").await, b"moved"); -} - -/// The property the previous engine could not offer: a rename is one commit, -/// so no observer ever sees the entry under both names or under neither. -#[tokio::test] -async fn rename_is_a_single_commit() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/old", b"data").await; - - let before = fs.store().root().await.unwrap().seq; - fs.rename(&root, "old", &root, "new").await.unwrap(); - let after = fs.store().root().await.unwrap().seq; - assert_eq!(after, before + 1, "rename must be exactly one commit"); -} - -#[tokio::test] -async fn renaming_a_directory_moves_its_whole_subtree() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - fs.mkdir(&a, "sub").await.unwrap(); - write_file(&fs, "/a/sub/deep", b"deep").await; - - // A directory rename is one entry move, not a recursive copy. - let before = fs.store().root().await.unwrap().seq; - fs.rename(&root, "a", &root, "z").await.unwrap(); - assert_eq!(fs.store().root().await.unwrap().seq, before + 1); - - assert_eq!(read_file(&fs, "/z/sub/deep").await, b"deep"); -} - -/// The subtree would keep its parent link into a directory now inside it, -/// and nothing from the root could reach any of it again. -#[tokio::test] -async fn a_directory_cannot_be_renamed_into_its_own_subtree() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - let b = fs.mkdir(&a, "b").await.unwrap(); - write_file(&fs, "/a/b/data", b"data").await; - - assert!(matches!( - fs.rename(&root, "a", &b, "moved").await, - Err(FsError::Invalid(_)) - )); - assert!(matches!( - fs.rename(&root, "a", &a, "moved").await, - Err(FsError::Invalid(_)) - )); - assert_eq!(read_file(&fs, "/a/b/data").await, b"data"); - - // A file may go anywhere, and a directory may still go sideways. - fs.rename(&b, "data", &b, "data2").await.unwrap(); - fs.rename(&a, "b", &root, "b").await.unwrap(); - assert_eq!(read_file(&fs, "/b/data2").await, b"data"); -} - -#[tokio::test] -async fn renaming_a_directory_repoints_its_parent_link() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - let b = fs.mkdir(&root, "b").await.unwrap(); - let moved = fs.mkdir(&a, "moved").await.unwrap(); - - fs.rename(&a, "moved", &b, "moved").await.unwrap(); - - // `..` from inside the moved directory must lead to its new parent. - let up = fs.lookup_at(&moved, "..").await.unwrap(); - assert_eq!(up.objid(), b.objid()); -} - -#[tokio::test] -async fn rename_replaces_an_existing_file() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/src", b"new").await; - write_file(&fs, "/dst", b"old").await; - - fs.rename(&root, "src", &root, "dst").await.unwrap(); - assert_eq!(names(&fs, &root).await, vec!["dst"]); - assert_eq!(read_file(&fs, "/dst").await, b"new"); -} - -#[tokio::test] -async fn rename_refuses_mismatched_kinds_and_non_empty_targets() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "dir").await.unwrap(); - fs.mkdir(&root, "full").await.unwrap(); - write_file(&fs, "/full/inside", b"x").await; - write_file(&fs, "/file", b"x").await; - - assert!(matches!( - fs.rename(&root, "file", &root, "dir").await, - Err(FsError::IsDirectory) - )); - assert!(matches!( - fs.rename(&root, "dir", &root, "file").await, - Err(FsError::NotDirectory) - )); - assert!(matches!( - fs.rename(&root, "dir", &root, "full").await, - Err(FsError::NotEmpty) - )); -} - -#[tokio::test] -async fn renaming_onto_itself_is_a_no_op() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/f", b"x").await; - fs.rename(&root, "f", &root, "f").await.unwrap(); - assert_eq!(read_file(&fs, "/f").await, b"x"); -} - -// ---- unlink and rmdir ------------------------------------------------------ - -#[tokio::test] -async fn unlink_removes_a_file() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/f", b"x").await; - fs.unlink(&root, "f").await.unwrap(); - - assert_eq!(names(&fs, &root).await, Vec::::new()); - assert!(matches!( - fs.lookup_at(&root, "f").await, - Err(FsError::NotFound) - )); - assert!(matches!( - fs.unlink(&root, "f").await, - Err(FsError::NotFound) - )); -} - -#[tokio::test] -async fn rmdir_requires_an_empty_directory() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "d").await.unwrap(); - write_file(&fs, "/d/f", b"x").await; - - assert!(matches!(fs.rmdir(&root, "d").await, Err(FsError::NotEmpty))); - fs.unlink(&fs.lookup_at(&root, "d").await.unwrap(), "f") - .await - .unwrap(); - fs.rmdir(&root, "d").await.unwrap(); - assert_eq!(names(&fs, &root).await, Vec::::new()); -} - -#[tokio::test] -async fn unlink_and_rmdir_refuse_the_wrong_kind() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "d").await.unwrap(); - write_file(&fs, "/f", b"x").await; - - assert!(matches!( - fs.unlink(&root, "d").await, - Err(FsError::IsDirectory) - )); - assert!(matches!( - fs.rmdir(&root, "f").await, - Err(FsError::NotDirectory) - )); -} - -// ---- symlinks -------------------------------------------------------------- - -#[tokio::test] -async fn symlink_round_trips_and_resolves() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/target", b"pointed-at").await; - fs.symlink_at(&root, "link", "target").await.unwrap(); - - assert_eq!(fs.readlink_at(&root, "link").await.unwrap(), "target"); - assert_eq!(read_file(&fs, "/link").await, b"pointed-at"); - - // Without following, the link itself is what resolves. - let raw = fs.lookup_at_no_follow(&root, "link").await.unwrap(); - assert_eq!(fs.stat(&raw).await.unwrap().kind, InodeKind::Symlink); -} - -#[tokio::test] -async fn symlinks_resolve_through_directories() { - let fs = fs().await; - let root = fs.root(); - let a = fs.mkdir(&root, "a").await.unwrap(); - fs.mkdir(&a, "real").await.unwrap(); - write_file(&fs, "/a/real/f", b"deep").await; - fs.symlink_at(&root, "shortcut", "a/real").await.unwrap(); - - assert_eq!(read_file(&fs, "/shortcut/f").await, b"deep"); -} - -#[tokio::test] -async fn an_absolute_symlink_resolves_from_the_root() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "d").await.unwrap(); - write_file(&fs, "/d/f", b"abs").await; - let d = fs.lookup_at(&root, "d").await.unwrap(); - fs.symlink_at(&d, "link", "/d/f").await.unwrap(); - - assert_eq!(read_file(&fs, "/d/link").await, b"abs"); -} - -#[tokio::test] -async fn a_symlink_loop_is_bounded() { - let fs = fs().await; - let root = fs.root(); - fs.symlink_at(&root, "a", "b").await.unwrap(); - fs.symlink_at(&root, "b", "a").await.unwrap(); - - assert!(matches!(fs.lookup_at(&root, "a").await, Err(FsError::Loop))); -} - -#[tokio::test] -async fn a_long_symlink_target_spills_to_blocks_and_reads_back() { - let fs = fs().await; - let root = fs.root(); - // Longer than the dnode's inline capacity, so it must use data blocks. - let target = format!("/{}", "x".repeat(INLINE_CAP + 50)); - fs.symlink_at(&root, "long", &target).await.unwrap(); - assert_eq!(fs.readlink_at(&root, "long").await.unwrap(), target); -} - -#[tokio::test] -async fn readlink_refuses_a_regular_file() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/f", b"x").await; - assert!(fs.readlink_at(&root, "f").await.is_err()); -} - -// ---- attributes ------------------------------------------------------------ - -#[tokio::test] -async fn stat_reports_real_metadata() { - let fs = fs().await; - write_file(&fs, "/f", b"12345").await; - let ino = fs.lookup_at(&fs.root(), "f").await.unwrap(); - let a = fs.stat(&ino).await.unwrap(); - - assert_eq!(a.kind, InodeKind::RegularFile); - assert_eq!(a.size, 5); - assert_eq!(a.nlink, 1); - assert_eq!(a.mode, DEFAULT_FILE_MODE); -} - -/// Timestamps live in the dnode, so there is no S3 `LastModified` to disagree -/// with and no self-copy to persist them. -#[tokio::test] -async fn set_times_persists_exactly() { - let fs = fs().await; - write_file(&fs, "/f", b"x").await; - - let when = crate::inode::from_nanos(1_700_000_000_000_000_000); - let h = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.set_times(&h, Some(when), Some(when)).await.unwrap(); - fs.close(&h).await.unwrap(); - - let ino = fs.lookup_at(&fs.root(), "f").await.unwrap(); - let a = fs.stat(&ino).await.unwrap(); - assert_eq!(a.mtime, when); - assert_eq!(a.atime, when); -} - -#[tokio::test] -async fn set_times_at_works_without_a_handle() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "d").await.unwrap(); - - let when = crate::inode::from_nanos(1_234_567_890); - fs.set_times_at(&root, "d", None, Some(when), true) - .await - .unwrap(); - - let d = fs.lookup_at(&root, "d").await.unwrap(); - assert_eq!(fs.stat(&d).await.unwrap().mtime, when); -} - -/// Object ids are never reused, so identity holds across a remount — which is -/// what `is-same-object` and `metadata-hash` need in order to be exact. -#[tokio::test] -async fn identity_is_stable_across_a_remount() { - let h = Harness::new(); - let fs = h.mount().await; - write_file(&fs, "/f", b"x").await; - let before = *fs.lookup_at(&fs.root(), "f").await.unwrap(); - drop(fs); - - let fs = h.mount().await; - let after = *fs.lookup_at(&fs.root(), "f").await.unwrap(); - assert_eq!(before, after); -} - -// ---- validation ------------------------------------------------------------ - -#[tokio::test] -async fn bad_names_are_rejected() { - let fs = fs().await; - let root = fs.root(); - assert!(fs.mkdir(&root, "with\0nul").await.is_err()); - assert!(fs.mkdir(&root, &"x".repeat(300)).await.is_err()); - assert!(fs.mkdir(&root, "").await.is_err()); -} - -#[tokio::test] -async fn opening_with_neither_read_nor_write_is_rejected() { - let fs = fs().await; - assert!(matches!( - fs.open("/f", OpenFlags::default()).await, - Err(FsError::Invalid(_)) - )); -} - -/// Every mutator that takes a handle, not just the one that writes bytes. -/// `set_times` used to be exempt, which made `flags.write` mean "may change the -/// contents" rather than "may change the file". -#[tokio::test] -async fn mutating_through_a_read_only_handle_is_refused() { - let fs = fs().await; - write_file(&fs, "/f", b"x").await; - let h = fs.open("/f", OpenFlags::read_only()).await.unwrap(); - - assert!(matches!( - fs.pwrite(&h, 0, b"nope").await, - Err(FsError::BadDescriptor) - )); - assert!(matches!( - fs.set_size(&h, 0).await, - Err(FsError::BadDescriptor) - )); - let when = crate::inode::from_nanos(1_700_000_000_000_000_000); - assert!(matches!( - fs.set_times(&h, Some(when), Some(when)).await, - Err(FsError::BadDescriptor) - )); -} - -/// Why the gate matters here more than it would in an ordinary filesystem: -/// every mutation commits, and every commit publishes a signed root record -/// under Object Lock that nothing can afterwards remove. A refusal that still -/// advanced the chain would be worse than no refusal, because it would look -/// safe. -#[tokio::test] -async fn a_read_only_handle_cannot_advance_the_anchor_chain() { - let fs = fs().await; - write_file(&fs, "/f", b"x").await; - let before = fs.store().root().await.unwrap().seq; - - let h = fs.open("/f", OpenFlags::read_only()).await.unwrap(); - let when = crate::inode::from_nanos(1_700_000_000_000_000_000); - let _ = fs.pwrite(&h, 0, b"nope").await; - let _ = fs.set_size(&h, 0).await; - let _ = fs.set_times(&h, Some(when), Some(when)).await; - fs.close(&h).await.unwrap(); - - assert_eq!( - fs.store().root().await.unwrap().seq, - before, - "a read-only handle committed something" - ); - - // And the timestamps it tried to set did not land. - let ino = fs.lookup_at(&fs.root(), "f").await.unwrap(); - assert_ne!(fs.stat(&ino).await.unwrap().mtime, when); -} - -#[tokio::test] -async fn opening_a_directory_for_writing_is_refused() { - let fs = fs().await; - fs.mkdir(&fs.root(), "d").await.unwrap(); - assert!(matches!( - fs.open("/d", OpenFlags::write_only()).await, - Err(FsError::IsDirectory) - )); -} - -#[tokio::test] -async fn resolving_through_a_file_is_refused() { - let fs = fs().await; - write_file(&fs, "/f", b"x").await; - assert!(matches!( - fs.lookup_at(&fs.root(), "f/inside").await, - Err(FsError::NotDirectory) - )); -} - -// ---- integrity end to end -------------------------------------------------- - -/// The whole point of the design, exercised through the public API: corrupt a -/// slab and the filesystem refuses to serve the bytes rather than returning -/// plausible-looking wrong ones. -#[tokio::test] -async fn tampered_storage_is_refused_at_the_filesystem_layer() { - use crate::backend::PutBlobInput; - - let h = Harness::new(); - let fs = h.mount().await; - write_file(&fs, "/f", &vec![0x5au8; 8192]).await; - drop(fs); - - // Flip a byte in every slab the data bucket holds. - for key in h.data.keys() { - let mut body = h.data.get_blob(&key, None).await.unwrap().body.to_vec(); - body[0] ^= 0xff; - h.data - .put_blob(PutBlobInput::new(key, Bytes::from(body))) - .await - .unwrap(); - } - - let fs = h.mount().await; - let result = async { - let handle = fs.open("/f", OpenFlags::read_only()).await?; - fs.pread(&handle, 0, 8192).await - } - .await; - assert!( - matches!(result, Err(FsError::Integrity(_))), - "expected an integrity failure, got {result:?}" - ); -} - -// ---- hard links ------------------------------------------------------------ - -#[tokio::test] -async fn a_hard_link_shares_one_object() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/original", b"shared").await; - - fs.link_at(&root, "original", &root, "alias", true) - .await - .unwrap(); - - assert_eq!(names(&fs, &root).await, vec!["alias", "original"]); - assert_eq!(read_file(&fs, "/alias").await, b"shared"); - - // Both names must be the same object, not a copy of it. - let a = fs.lookup_at(&root, "original").await.unwrap(); - let b = fs.lookup_at(&root, "alias").await.unwrap(); - assert_eq!(*a, *b); - assert_eq!(fs.stat(&a).await.unwrap().nlink, 2); -} - -#[tokio::test] -async fn a_write_through_one_link_is_visible_through_the_other() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/a", b"before").await; - fs.link_at(&root, "a", &root, "b", true).await.unwrap(); - - let h = fs.open("/b", OpenFlags::read_write()).await.unwrap(); - fs.pwrite(&h, 0, b"after!").await.unwrap(); - fs.close(&h).await.unwrap(); - - assert_eq!(read_file(&fs, "/a").await, b"after!"); -} - -#[tokio::test] -async fn unlinking_one_link_leaves_the_other() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/a", b"content").await; - fs.link_at(&root, "a", &root, "b", true).await.unwrap(); - - fs.unlink(&root, "a").await.unwrap(); - assert_eq!(read_file(&fs, "/b").await, b"content"); - - let b = fs.lookup_at(&root, "b").await.unwrap(); - assert_eq!(fs.stat(&b).await.unwrap().nlink, 1); - assert!(matches!( - fs.lookup_at(&root, "a").await, - Err(FsError::NotFound) - )); - - // Removing the last link really does remove the object. - fs.unlink(&root, "b").await.unwrap(); - assert_eq!(names(&fs, &root).await, Vec::::new()); -} - -#[tokio::test] -async fn hard_links_survive_a_remount() { - let h = Harness::new(); - let fs = h.mount().await; - let root = fs.root(); - write_file(&fs, "/a", b"durable").await; - fs.link_at(&root, "a", &root, "b", true).await.unwrap(); - drop(fs); - - let fs = h.mount().await; - let a = fs.lookup_at(&fs.root(), "a").await.unwrap(); - let b = fs.lookup_at(&fs.root(), "b").await.unwrap(); - assert_eq!(*a, *b); - assert_eq!(fs.stat(&a).await.unwrap().nlink, 2); -} - -#[tokio::test] -async fn links_can_cross_directories() { - let fs = fs().await; - let root = fs.root(); - let d = fs.mkdir(&root, "d").await.unwrap(); - write_file(&fs, "/f", b"x").await; - - fs.link_at(&root, "f", &d, "linked", true).await.unwrap(); - assert_eq!(read_file(&fs, "/d/linked").await, b"x"); -} - -/// A hard link to a directory would turn the namespace into a graph, and the -/// dnode's parent link has room for exactly one answer. -#[tokio::test] -async fn a_directory_cannot_be_hard_linked() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "d").await.unwrap(); - assert!(matches!( - fs.link_at(&root, "d", &root, "alias", true).await, - Err(FsError::NotPermitted) - )); -} - -#[tokio::test] -async fn link_refuses_an_occupied_name_or_a_missing_source() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/a", b"x").await; - write_file(&fs, "/b", b"y").await; - - assert!(matches!( - fs.link_at(&root, "a", &root, "b", true).await, - Err(FsError::AlreadyExists) - )); - assert!(matches!( - fs.link_at(&root, "nope", &root, "c", true).await, - Err(FsError::NotFound) - )); -} - -#[tokio::test] -async fn linking_a_symlink_can_target_the_link_itself() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/target", b"data").await; - fs.symlink_at(&root, "sym", "target").await.unwrap(); - - // Without following, the new name is a second link to the symlink object. - fs.link_at(&root, "sym", &root, "sym2", false) - .await - .unwrap(); - assert_eq!(fs.readlink_at(&root, "sym2").await.unwrap(), "target"); - - // Following, it is a second link to the file the symlink points at. - fs.link_at(&root, "sym", &root, "direct", true) - .await - .unwrap(); - let direct = fs.lookup_at_no_follow(&root, "direct").await.unwrap(); - assert_eq!(fs.stat(&direct).await.unwrap().kind, InodeKind::RegularFile); -} - -// ---- unlink while open ----------------------------------------------------- - -/// POSIX keeps an unlinked file alive until the last descriptor closes. The -/// previous engine could not do this at all. -#[tokio::test] -async fn an_unlinked_file_stays_readable_through_an_open_handle() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/doomed", b"still here").await; - - let h = fs.open("/doomed", OpenFlags::read_only()).await.unwrap(); - fs.unlink(&root, "doomed").await.unwrap(); - - // The name is gone... - assert!(matches!( - fs.lookup_at(&root, "doomed").await, - Err(FsError::NotFound) - )); - // ...but the handle still works. - assert_eq!( - fs.pread(&h, 0, 10).await.unwrap(), - Bytes::from_static(b"still here") - ); - fs.close(&h).await.unwrap(); -} - -#[tokio::test] -async fn an_unlinked_file_is_still_writable_through_its_handle() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/doomed", b"old").await; - - let h = fs.open("/doomed", OpenFlags::read_write()).await.unwrap(); - fs.unlink(&root, "doomed").await.unwrap(); - fs.pwrite(&h, 0, b"new").await.unwrap(); - assert_eq!( - fs.pread(&h, 0, 3).await.unwrap(), - Bytes::from_static(b"new") - ); - fs.close(&h).await.unwrap(); -} - -#[tokio::test] -async fn the_object_is_freed_when_the_last_handle_closes() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/doomed", b"x").await; - let ino = fs.lookup_at(&root, "doomed").await.unwrap(); - - let h = fs.open("/doomed", OpenFlags::read_only()).await.unwrap(); - fs.unlink(&root, "doomed").await.unwrap(); - assert!(fs.stat(&ino).await.is_ok(), "still alive while open"); - - fs.close(&h).await.unwrap(); - assert!( - matches!(fs.stat(&ino).await, Err(FsError::NotFound)), - "the last close must free it" - ); -} - -#[tokio::test] -async fn the_object_survives_until_every_handle_closes() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/doomed", b"x").await; - let ino = fs.lookup_at(&root, "doomed").await.unwrap(); - - let a = fs.open("/doomed", OpenFlags::read_only()).await.unwrap(); - let b = fs.open("/doomed", OpenFlags::read_only()).await.unwrap(); - fs.unlink(&root, "doomed").await.unwrap(); - - fs.close(&a).await.unwrap(); - assert!(fs.stat(&ino).await.is_ok(), "one handle remains"); - fs.close(&b).await.unwrap(); - assert!(matches!(fs.stat(&ino).await, Err(FsError::NotFound))); -} - -/// A closed file with no remaining handles is freed immediately, not deferred. -#[tokio::test] -async fn unlinking_a_closed_file_frees_it_at_once() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/f", b"x").await; - let ino = fs.lookup_at(&root, "f").await.unwrap(); - - fs.unlink(&root, "f").await.unwrap(); - assert!(matches!(fs.stat(&ino).await, Err(FsError::NotFound))); -} - -/// If the object gets a new name before the last close, it must not be freed. -#[tokio::test] -async fn relinking_before_the_last_close_cancels_the_free() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/f", b"rescued").await; - - let h = fs.open("/f", OpenFlags::read_only()).await.unwrap(); - fs.link_at(&root, "f", &root, "rescue", true).await.unwrap(); - fs.unlink(&root, "f").await.unwrap(); - fs.close(&h).await.unwrap(); - - assert_eq!(read_file(&fs, "/rescue").await, b"rescued"); -} - -#[tokio::test] -async fn a_file_replaced_by_rename_stays_readable_while_open() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/victim", b"victim data").await; - write_file(&fs, "/src", b"src data").await; - - let h = fs.open("/victim", OpenFlags::read_only()).await.unwrap(); - fs.rename(&root, "src", &root, "victim").await.unwrap(); - - assert_eq!(read_file(&fs, "/victim").await, b"src data"); - assert_eq!( - fs.pread(&h, 0, 11).await.unwrap(), - Bytes::from_static(b"victim data"), - "the replaced file must remain readable through its handle" - ); - fs.close(&h).await.unwrap(); -} - -// ---- snapshots ------------------------------------------------------------- - -/// Every root record is a snapshot, because copy-on-write never overwrites the -/// blocks an older root names. -#[tokio::test] -async fn a_snapshot_shows_the_state_at_its_sequence() { - let fs = fs().await; - write_file(&fs, "/f", b"version one").await; - let first = fs.store().root().await.unwrap().seq; - - let h = fs.open("/f", OpenFlags::read_write()).await.unwrap(); - fs.pwrite(&h, 0, b"version two").await.unwrap(); - fs.close(&h).await.unwrap(); - - assert_eq!(read_file(&fs, "/f").await, b"version two"); - - let snap = fs.open_snapshot(first).await.unwrap(); - let ino = snap.lookup(&snap.root(), "f").await.unwrap(); - assert_eq!( - snap.read(&ino, 0, 11).await.unwrap(), - Bytes::from_static(b"version one") - ); - assert_eq!(snap.info().seq, first); -} - -#[tokio::test] -async fn a_snapshot_still_holds_a_since_deleted_file() { - let fs = fs().await; - let root = fs.root(); - write_file(&fs, "/gone", b"recoverable").await; - let before_delete = fs.store().root().await.unwrap().seq; - fs.unlink(&root, "gone").await.unwrap(); - - assert!(matches!( - fs.lookup_at(&root, "gone").await, - Err(FsError::NotFound) - )); - - let snap = fs.open_snapshot(before_delete).await.unwrap(); - let ino = snap.lookup(&snap.root(), "gone").await.unwrap(); - assert_eq!( - snap.read(&ino, 0, 11).await.unwrap(), - Bytes::from_static(b"recoverable") - ); -} - -#[tokio::test] -async fn snapshots_list_newest_first_with_distinct_merkle_roots() { - let fs = fs().await; - for i in 0..4u32 { - write_file(&fs, &format!("/f{i}"), b"x").await; - } - - let snaps = fs.snapshots(3).await.unwrap(); - assert_eq!(snaps.len(), 3); - assert!( - snaps.windows(2).all(|w| w[0].seq > w[1].seq), - "newest first" - ); - // Each commit changes the filesystem, so each root covers a different tree. - let roots: std::collections::HashSet<_> = snaps.iter().map(|s| s.merkle_root).collect(); - assert_eq!(roots.len(), snaps.len()); -} - -#[tokio::test] -async fn snapshot_listing_stops_at_the_genesis_root() { - let fs = fs().await; - write_file(&fs, "/f", b"x").await; - - // Asking for more than exist yields what exists, ending at sequence 0. - let snaps = fs.snapshots(1000).await.unwrap(); - assert_eq!(snaps.last().unwrap().seq, 0); - assert!(fs.snapshots(0).await.unwrap().is_empty()); -} - -#[tokio::test] -async fn a_snapshot_can_list_directories_and_stat() { - let fs = fs().await; - let root = fs.root(); - fs.mkdir(&root, "d").await.unwrap(); - write_file(&fs, "/d/a", b"12345").await; - let seq = fs.store().root().await.unwrap().seq; - fs.unlink(&fs.lookup_at(&root, "d").await.unwrap(), "a") - .await - .unwrap(); - - let snap = fs.open_snapshot(seq).await.unwrap(); - let d = snap.lookup(&snap.root(), "d").await.unwrap(); - let entries: Vec<_> = snap - .read_dir(&d) - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .collect(); - assert_eq!(entries, vec!["a"]); - - let a = snap.lookup(&snap.root(), "d/a").await.unwrap(); - assert_eq!(snap.stat(&a).await.unwrap().size, 5); -} - -/// Opening a snapshot must not disturb the live mount's rollback floor — -/// otherwise reading history would be a way to make the store rewind. -#[tokio::test] -async fn opening_a_snapshot_does_not_lower_the_rollback_floor() { - let fs = fs().await; - write_file(&fs, "/a", b"x").await; - write_file(&fs, "/b", b"x").await; - - let floor = fs.store().roots().expected_seq(); - let _snap = fs.open_snapshot(0).await.unwrap(); - assert_eq!(fs.store().roots().expected_seq(), floor); - - // And the live mount is untouched. - assert_eq!(read_file(&fs, "/b").await, b"x"); -} diff --git a/crates/s3fs-core/src/inode.rs b/crates/s3fs-core/src/inode.rs deleted file mode 100644 index dc10092..0000000 --- a/crates/s3fs-core/src/inode.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! `Inode` — a handle on one object, and the attributes callers see. -//! -//! An inode is just an object id. There is no cached metadata and no parent -//! pointer: the dnode in the object set is the only source of truth, and it is -//! cheap to read because its block sits in the decrypted-block cache. That is -//! a deliberate change from the previous engine, whose inode tree cached -//! attributes with a TTL that was never actually checked — so a cache could -//! disagree with the store indefinitely and nothing would notice. -//! -//! Identity is `(objid, gen)`. Object ids are never reused, so this is stable -//! across mounts, which is what makes `is-same-object` and `metadata-hash` -//! exact rather than a hash of an ETag. - -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::errors::{FsError, FsResult}; -use crate::store::dnode::{Dnode, DnodeKind}; - -/// Stable identifier for an object. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct InodeId(pub u64); - -impl InodeId { - pub fn get(self) -> u64 { - self.0 - } -} - -/// What an object is, as the WASI layer sees it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InodeKind { - RegularFile, - Directory, - Symlink, -} - -impl InodeKind { - pub(crate) fn from_dnode(kind: DnodeKind) -> FsResult { - Ok(match kind { - DnodeKind::File => InodeKind::RegularFile, - DnodeKind::Dir => InodeKind::Directory, - DnodeKind::Symlink => InodeKind::Symlink, - DnodeKind::Free => return Err(FsError::NotFound), - DnodeKind::DnodeArray => return Err(FsError::NotSupported), - }) - } - - /// Used by callers that create objects of a chosen kind. - pub fn to_dnode(self) -> DnodeKind { - match self { - InodeKind::RegularFile => DnodeKind::File, - InodeKind::Directory => DnodeKind::Dir, - InodeKind::Symlink => DnodeKind::Symlink, - } - } - - pub fn is_dir(self) -> bool { - matches!(self, InodeKind::Directory) - } -} - -/// A reference to one object. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Inode { - pub id: InodeId, - /// Generation, paired with the id to form an identity that survives a - /// remount. - pub gen: u64, -} - -impl Inode { - pub fn new(objid: u64, gen: u64) -> Arc { - Arc::new(Inode { - id: InodeId(objid), - gen, - }) - } - - pub fn objid(&self) -> u64 { - self.id.0 - } -} - -/// Everything `stat` reports. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Attrs { - pub kind: InodeKind, - /// Logical size in bytes. For a directory this is the size of its block - /// structure, not an entry count. - pub size: u64, - /// Number of directory entries pointing at this object. - pub nlink: u32, - pub mode: u32, - pub atime: SystemTime, - pub mtime: SystemTime, - pub ctime: SystemTime, - pub btime: SystemTime, -} - -impl Attrs { - pub(crate) fn from_dnode(d: &Dnode) -> FsResult { - Ok(Attrs { - kind: InodeKind::from_dnode(d.kind)?, - size: d.size, - nlink: d.nlink, - mode: d.mode, - atime: from_nanos(d.atime_nanos), - mtime: from_nanos(d.mtime_nanos), - ctime: from_nanos(d.ctime_nanos), - btime: from_nanos(d.btime_nanos), - }) - } -} - -/// One entry of a directory listing. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DirEntry { - pub name: String, - pub kind: InodeKind, - /// Object id, so a caller can compare identity without a second lookup. - pub objid: u64, -} - -pub(crate) fn from_nanos(nanos: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_nanos(nanos) -} - -pub(crate) fn to_nanos(t: SystemTime) -> u64 { - t.duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos().min(u64::MAX as u128) as u64) - .unwrap_or(0) -} - -pub(crate) fn now_nanos() -> u64 { - to_nanos(SystemTime::now()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn kind_maps_both_ways() { - for k in [ - InodeKind::RegularFile, - InodeKind::Directory, - InodeKind::Symlink, - ] { - assert_eq!(InodeKind::from_dnode(k.to_dnode()).unwrap(), k); - } - } - - /// A free slot is "no such file", not a kind of file. Mapping it to - /// anything else would let a deleted object be stat'd. - #[test] - fn a_free_slot_is_not_a_file() { - assert!(matches!( - InodeKind::from_dnode(DnodeKind::Free), - Err(FsError::NotFound) - )); - assert!(InodeKind::from_dnode(DnodeKind::DnodeArray).is_err()); - } - - #[test] - fn timestamps_round_trip() { - for nanos in [0u64, 1, 1_000_000_000, 1_700_000_000_123_456_789] { - assert_eq!(to_nanos(from_nanos(nanos)), nanos); - } - } - - #[test] - fn pre_epoch_times_clamp_rather_than_panic() { - assert_eq!(to_nanos(UNIX_EPOCH - Duration::from_secs(1)), 0); - } - - #[test] - fn attrs_come_from_the_dnode() { - let mut d = Dnode::new(5, DnodeKind::File, 12, 42); - d.size = 1234; - d.mode = 0o100644; - d.nlink = 2; - let a = Attrs::from_dnode(&d).unwrap(); - - assert_eq!(a.kind, InodeKind::RegularFile); - assert_eq!(a.size, 1234); - assert_eq!(a.nlink, 2, "link count is real, not hardcoded to 1"); - assert_eq!(a.mode, 0o100644); - assert_eq!(to_nanos(a.mtime), 42); - } - - #[test] - fn identity_pairs_the_id_with_the_generation() { - let a = Inode::new(7, 1); - let b = Inode::new(7, 2); - assert_eq!(a.objid(), b.objid()); - assert_ne!(*a, *b, "a reused id with a new generation is a new object"); - } -} diff --git a/crates/s3fs-core/src/lib.rs b/crates/s3fs-core/src/lib.rs deleted file mode 100644 index 130adff..0000000 --- a/crates/s3fs-core/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! `s3fs-core` — a ZFS-style, Merkle-anchored filesystem over S3. -//! -//! Data lives in immutable, encrypted blocks packed into slab objects; the -//! whole filesystem hangs off a single signed, hash-chained root record written -//! with a conditional PUT under S3 Object Lock. Verifying that record -//! transitively verifies every byte beneath it, and the chain of records is -//! what makes a rollback detectable rather than merely unlikely. -//! -//! ```text -//! Fs POSIX semantics: paths, handles, directories -//! │ -//! store::Store transaction groups, the root chain, the object set -//! │ -//! Backend S3, S3-compatible, or an in-memory fake -//! ``` -//! -//! See [`store`] for the on-disk format and [`crypto`] for the key hierarchy. - -pub mod backend; -pub mod config; -pub mod crypto; -pub mod errors; -pub mod fs; -pub mod inode; -pub mod path; -pub mod store; - -pub use config::Config; -pub use crypto::MasterSecret; -pub use errors::{FsError, FsResult}; -pub use fs::{FileHandle, Fs, HandleId, OpenFlags, SnapshotFs, SnapshotInfo}; -pub use inode::{Attrs, DirEntry, Inode, InodeId, InodeKind}; -pub use store::{RootRecord, Store}; diff --git a/crates/s3fs-core/src/path.rs b/crates/s3fs-core/src/path.rs deleted file mode 100644 index bd7837a..0000000 --- a/crates/s3fs-core/src/path.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Path resolution and key encoding. -//! -//! WASI Preview 2 uses openat-style paths: every operation is relative to a -//! directory descriptor, with no ambient absolute paths. This module enforces -//! that contract and produces clean S3 keys from guest-supplied relative paths. -//! -//! Containment: `..` may pop above the descriptor's current directory but not -//! above the preopen root the descriptor was derived from. Paths attempting -//! such an escape return [`FsError::NotPermitted`]. -//! -//! Encoding: forward-slash maps directly to S3 key separator. NUL and control -//! bytes are rejected as illegal byte sequences. Per-segment length is capped -//! at 255 (POSIX `NAME_MAX`); total resolved key length at 1024 (S3 limit). - -use crate::errors::{FsError, FsResult}; - -/// POSIX `NAME_MAX`. -pub const MAX_SEGMENT_LEN: usize = 255; - -/// S3 object-key length limit. -pub const MAX_KEY_LEN: usize = 1024; - -/// Maximum stored target body for a symlink. Far longer than any sane path. -pub const MAX_SYMLINK_TARGET_LEN: usize = 4 * 1024; - -/// Validate a single path segment (a slash-free name). -/// -/// Rejects: -/// - empty string, `.`, `..` (reserved; handled by the resolver, never stored) -/// - segments longer than [`MAX_SEGMENT_LEN`] -/// - NUL (`0x00`) and any other control byte (`0x01..=0x1F` or `0x7F`) -pub fn validate_segment(name: &str) -> FsResult<()> { - if name.is_empty() || name == "." || name == ".." { - return Err(FsError::Invalid("reserved or empty path segment")); - } - if name.len() > MAX_SEGMENT_LEN { - return Err(FsError::NameTooLong); - } - for &b in name.as_bytes() { - if b == 0 || b < 0x20 || b == 0x7F { - return Err(FsError::IllegalByteSequence); - } - } - Ok(()) -} - -/// Resolve a guest-supplied relative path against a descriptor's current -/// directory key, with preopen-root containment enforced. -/// -/// - `preopen_root`: bucket-relative key prefix where the preopen was rooted. -/// Must not start or end with `/`. May be empty (preopen at bucket root). -/// - `base_key`: bucket-relative key for the descriptor's current directory. -/// Must begin with `preopen_root` (debug-asserted). Empty means the preopen -/// root itself. -/// - `rel_path`: guest-supplied path. Must not be absolute. May contain `.`, -/// `..`, and multiple consecutive `/`. -/// -/// Returns the resolved bucket-relative key with no leading slash, no trailing -/// slash, and no `.`/`..` components. -pub fn resolve_at(preopen_root: &str, base_key: &str, rel_path: &str) -> FsResult { - if rel_path.starts_with('/') { - return Err(FsError::NotPermitted); - } - - let mut stack: Vec<&str> = base_key.split('/').filter(|s| !s.is_empty()).collect(); - let floor = preopen_root.split('/').filter(|s| !s.is_empty()).count(); - - debug_assert!( - stack.len() >= floor, - "base_key {:?} must extend preopen_root {:?}", - base_key, - preopen_root - ); - - for seg in rel_path.split('/') { - if seg.is_empty() || seg == "." { - continue; - } - if seg == ".." { - if stack.len() <= floor { - return Err(FsError::NotPermitted); - } - stack.pop(); - continue; - } - validate_segment(seg)?; - stack.push(seg); - } - - let resolved = stack.join("/"); - if resolved.len() > MAX_KEY_LEN { - return Err(FsError::NameTooLong); - } - Ok(resolved) -} - -/// Append a trailing `/` to a key for use as an explicit directory marker. -/// Returns the empty string unchanged (the bucket root needs no marker). -pub fn dir_marker_key(key: &str) -> String { - if key.is_empty() || key.ends_with('/') { - key.to_string() - } else { - let mut s = String::with_capacity(key.len() + 1); - s.push_str(key); - s.push('/'); - s - } -} - -/// Strip a single trailing `/` if present. Used when normalising directory -/// keys back to their plain form. -pub fn strip_trailing_slash(key: &str) -> &str { - key.strip_suffix('/').unwrap_or(key) -} - -/// Split a resolved key into `(parent_dir_key, basename)`. -/// -/// `parent_dir_key` is empty if the key has no parent (top-level under the -/// preopen root). `basename` is empty for the empty key. -pub fn split_parent(key: &str) -> (&str, &str) { - match key.rfind('/') { - Some(idx) => (&key[..idx], &key[idx + 1..]), - None => ("", key), - } -} - -/// Join a `bucket_prefix` with an internal mount-relative key to get the key -/// that should be sent on the wire to the backend. -/// -/// - Empty `prefix` → `key` unchanged. -/// - Empty `key` → `prefix` unchanged. -/// - Otherwise: `"{prefix}/{key}"` with no double slash. -pub fn join_prefix(prefix: &str, key: &str) -> String { - let prefix = strip_trailing_slash(prefix); - if prefix.is_empty() { - return key.to_string(); - } - if key.is_empty() { - return prefix.to_string(); - } - let mut s = String::with_capacity(prefix.len() + 1 + key.len()); - s.push_str(prefix); - s.push('/'); - s.push_str(key); - s -} - -/// Validate a stored symlink-target body. Targets are themselves treated as -/// guest paths and resolved at follow-time, but the byte-level body must be -/// short and free of illegal characters. -pub fn validate_symlink_target(target: &str) -> FsResult<()> { - if target.is_empty() { - return Err(FsError::Invalid("empty symlink target")); - } - if target.len() > MAX_SYMLINK_TARGET_LEN { - return Err(FsError::NameTooLong); - } - for &b in target.as_bytes() { - if b == 0 { - return Err(FsError::IllegalByteSequence); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ---------- validate_segment ---------- - - #[test] - fn validate_segment_rejects_reserved() { - assert!(matches!(validate_segment(""), Err(FsError::Invalid(_)))); - assert!(matches!(validate_segment("."), Err(FsError::Invalid(_)))); - assert!(matches!(validate_segment(".."), Err(FsError::Invalid(_)))); - } - - #[test] - fn validate_segment_rejects_long_names() { - let s = "a".repeat(MAX_SEGMENT_LEN + 1); - assert!(matches!(validate_segment(&s), Err(FsError::NameTooLong))); - // Boundary: exactly MAX_SEGMENT_LEN is fine. - let s = "a".repeat(MAX_SEGMENT_LEN); - assert!(validate_segment(&s).is_ok()); - } - - #[test] - fn validate_segment_rejects_nul_and_control() { - assert!(matches!( - validate_segment("foo\0bar"), - Err(FsError::IllegalByteSequence) - )); - assert!(matches!( - validate_segment("foo\nbar"), - Err(FsError::IllegalByteSequence) - )); - assert!(matches!( - validate_segment("foo\x7Fbar"), - Err(FsError::IllegalByteSequence) - )); - } - - #[test] - fn validate_segment_accepts_unicode() { - assert!(validate_segment("héllo.txt").is_ok()); - assert!(validate_segment("файл").is_ok()); - assert!(validate_segment("文件.json").is_ok()); - } - - // ---------- resolve_at: basic shapes ---------- - - #[test] - fn resolve_simple_relative() { - assert_eq!(resolve_at("", "", "foo.txt").unwrap(), "foo.txt"); - assert_eq!(resolve_at("", "", "a/b/c.txt").unwrap(), "a/b/c.txt"); - } - - #[test] - fn resolve_extends_base() { - assert_eq!( - resolve_at("", "dir/sub", "file.txt").unwrap(), - "dir/sub/file.txt" - ); - } - - #[test] - fn resolve_collapses_double_slashes() { - assert_eq!(resolve_at("", "", "a//b///c").unwrap(), "a/b/c"); - } - - #[test] - fn resolve_skips_dot_components() { - assert_eq!(resolve_at("", "dir", "./file").unwrap(), "dir/file"); - assert_eq!(resolve_at("", "dir", "a/./b/./c").unwrap(), "dir/a/b/c"); - assert_eq!(resolve_at("", "dir", ".").unwrap(), "dir"); - } - - #[test] - fn resolve_pops_on_dotdot() { - assert_eq!( - resolve_at("", "dir/sub", "../sibling").unwrap(), - "dir/sibling" - ); - assert_eq!(resolve_at("", "a/b/c", "../../x").unwrap(), "a/x"); - } - - #[test] - fn resolve_empty_path_returns_base() { - assert_eq!(resolve_at("", "dir/sub", "").unwrap(), "dir/sub"); - } - - // ---------- resolve_at: containment ---------- - - #[test] - fn rejects_absolute_path() { - assert!(matches!( - resolve_at("", "dir", "/etc/passwd"), - Err(FsError::NotPermitted) - )); - assert!(matches!( - resolve_at("data", "data/foo", "/escape"), - Err(FsError::NotPermitted) - )); - } - - #[test] - fn rejects_dotdot_escape_above_preopen_root() { - // preopen at "data", descriptor at "data/foo", trying to escape: - assert!(matches!( - resolve_at("data", "data/foo", "../../escape"), - Err(FsError::NotPermitted) - )); - // But popping just back to the preopen root is fine: - assert_eq!(resolve_at("data", "data/foo", "..").unwrap(), "data"); - // And popping to a sibling within the preopen is fine: - assert_eq!( - resolve_at("data", "data/foo/bar", "../sib").unwrap(), - "data/foo/sib" - ); - } - - #[test] - fn dotdot_at_preopen_root_is_rejected() { - assert!(matches!( - resolve_at("", "", ".."), - Err(FsError::NotPermitted) - )); - assert!(matches!( - resolve_at("data", "data", ".."), - Err(FsError::NotPermitted) - )); - } - - // ---------- resolve_at: limits ---------- - - #[test] - fn rejects_segment_too_long() { - let long = "a".repeat(MAX_SEGMENT_LEN + 1); - assert!(matches!( - resolve_at("", "", &long), - Err(FsError::NameTooLong) - )); - } - - #[test] - fn rejects_total_key_too_long() { - // Build a path where each segment is valid but the total exceeds 1024. - let seg = "a".repeat(MAX_SEGMENT_LEN); // 255 - // 5*255 + 4 separators = 1279 > 1024 - let path = std::iter::repeat_n(seg.as_str(), 5) - .collect::>() - .join("/"); - assert!(matches!( - resolve_at("", "", &path), - Err(FsError::NameTooLong) - )); - } - - #[test] - fn rejects_nul_byte_in_segment() { - assert!(matches!( - resolve_at("", "", "foo/bar\0baz"), - Err(FsError::IllegalByteSequence) - )); - } - - // ---------- helpers ---------- - - #[test] - fn dir_marker_appends_slash() { - assert_eq!(dir_marker_key("foo"), "foo/"); - assert_eq!(dir_marker_key("foo/"), "foo/"); - assert_eq!(dir_marker_key(""), ""); - } - - #[test] - fn strip_trailing_slash_works() { - assert_eq!(strip_trailing_slash("foo/"), "foo"); - assert_eq!(strip_trailing_slash("foo"), "foo"); - assert_eq!(strip_trailing_slash(""), ""); - assert_eq!(strip_trailing_slash("/"), ""); - } - - #[test] - fn split_parent_basic() { - assert_eq!(split_parent("a/b/c"), ("a/b", "c")); - assert_eq!(split_parent("foo"), ("", "foo")); - assert_eq!(split_parent(""), ("", "")); - } - - #[test] - fn join_prefix_handles_empties_and_trailing_slashes() { - assert_eq!(join_prefix("", "foo/bar"), "foo/bar"); - assert_eq!(join_prefix("data", ""), "data"); - assert_eq!(join_prefix("data", "foo"), "data/foo"); - assert_eq!(join_prefix("data/", "foo"), "data/foo"); // trailing slash collapsed - assert_eq!(join_prefix("", ""), ""); - } - - // ---------- validate_symlink_target ---------- - - #[test] - fn symlink_target_rejects_empty_and_long() { - assert!(matches!( - validate_symlink_target(""), - Err(FsError::Invalid(_)) - )); - let s = "a".repeat(MAX_SYMLINK_TARGET_LEN + 1); - assert!(matches!( - validate_symlink_target(&s), - Err(FsError::NameTooLong) - )); - } - - #[test] - fn symlink_target_rejects_nul() { - assert!(matches!( - validate_symlink_target("a\0b"), - Err(FsError::IllegalByteSequence) - )); - } - - #[test] - fn symlink_target_accepts_relative_and_absolute() { - // Validation is purely byte-level; relative-vs-absolute is decided at - // follow-time by the resolver. - assert!(validate_symlink_target("../sibling").is_ok()); - assert!(validate_symlink_target("/abs/path").is_ok()); - assert!(validate_symlink_target("plain.txt").is_ok()); - } -} diff --git a/crates/s3fs-core/src/store/blkptr.rs b/crates/s3fs-core/src/store/blkptr.rs deleted file mode 100644 index 0b61574..0000000 --- a/crates/s3fs-core/src/store/blkptr.rs +++ /dev/null @@ -1,445 +0,0 @@ -//! `BlkPtr` — the block pointer, and the edge of the Merkle tree. -//! -//! A block pointer says *where* a block lives, *how* to decrypt it, and *what -//! it should hash to*. Because a parent block is itself a block, and its own -//! pointer carries its own checksum, the checksums chain all the way to the -//! root record. Verifying the root therefore verifies every byte beneath it. -//! -//! ## Location, not content -//! -//! The address is a [`Dva`] — a `(txg, slab, offset, len)` tuple naming a byte -//! range inside an immutable slab object — rather than a content hash. This is -//! the ZFS model, and it is chosen deliberately over content-addressed -//! `blocks/` keys: every block dirtied in a transaction group is packed -//! into a handful of slabs, so a commit costs a couple of PUTs no matter how -//! many blocks changed. One PUT per 128 KiB block would make small-file -//! workloads (a SQLite page write, say) cost a round trip each. -//! -//! ## Wire layout — 128 bytes, big-endian -//! -//! ```text -//! offset size field -//! 0 8 dva.txg transaction group that allocated the slab -//! 8 2 dva.slab slab index within that txg -//! 10 4 dva.offset byte offset within the slab -//! 14 4 dva.len stored length (ciphertext + AEAD tag) -//! 18 4 logical_len plaintext length -//! 22 1 level 0 = data, >=1 = indirect -//! 23 1 flags low nibble: compression algorithm -//! 24 4 fill non-hole pointers beneath this one -//! 28 8 birth_txg txg that wrote this block -//! 36 12 nonce AEAD nonce (txg || block_seq) -//! 48 32 checksum BLAKE3-256 of the stored bytes -//! 80 48 reserved must be zero -//! ``` -//! -//! Big-endian throughout, matching the AAD and nonce encodings, so a hex dump -//! of a slab reads in the same order as the struct. -//! -//! An all-zero pointer is a *hole*: a sparse region that reads as zeros and -//! occupies no storage. Holes are why growing a file with `set_size` is free. - -use crate::crypto::aead::TAG_LEN; -use crate::crypto::{BlockNonce, Hash256}; -use crate::errors::{FsError, FsResult}; - -/// Encoded size of a block pointer. -pub const BLKPTR_LEN: usize = 128; - -/// Offset of the `reserved` tail within the encoding. -const RESERVED_OFFSET: usize = 80; - -/// Deepest indirect tree we will decode. -/// -/// The binding case is the *smallest* record size, not the default. At 128 KiB -/// the fan-out is 1024 and six levels already address more than any real file; -/// but at the 4 KiB minimum the fan-out is only 32, and a file spanning the -/// full `u64` byte range needs 2^52 blocks, hence `1 + ceil(52 / 5) = 12` -/// levels. The limit is set from that worst case with one level to spare. -/// -/// Anything deeper is a corrupt or hostile pointer. Rejecting it at decode -/// bounds recursion in the tree walker. -pub const MAX_LEVEL: u8 = 12; - -/// Largest block we will decode, as a sanity bound on `logical_len`. -/// -/// Without this, a corrupt length field turns into a multi-gigabyte allocation -/// on the read path — a trivial denial of service from anyone who can write to -/// the bucket. -pub const MAX_BLOCK_LEN: u32 = 16 * 1024 * 1024; - -/// How many block pointers fit in one block of the given size. -pub const fn blkptrs_per_block(block_size: usize) -> usize { - block_size / BLKPTR_LEN -} - -/// Compression applied before encryption. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum Compression { - #[default] - None = 0, -} - -impl Compression { - fn from_u8(v: u8) -> FsResult { - match v { - 0 => Ok(Compression::None), - _ => Err(FsError::Integrity("blkptr: unknown compression algorithm")), - } - } -} - -/// Data Virtual Address — a byte range inside an immutable slab object. -/// -/// Hashable because it is the block cache's key: a DVA is allocated once and -/// never reused, so it identifies an immutable byte range for all time. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct Dva { - /// Transaction group whose slab holds this block. - pub txg: u64, - /// Slab index within that txg. - pub slab: u16, - /// Byte offset within the slab. - pub offset: u32, - /// Stored length: ciphertext plus AEAD tag. - pub len: u32, -} - -impl Dva { - /// Byte range within the slab, for a ranged GET. - pub fn range(&self) -> std::ops::Range { - let start = u64::from(self.offset); - start..start + u64::from(self.len) - } -} - -/// A pointer to one block. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct BlkPtr { - pub dva: Dva, - /// Plaintext length. Differs from `dva.len` by the AEAD tag and, once - /// compression is implemented, by the compression ratio. - pub logical_len: u32, - /// 0 for data blocks, ≥1 for indirect blocks. - pub level: u8, - pub compression: Compression, - /// Number of non-hole pointers at or beneath this one. Lets a sparse file - /// report its allocated size, and lets a tree walk skip empty subtrees - /// without reading them. - pub fill: u32, - /// Transaction group that wrote this block. Part of the AEAD AAD, so it - /// cannot be altered without invalidating the block. - pub birth_txg: u64, - pub nonce: BlockNonce, - /// BLAKE3-256 of the stored (encrypted) bytes. - pub checksum: Hash256, -} - -impl BlkPtr { - /// The all-zero pointer: a sparse hole. - pub const HOLE: BlkPtr = BlkPtr { - dva: Dva { - txg: 0, - slab: 0, - offset: 0, - len: 0, - }, - logical_len: 0, - level: 0, - compression: Compression::None, - fill: 0, - birth_txg: 0, - nonce: BlockNonce::from_bytes([0u8; 12]), - checksum: Hash256::ZERO, - }; - - /// `true` if this pointer addresses nothing — a sparse region that reads - /// as zeros. - pub fn is_hole(&self) -> bool { - self.dva.len == 0 && self.checksum.is_zero() - } - - pub fn encode(&self) -> [u8; BLKPTR_LEN] { - let mut b = [0u8; BLKPTR_LEN]; - b[0..8].copy_from_slice(&self.dva.txg.to_be_bytes()); - b[8..10].copy_from_slice(&self.dva.slab.to_be_bytes()); - b[10..14].copy_from_slice(&self.dva.offset.to_be_bytes()); - b[14..18].copy_from_slice(&self.dva.len.to_be_bytes()); - b[18..22].copy_from_slice(&self.logical_len.to_be_bytes()); - b[22] = self.level; - b[23] = self.compression as u8; - b[24..28].copy_from_slice(&self.fill.to_be_bytes()); - b[28..36].copy_from_slice(&self.birth_txg.to_be_bytes()); - b[36..48].copy_from_slice(self.nonce.as_bytes()); - b[48..80].copy_from_slice(self.checksum.as_bytes()); - // b[80..128] stays zero. - b - } - - /// Decode and validate. - /// - /// Validation is strict — every rejection here is a pointer that could - /// only have come from corruption or tampering, and the cost of accepting - /// one is either wrong data or an unbounded allocation. Unknown bits are - /// rejected rather than ignored; the root record's `format_version` is the - /// mechanism for evolving this layout. - pub fn decode(bytes: &[u8]) -> FsResult { - if bytes.len() != BLKPTR_LEN { - return Err(FsError::Integrity("blkptr: wrong length")); - } - if bytes[RESERVED_OFFSET..].iter().any(|&b| b != 0) { - return Err(FsError::Integrity("blkptr: reserved bytes not zero")); - } - if bytes.iter().all(|&b| b == 0) { - return Ok(BlkPtr::HOLE); - } - - let ptr = BlkPtr { - dva: Dva { - txg: u64::from_be_bytes(bytes[0..8].try_into().expect("8 bytes")), - slab: u16::from_be_bytes(bytes[8..10].try_into().expect("2 bytes")), - offset: u32::from_be_bytes(bytes[10..14].try_into().expect("4 bytes")), - len: u32::from_be_bytes(bytes[14..18].try_into().expect("4 bytes")), - }, - logical_len: u32::from_be_bytes(bytes[18..22].try_into().expect("4 bytes")), - level: bytes[22], - compression: Compression::from_u8(bytes[23])?, - fill: u32::from_be_bytes(bytes[24..28].try_into().expect("4 bytes")), - birth_txg: u64::from_be_bytes(bytes[28..36].try_into().expect("8 bytes")), - nonce: BlockNonce::from_bytes(bytes[36..48].try_into().expect("12 bytes")), - checksum: Hash256::from_bytes(bytes[48..80].try_into().expect("32 bytes")), - }; - - // A non-hole must actually point somewhere. Catching a partially-zero - // pointer here stops it being mistaken for a hole, which would silently - // turn tampering into a file full of zeros. - if ptr.checksum.is_zero() { - return Err(FsError::Integrity("blkptr: non-hole with zero checksum")); - } - if ptr.dva.len < TAG_LEN as u32 { - return Err(FsError::Integrity("blkptr: stored length below AEAD tag")); - } - if ptr.level > MAX_LEVEL { - return Err(FsError::Integrity("blkptr: level exceeds maximum")); - } - if ptr.logical_len > MAX_BLOCK_LEN || ptr.dva.len > MAX_BLOCK_LEN { - return Err(FsError::Integrity("blkptr: block length exceeds maximum")); - } - // Uncompressed blocks are exactly plaintext plus tag. This catches a - // length field edited to over- or under-read the slab. - if ptr.compression == Compression::None && ptr.dva.len != ptr.logical_len + TAG_LEN as u32 { - return Err(FsError::Integrity( - "blkptr: stored length inconsistent with logical length", - )); - } - if ptr.dva.offset.checked_add(ptr.dva.len).is_none() { - return Err(FsError::Integrity("blkptr: slab range overflows")); - } - Ok(ptr) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> BlkPtr { - BlkPtr { - dva: Dva { - txg: 0x0102_0304_0506_0708, - slab: 0x090a, - offset: 0x0b0c_0d0e, - len: 4096 + TAG_LEN as u32, - }, - logical_len: 4096, - level: 2, - compression: Compression::None, - fill: 7, - birth_txg: 0x1112_1314_1516_1718, - nonce: BlockNonce::new(0x1112_1314_1516_1718, 5), - checksum: Hash256::of(b"block bytes"), - } - } - - #[test] - fn encoding_is_exactly_128_bytes() { - assert_eq!(sample().encode().len(), BLKPTR_LEN); - assert_eq!(BLKPTR_LEN, 128); - } - - #[test] - fn round_trips() { - let p = sample(); - assert_eq!(BlkPtr::decode(&p.encode()).unwrap(), p); - } - - #[test] - fn hole_round_trips_and_is_all_zero() { - let encoded = BlkPtr::HOLE.encode(); - assert!(encoded.iter().all(|&b| b == 0)); - assert_eq!(BlkPtr::decode(&encoded).unwrap(), BlkPtr::HOLE); - assert!(BlkPtr::HOLE.is_hole()); - assert!(!sample().is_hole()); - } - - #[test] - fn field_offsets_are_stable() { - // The on-disk layout is a compatibility surface: if these move, - // existing filesystems become unreadable. Pin them explicitly rather - // than trusting the round-trip test, which would pass even if two - // fields swapped places. - let b = sample().encode(); - assert_eq!(&b[0..8], &0x0102_0304_0506_0708u64.to_be_bytes()); - assert_eq!(&b[8..10], &0x090au16.to_be_bytes()); - assert_eq!(&b[10..14], &0x0b0c_0d0eu32.to_be_bytes()); - assert_eq!(&b[14..18], &(4096u32 + TAG_LEN as u32).to_be_bytes()); - assert_eq!(&b[18..22], &4096u32.to_be_bytes()); - assert_eq!(b[22], 2); - assert_eq!(b[23], 0); - assert_eq!(&b[24..28], &7u32.to_be_bytes()); - assert_eq!(&b[28..36], &0x1112_1314_1516_1718u64.to_be_bytes()); - assert_eq!( - &b[36..48], - BlockNonce::new(0x1112_1314_1516_1718, 5).as_bytes() - ); - assert_eq!(&b[48..80], Hash256::of(b"block bytes").as_bytes()); - assert!(b[80..].iter().all(|&x| x == 0)); - } - - #[test] - fn rejects_wrong_length() { - assert!(BlkPtr::decode(&[0u8; 127]).is_err()); - assert!(BlkPtr::decode(&[0u8; 129]).is_err()); - assert!(BlkPtr::decode(&[]).is_err()); - } - - #[test] - fn rejects_nonzero_reserved_bytes() { - let mut b = sample().encode(); - b[100] = 1; - assert!(matches!( - BlkPtr::decode(&b), - Err(FsError::Integrity("blkptr: reserved bytes not zero")) - )); - } - - /// The dangerous near-miss: a pointer zeroed everywhere that matters would - /// read as a hole and silently produce a file of zeros. It must be an - /// error instead. - #[test] - fn rejects_partially_zeroed_pointer() { - let mut b = sample().encode(); - b[48..80].fill(0); // wipe the checksum, leave the address - assert!(matches!( - BlkPtr::decode(&b), - Err(FsError::Integrity("blkptr: non-hole with zero checksum")) - )); - } - - #[test] - fn rejects_stored_length_below_tag() { - let mut p = sample(); - p.dva.len = TAG_LEN as u32 - 1; - p.logical_len = 0; - assert!(matches!( - BlkPtr::decode(&p.encode()), - Err(FsError::Integrity("blkptr: stored length below AEAD tag")) - )); - } - - #[test] - fn rejects_length_inconsistency() { - // Over-read: claim more stored bytes than the plaintext accounts for. - let mut p = sample(); - p.dva.len += 1; - assert!(BlkPtr::decode(&p.encode()).is_err()); - - // Under-read. - let mut p = sample(); - p.logical_len += 1; - assert!(BlkPtr::decode(&p.encode()).is_err()); - } - - #[test] - fn rejects_excessive_level() { - let mut p = sample(); - p.level = MAX_LEVEL + 1; - assert!(matches!( - BlkPtr::decode(&p.encode()), - Err(FsError::Integrity("blkptr: level exceeds maximum")) - )); - - p.level = MAX_LEVEL; - assert!(BlkPtr::decode(&p.encode()).is_ok()); - } - - #[test] - fn rejects_absurd_block_length() { - let mut p = sample(); - p.logical_len = MAX_BLOCK_LEN + 1; - p.dva.len = p.logical_len + TAG_LEN as u32; - assert!(matches!( - BlkPtr::decode(&p.encode()), - Err(FsError::Integrity("blkptr: block length exceeds maximum")) - )); - } - - #[test] - fn rejects_unknown_compression() { - let mut b = sample().encode(); - b[23] = 9; - assert!(matches!( - BlkPtr::decode(&b), - Err(FsError::Integrity("blkptr: unknown compression algorithm")) - )); - } - - #[test] - fn rejects_slab_range_overflow() { - let mut p = sample(); - p.dva.offset = u32::MAX; - assert!(matches!( - BlkPtr::decode(&p.encode()), - Err(FsError::Integrity("blkptr: slab range overflows")) - )); - } - - #[test] - fn dva_range_covers_the_stored_bytes() { - let p = sample(); - assert_eq!( - p.dva.range(), - 0x0b0c_0d0e..0x0b0c_0d0e + 4096 + TAG_LEN as u64 - ); - } - - #[test] - fn fanout_at_default_record_size() { - assert_eq!(blkptrs_per_block(128 * 1024), 1024); - assert_eq!(blkptrs_per_block(4096), 32); - } - - /// Every single-byte corruption of a valid pointer must either decode to - /// something different (and so fail its checksum against the parent) or be - /// rejected outright. What must never happen is decoding back to the - /// original pointer, which would mean a byte of the encoding is ignored. - #[test] - fn no_byte_of_the_encoding_is_ignored() { - let p = sample(); - let encoded = p.encode(); - for i in 0..BLKPTR_LEN { - for bit in [0x01u8, 0x80] { - let mut b = encoded; - b[i] ^= bit; - match BlkPtr::decode(&b) { - Err(_) => {} - Ok(decoded) => assert_ne!( - decoded, p, - "flipping bit {bit:#x} of byte {i} decoded back to the original" - ), - } - } - } - } -} diff --git a/crates/s3fs-core/src/store/blockstore.rs b/crates/s3fs-core/src/store/blockstore.rs deleted file mode 100644 index e82fd7f..0000000 --- a/crates/s3fs-core/src/store/blockstore.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! `BlockStore` — the verified read path and the slab-writing commit path. -//! -//! Everything above this module deals in `BlkPtr`s and plaintext blocks and -//! never touches the backend directly. Everything below is ranged GETs and -//! PUTs against immutable objects. -//! -//! The read path is fail-closed by construction: there is no way to obtain a -//! block's bytes except through [`BlockStore::read_block`], which verifies the -//! BLAKE3 checksum against the parent's pointer and then AEAD-opens with -//! position-binding AAD. A caller cannot opt out, and no partial result -//! escapes on failure. - -use std::sync::Arc; - -use bytes::Bytes; -use futures::stream::{FuturesUnordered, StreamExt}; - -use crate::backend::{Backend, ObjectLock, PutBlobInput}; -use crate::crypto::KeyMaterial; -use crate::errors::{FsError, FsResult}; - -use super::blkptr::BlkPtr; -use super::cache::{BlockCache, BlockKey, CacheStats}; -use super::config::StoreConfig; -use super::slab::{verify_and_open, FinishedSlab}; - -/// Reads and writes verified, encrypted blocks against a backend. -#[derive(Debug)] -pub struct BlockStore { - data: Arc, - keys: Arc, - config: Arc, - cache: BlockCache, -} - -impl BlockStore { - pub fn new(data: Arc, keys: Arc, config: Arc) -> Self { - let cache = BlockCache::new(config.block_cache_bytes); - Self { - data, - keys, - config, - cache, - } - } - - pub fn config(&self) -> &StoreConfig { - &self.config - } - - pub fn keys(&self) -> &KeyMaterial { - &self.keys - } - - pub fn cache_stats(&self) -> CacheStats { - self.cache.stats() - } - - /// Read, verify, and decrypt the block a pointer addresses. - /// - /// A hole yields `logical_len` zero bytes without any I/O — that is what - /// makes sparse files free. - /// - /// `objid` and `block_index` must be the position the caller *expects* the - /// block to occupy, not a position read back from the store. They are fed - /// into the AEAD's additional data, so passing what the store told us - /// would defeat the check entirely. - pub async fn read_block(&self, ptr: &BlkPtr, objid: u64, block_index: u64) -> FsResult { - if ptr.is_hole() { - return Ok(Bytes::from(vec![0u8; ptr.logical_len as usize])); - } - let cache_key = BlockKey::new(ptr.dva, objid, block_index); - if let Some(hit) = self.cache.get(&cache_key) { - return Ok(hit); - } - - let key = self.config.slab_key(ptr.dva.txg, ptr.dva.slab); - let got = self - .data - .get_blob(&key, Some(ptr.dva.range())) - .await - .map_err(|e| match e { - // A missing slab under a root that references it means the - // data was deleted out from under us. That is a denial of - // service, not a forgery — but it is still fatal for this read - // and must not be confused with "the file does not exist". - FsError::NotFound => FsError::Integrity("slab referenced by root is missing"), - other => other, - })?; - - let plaintext = Bytes::from(verify_and_open( - &self.keys, - ptr, - objid, - block_index, - &got.body, - )?); - self.cache.insert(cache_key, plaintext.clone()); - Ok(plaintext) - } - - /// PUT every slab of a transaction group, in parallel. - /// - /// Returns only once all of them are durable. The commit protocol depends - /// on this: the root record must not be published until every block it - /// references is readable, or a crash between the two would leave a root - /// pointing at blocks that do not exist. - pub async fn write_slabs(&self, txg: u64, slabs: Vec) -> FsResult<()> { - if slabs.is_empty() { - return Ok(()); - } - let limit = self.config.max_parallel_slab_puts; - let mut in_flight = FuturesUnordered::new(); - let mut queue = slabs.into_iter(); - - loop { - while in_flight.len() < limit { - match queue.next() { - Some(slab) => in_flight.push(self.put_one_slab(txg, slab)), - None => break, - } - } - match in_flight.next().await { - Some(result) => result?, - None => return Ok(()), - } - } - } - - async fn put_one_slab(&self, txg: u64, slab: FinishedSlab) -> FsResult<()> { - let key = self.config.slab_key(txg, slab.index); - // Slabs carry no retention: they live in the unlocked data bucket, so - // dead copy-on-write blocks stay reclaimable. Deleting one is a - // detectable denial of service, never a rollback — the rollback - // guarantee lives entirely in the locked root records. - self.data - .put_blob(PutBlobInput::new(key, slab.body)) - .await - .map(|_| ()) - } - - /// Retention to stamp on a root record, resolved against wall-clock now. - pub fn root_retention(&self) -> Option { - self.config.root_retention.map(|d| ObjectLock { - mode: crate::backend::ObjectLockMode::Compliance, - retain_until: std::time::SystemTime::now() + d, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::crypto::MasterSecret; - use crate::store::slab::SlabWriter; - - fn store(config: StoreConfig) -> (Arc, BlockStore) { - let backend = Arc::new(MemoryBackend::new()); - let keys = - Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([4u8; 32]), [0u8; 16]).unwrap()); - let bs = BlockStore::new(backend.clone(), keys, Arc::new(config)); - (backend, bs) - } - - /// Seal `blocks` into slabs, PUT them, and return their pointers. - async fn commit_blocks( - bs: &BlockStore, - txg: u64, - blocks: &[(u64, u64, Vec)], - ) -> Vec { - let mut w = SlabWriter::new(txg, bs.config()); - let ptrs: Vec<_> = blocks - .iter() - .map(|(objid, idx, data)| w.write_block(bs.keys(), *objid, 0, *idx, 1, data).unwrap()) - .collect(); - bs.write_slabs(txg, w.finish().unwrap()).await.unwrap(); - ptrs - } - - #[tokio::test] - async fn round_trip_through_the_backend() { - let (_b, bs) = store(StoreConfig::default()); - let data = vec![0x5au8; 8192]; - let ptrs = commit_blocks(&bs, 1, &[(10, 0, data.clone())]).await; - - assert_eq!(bs.read_block(&ptrs[0], 10, 0).await.unwrap(), data); - } - - #[tokio::test] - async fn holes_read_as_zeros_without_io() { - let (backend, bs) = store(StoreConfig::default()); - let mut hole = BlkPtr::HOLE; - hole.logical_len = 4096; - - let got = bs.read_block(&hole, 1, 0).await.unwrap(); - assert_eq!(got.len(), 4096); - assert!(got.iter().all(|&b| b == 0)); - assert_eq!(backend.object_count(), 0, "a hole must not touch storage"); - } - - #[tokio::test] - async fn second_read_is_served_from_cache() { - let (_b, bs) = store(StoreConfig::default()); - let ptrs = commit_blocks(&bs, 1, &[(10, 0, vec![1u8; 4096])]).await; - - bs.read_block(&ptrs[0], 10, 0).await.unwrap(); - bs.read_block(&ptrs[0], 10, 0).await.unwrap(); - let stats = bs.cache_stats(); - assert_eq!((stats.hits, stats.misses), (1, 1)); - } - - #[tokio::test] - async fn many_blocks_pack_into_few_objects() { - // The economic claim behind slab packing: a txg dirtying many blocks - // costs a handful of PUTs, not one per block. - let (backend, bs) = store(StoreConfig::default()); - let blocks: Vec<_> = (0..64u64).map(|i| (7, i, vec![i as u8; 4096])).collect(); - let ptrs = commit_blocks(&bs, 3, &blocks).await; - - assert_eq!(ptrs.len(), 64); - assert_eq!(backend.object_count(), 1, "64 blocks should be one slab"); - - for (i, p) in ptrs.iter().enumerate() { - assert_eq!( - bs.read_block(p, 7, i as u64).await.unwrap(), - vec![i as u8; 4096] - ); - } - } - - #[tokio::test] - async fn multiple_slabs_are_all_written() { - let cfg = StoreConfig { - record_size: 4096, - slab_max_bytes: 8300, - ..Default::default() - }; - let (backend, bs) = store(cfg); - let blocks: Vec<_> = (0..7u64).map(|i| (1, i, vec![i as u8; 4096])).collect(); - let ptrs = commit_blocks(&bs, 1, &blocks).await; - - assert_eq!(backend.object_count(), 4, "7 blocks, 2 per slab"); - for (i, p) in ptrs.iter().enumerate() { - assert_eq!( - bs.read_block(p, 1, i as u64).await.unwrap(), - vec![i as u8; 4096] - ); - } - } - - #[tokio::test] - async fn empty_commit_writes_nothing() { - let (backend, bs) = store(StoreConfig::default()); - bs.write_slabs(1, vec![]).await.unwrap(); - assert_eq!(backend.object_count(), 0); - } - - // ---- adversarial reads ------------------------------------------------- - - #[tokio::test] - async fn tampered_slab_bytes_are_rejected() { - let (backend, bs) = store(StoreConfig::default()); - let ptrs = commit_blocks(&bs, 1, &[(10, 0, vec![0xaau8; 4096])]).await; - - // Rewrite the slab with corrupted contents, as a bucket operator could. - let key = bs.config().slab_key(1, 0); - let mut body = backend.get_blob(&key, None).await.unwrap().body.to_vec(); - body[0] ^= 0xff; - backend - .put_blob(PutBlobInput::new(key, Bytes::from(body))) - .await - .unwrap(); - - assert!(matches!( - bs.read_block(&ptrs[0], 10, 0).await, - Err(FsError::Integrity(_)) - )); - } - - #[tokio::test] - async fn deleted_slab_reports_integrity_not_missing_file() { - let (backend, bs) = store(StoreConfig::default()); - let ptrs = commit_blocks(&bs, 1, &[(10, 0, vec![1u8; 4096])]).await; - backend - .delete_blob(&bs.config().slab_key(1, 0)) - .await - .unwrap(); - - // Must not look like "no such file" — the root says this block exists, - // so its absence is the store failing us, not the guest asking for - // something that was never there. - assert!(matches!( - bs.read_block(&ptrs[0], 10, 0).await, - Err(FsError::Integrity("slab referenced by root is missing")) - )); - } - - #[tokio::test] - async fn a_block_cannot_be_read_at_the_wrong_position() { - let (_b, bs) = store(StoreConfig::default()); - let ptrs = commit_blocks(&bs, 1, &[(10, 5, vec![9u8; 1024])]).await; - - assert!(bs.read_block(&ptrs[0], 10, 5).await.is_ok()); - assert!(bs.read_block(&ptrs[0], 11, 5).await.is_err(), "wrong objid"); - assert!(bs.read_block(&ptrs[0], 10, 6).await.is_err(), "wrong index"); - } - - /// Point a pointer at a neighbouring block's bytes. Both blocks are - /// genuine; only the checksum in the pointer disagrees. - #[tokio::test] - async fn a_pointer_cannot_be_aimed_at_another_block() { - let (_b, bs) = store(StoreConfig::default()); - let ptrs = commit_blocks( - &bs, - 1, - &[(10, 0, vec![1u8; 1024]), (10, 1, vec![2u8; 1024])], - ) - .await; - - let mut forged = ptrs[0]; - forged.dva.offset = ptrs[1].dva.offset; - assert!(matches!( - bs.read_block(&forged, 10, 0).await, - Err(FsError::Integrity("block: checksum mismatch")) - )); - } - - #[tokio::test] - async fn a_block_from_another_filesystem_is_rejected() { - let (backend, bs) = store(StoreConfig::default()); - let ptrs = commit_blocks(&bs, 1, &[(10, 0, vec![1u8; 1024])]).await; - - // Same bucket, same pointers, different key material. - let other_keys = Arc::new( - KeyMaterial::derive(&MasterSecret::from_bytes([99u8; 32]), [0u8; 16]).unwrap(), - ); - let other = BlockStore::new(backend, other_keys, Arc::new(StoreConfig::default())); - assert!(matches!( - other.read_block(&ptrs[0], 10, 0).await, - Err(FsError::Integrity(_)) - )); - } - - #[tokio::test] - async fn root_retention_is_compliance_mode_and_in_the_future() { - let (_b, bs) = store(StoreConfig::default()); - let lock = bs.root_retention().expect("default config locks roots"); - assert_eq!(lock.mode, crate::backend::ObjectLockMode::Compliance); - assert!(lock.retain_until > std::time::SystemTime::now()); - - let (_b, unlocked) = store(StoreConfig { - root_retention: None, - ..Default::default() - }); - assert!(unlocked.root_retention().is_none()); - } -} diff --git a/crates/s3fs-core/src/store/cache.rs b/crates/s3fs-core/src/store/cache.rs deleted file mode 100644 index c49ba2e..0000000 --- a/crates/s3fs-core/src/store/cache.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Byte-accounted LRU cache of decrypted blocks. -//! -//! Entries never go stale and need no invalidation. Copy-on-write gives us -//! that for free: a modified block is written to a *new* address, so a cached -//! entry describes bytes that can never change, and the old entry simply ages -//! out. -//! -//! Entries are stored decrypted, so a hit skips the GET, the BLAKE3 pass, and -//! the AEAD open. That is safe because the cache lives in enclave memory, -//! which is the same trust boundary the plaintext already occupies. -//! -//! ## Why the key includes the position -//! -//! A hit bypasses verification, so the key must carry everything verification -//! would have checked. The address alone is not enough: keyed only by -//! [`Dva`], a block legitimately read at one position would then be served -//! from cache when a *forged* indirect block claimed it belonged at another — -//! exactly the relocation attack the AEAD's additional data exists to stop. -//! Including `(objid, block_index)` in the key turns that into a miss, and the -//! miss then fails the real check. -//! -//! No legitimate tree ever holds one address at two positions: a block sealed -//! for one position cannot be opened at another, so the duplicate keys this -//! permits in principle do not arise in practice. - -use bytes::Bytes; -use hashlink::LinkedHashMap; -use parking_lot::Mutex; - -use super::blkptr::Dva; - -/// Cache key: an immutable address plus the tree position it was verified at. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct BlockKey { - pub dva: Dva, - pub objid: u64, - pub block_index: u64, -} - -impl BlockKey { - pub fn new(dva: Dva, objid: u64, block_index: u64) -> Self { - Self { - dva, - objid, - block_index, - } - } -} - -/// Cache statistics, for tests and metrics. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct CacheStats { - pub hits: u64, - pub misses: u64, - pub evictions: u64, -} - -#[derive(Debug)] -struct Inner { - used: u64, - /// Insertion order is age; entries move to the back on access. - lru: LinkedHashMap, - stats: CacheStats, -} - -/// In-memory cache of decrypted blocks. -#[derive(Debug)] -pub struct BlockCache { - limit: u64, - inner: Mutex, -} - -impl BlockCache { - pub fn new(limit_bytes: u64) -> Self { - Self { - limit: limit_bytes, - inner: Mutex::new(Inner { - used: 0, - lru: LinkedHashMap::new(), - stats: CacheStats::default(), - }), - } - } - - pub fn get(&self, key: &BlockKey) -> Option { - let mut g = self.inner.lock(); - match g.lru.raw_entry_mut().from_key(key) { - hashlink::linked_hash_map::RawEntryMut::Occupied(mut e) => { - e.to_back(); - let v = e.get().clone(); - g.stats.hits += 1; - Some(v) - } - hashlink::linked_hash_map::RawEntryMut::Vacant(_) => { - g.stats.misses += 1; - None - } - } - } - - /// Insert, evicting oldest-first until the entry fits. - /// - /// A block larger than the whole budget is simply not cached, rather than - /// evicting everything to make room for something that will immediately be - /// evicted itself. - pub fn insert(&self, key: BlockKey, block: Bytes) { - let len = block.len() as u64; - if len > self.limit { - return; - } - let mut g = self.inner.lock(); - if let Some(old) = g.lru.insert(key, block) { - g.used -= old.len() as u64; - } - g.used += len; - while g.used > self.limit { - match g.lru.pop_front() { - Some((_, evicted)) => { - g.used -= evicted.len() as u64; - g.stats.evictions += 1; - } - None => break, - } - } - } - - pub fn used_bytes(&self) -> u64 { - self.inner.lock().used - } - - pub fn len(&self) -> usize { - self.inner.lock().lru.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub fn stats(&self) -> CacheStats { - self.inner.lock().stats - } - - #[cfg(test)] - fn contains(&self, key: &BlockKey) -> bool { - self.inner.lock().lru.contains_key(key) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn dva(offset: u32, len: u32) -> Dva { - Dva { - txg: 1, - slab: 0, - offset, - len, - } - } - - /// A key at the canonical position, for tests that only care about LRU - /// behaviour rather than about position binding. - fn k(offset: u32, len: u32) -> BlockKey { - BlockKey::new(dva(offset, len), 1, 0) - } - - fn block(n: usize) -> Bytes { - Bytes::from(vec![0u8; n]) - } - - #[test] - fn hit_and_miss() { - let c = BlockCache::new(1024); - assert!(c.get(&k(0, 10)).is_none()); - c.insert(k(0, 10), block(10)); - assert_eq!(c.get(&k(0, 10)).unwrap().len(), 10); - assert_eq!( - c.stats(), - CacheStats { - hits: 1, - misses: 1, - evictions: 0 - } - ); - } - - #[test] - fn accounts_bytes() { - let c = BlockCache::new(1024); - c.insert(k(0, 100), block(100)); - c.insert(k(100, 200), block(200)); - assert_eq!(c.used_bytes(), 300); - assert_eq!(c.len(), 2); - } - - #[test] - fn evicts_oldest_first() { - let c = BlockCache::new(300); - c.insert(k(0, 100), block(100)); - c.insert(k(1, 100), block(100)); - c.insert(k(2, 100), block(100)); - assert_eq!(c.used_bytes(), 300); - - c.insert(k(3, 100), block(100)); - assert_eq!(c.used_bytes(), 300); - assert!(!c.contains(&k(0, 100)), "oldest should have been evicted"); - assert!(c.contains(&k(3, 100))); - assert_eq!(c.stats().evictions, 1); - } - - #[test] - fn access_refreshes_recency() { - let c = BlockCache::new(300); - c.insert(k(0, 100), block(100)); - c.insert(k(1, 100), block(100)); - c.insert(k(2, 100), block(100)); - - // Touch the oldest so it is no longer the eviction candidate. - assert!(c.get(&k(0, 100)).is_some()); - c.insert(k(3, 100), block(100)); - - assert!(c.contains(&k(0, 100)), "touched entry must survive"); - assert!(!c.contains(&k(1, 100))); - } - - #[test] - fn oversized_block_is_not_cached() { - let c = BlockCache::new(100); - c.insert(k(0, 200), block(200)); - assert!(c.is_empty()); - assert_eq!(c.used_bytes(), 0); - } - - #[test] - fn reinsert_does_not_double_count() { - let c = BlockCache::new(1024); - c.insert(k(0, 100), block(100)); - c.insert(k(0, 100), block(100)); - assert_eq!(c.used_bytes(), 100); - assert_eq!(c.len(), 1); - } - - /// Copy-on-write is what makes this cache safe without invalidation: - /// two different versions of a block occupy two different addresses, so a - /// stale entry is unreachable rather than wrong. - #[test] - fn rewritten_blocks_get_distinct_keys() { - let c = BlockCache::new(1024); - let at = |txg| { - BlockKey::new( - Dva { - txg, - slab: 0, - offset: 0, - len: 100, - }, - 1, - 0, - ) - }; - c.insert(at(1), Bytes::from_static(b"old")); - c.insert(at(2), Bytes::from_static(b"new")); - assert_eq!(c.get(&at(1)).unwrap(), Bytes::from_static(b"old")); - assert_eq!(c.get(&at(2)).unwrap(), Bytes::from_static(b"new")); - } - - /// Regression guard. Keyed on the address alone, a block cached after a - /// legitimate read would be served straight back when a forged pointer - /// claimed it belonged somewhere else — silently skipping the AEAD's - /// position check. The position must be part of the key. - #[test] - fn the_same_address_at_a_different_position_is_a_miss() { - let c = BlockCache::new(1024); - let d = dva(0, 100); - c.insert(BlockKey::new(d, 10, 5), Bytes::from_static(b"payload")); - - assert!(c.get(&BlockKey::new(d, 10, 5)).is_some()); - assert!(c.get(&BlockKey::new(d, 11, 5)).is_none(), "wrong objid"); - assert!(c.get(&BlockKey::new(d, 10, 6)).is_none(), "wrong index"); - } - - #[test] - fn zero_budget_caches_nothing() { - let c = BlockCache::new(0); - c.insert(k(0, 10), block(10)); - assert!(c.is_empty()); - } -} diff --git a/crates/s3fs-core/src/store/config.rs b/crates/s3fs-core/src/store/config.rs deleted file mode 100644 index 81d6570..0000000 --- a/crates/s3fs-core/src/store/config.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Store tuning knobs and key-space layout. -//! -//! Kept separate from [`crate::config::Config`] while the old path→key engine -//! still exists; the two merge when that engine is removed. - -use std::time::Duration; - -use crate::errors::{FsError, FsResult}; - -/// Default record size. Matches ZFS's default for the same reasons: large -/// enough that per-block overhead (128-byte pointer, 16-byte AEAD tag, one -/// BLAKE3 pass) is negligible, small enough that rewriting one record after a -/// small write does not amplify badly. -pub const DEFAULT_RECORD_SIZE: usize = 128 * 1024; - -/// Default cap on a single slab object. A commit writes `ceil(dirty / -/// SLAB_MAX)` objects, so this trades PUT count against how much has to be -/// re-sent if one PUT fails. -pub const DEFAULT_SLAB_MAX_BYTES: usize = 64 * 1024 * 1024; - -/// Smallest and largest permitted record sizes. -const MIN_RECORD_SIZE: usize = 4 * 1024; -const MAX_RECORD_SIZE: usize = 1024 * 1024; - -/// How long a committed root is retained when per-object Object Lock is used. -/// Ten years, per the deployment contract. -pub const DEFAULT_ROOT_RETENTION: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60); - -/// Store configuration. -#[derive(Debug, Clone)] -pub struct StoreConfig { - /// Key prefix inside both buckets. Empty for bucket root. - pub prefix: String, - /// Size of a level-0 data block and of an indirect block. Power of two. - pub record_size: usize, - /// Cap on one slab object. - pub slab_max_bytes: usize, - /// Byte budget for the decrypted-block cache. - pub block_cache_bytes: u64, - /// Max concurrent slab PUTs during a commit. - pub max_parallel_slab_puts: usize, - /// How many links of the root hash chain to verify at mount. - /// - /// 1 is enough to detect a spliced history at the tip, which is the live - /// attack; walking the whole chain is an audit operation, not a mount-time - /// one, because it costs one GET per root and roots are never deleted. - pub root_chain_verify_depth: u32, - /// Retention to stamp on root records, or `None` to rely on the bucket's - /// default retention configuration. - pub root_retention: Option, -} - -impl Default for StoreConfig { - fn default() -> Self { - Self { - prefix: String::new(), - record_size: DEFAULT_RECORD_SIZE, - slab_max_bytes: DEFAULT_SLAB_MAX_BYTES, - block_cache_bytes: 64 * 1024 * 1024, - max_parallel_slab_puts: 8, - root_chain_verify_depth: 1, - root_retention: Some(DEFAULT_ROOT_RETENTION), - } - } -} - -impl StoreConfig { - pub fn validate(&self) -> FsResult<()> { - if !self.record_size.is_power_of_two() { - return Err(FsError::Invalid("record_size must be a power of two")); - } - if self.record_size < MIN_RECORD_SIZE || self.record_size > MAX_RECORD_SIZE { - return Err(FsError::Invalid("record_size out of range")); - } - if self.slab_max_bytes < self.record_size { - return Err(FsError::Invalid("slab_max_bytes below record_size")); - } - // A slab offset is a u32 in the block pointer. - if self.slab_max_bytes > u32::MAX as usize { - return Err(FsError::Invalid("slab_max_bytes exceeds u32 addressing")); - } - if self.max_parallel_slab_puts == 0 { - return Err(FsError::Invalid("max_parallel_slab_puts must be nonzero")); - } - Ok(()) - } - - /// `log2(record_size)`, as stored in a dnode. - pub fn record_shift(&self) -> u8 { - self.record_size.trailing_zeros() as u8 - } - - /// Key of a slab object. Zero-padded hex so S3's lexicographic ordering - /// matches numeric ordering, which is what makes a range scan over txgs - /// meaningful. - pub fn slab_key(&self, txg: u64, slab: u16) -> String { - format!("{}slabs/{:016x}/{:04x}", self.prefix, txg, slab) - } - - /// Key of a root record. - pub fn root_key(&self, seq: u64) -> String { - format!("{}roots/{:016x}", self.prefix, seq) - } - - /// Key of the (untrusted) tip hint. - pub fn root_hint_key(&self) -> String { - format!("{}roots/latest", self.prefix) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_validate() { - StoreConfig::default().validate().unwrap(); - } - - #[test] - fn record_shift_matches_record_size() { - let mut c = StoreConfig::default(); - assert_eq!(c.record_shift(), 17); // 128 KiB - c.record_size = 4096; - assert_eq!(c.record_shift(), 12); - assert_eq!(1usize << c.record_shift(), c.record_size); - } - - #[test] - fn rejects_non_power_of_two_record_size() { - let c = StoreConfig { - record_size: 100_000, - ..Default::default() - }; - assert!(c.validate().is_err()); - } - - #[test] - fn rejects_out_of_range_record_size() { - for size in [1024, 2 * 1024 * 1024] { - let c = StoreConfig { - record_size: size, - ..Default::default() - }; - assert!(c.validate().is_err(), "accepted record_size {size}"); - } - } - - #[test] - fn rejects_slab_smaller_than_a_record() { - let c = StoreConfig { - slab_max_bytes: 4096, - ..Default::default() - }; - assert!(c.validate().is_err()); - } - - /// A slab offset is a `u32` in the block pointer, so a slab larger than - /// 4 GiB would silently truncate addresses. - #[test] - fn rejects_slab_beyond_u32_addressing() { - let c = StoreConfig { - slab_max_bytes: u32::MAX as usize + 1, - ..Default::default() - }; - assert!(c.validate().is_err()); - } - - #[test] - fn keys_are_zero_padded_and_sort_numerically() { - let c = StoreConfig::default(); - assert_eq!(c.root_key(1), "roots/0000000000000001"); - assert_eq!(c.root_key(0x2a), "roots/000000000000002a"); - assert_eq!(c.slab_key(9, 3), "slabs/0000000000000009/0003"); - - // The property that matters: lexicographic order == numeric order, - // so a LIST or a range scan sees roots in sequence order. - let mut keys: Vec<_> = [300u64, 2, 41, 1].iter().map(|&s| c.root_key(s)).collect(); - keys.sort(); - assert_eq!( - keys, - vec![ - c.root_key(1), - c.root_key(2), - c.root_key(41), - c.root_key(300) - ] - ); - } - - #[test] - fn prefix_is_applied_to_every_key() { - let c = StoreConfig { - prefix: "tenant-a/".into(), - ..Default::default() - }; - assert!(c.root_key(1).starts_with("tenant-a/roots/")); - assert!(c.slab_key(1, 0).starts_with("tenant-a/slabs/")); - assert!(c.root_hint_key().starts_with("tenant-a/roots/")); - } -} diff --git a/crates/s3fs-core/src/store/dir.rs b/crates/s3fs-core/src/store/dir.rs deleted file mode 100644 index 319fb63..0000000 --- a/crates/s3fs-core/src/store/dir.rs +++ /dev/null @@ -1,978 +0,0 @@ -//! Directories — a two-level B+tree of name-ordered entries. -//! -//! A directory object's data blocks are: -//! -//! ```text -//! block 0 index: [(separator, leaf block), …] sorted by separator -//! block 1..N leaves: sorted runs of (name, objid, kind) -//! ``` -//! -//! Lookup binary-searches the index for the leaf whose separator range covers -//! the name, then binary-searches that leaf: two block reads, both of which -//! stay cached for a hot directory. Inserting dirties one leaf plus the index, -//! and a leaf that overflows splits in half — so an insert costs three blocks -//! and their indirect path, not a rewrite of the directory. -//! -//! Because leaves are name-ordered and the index is separator-ordered, -//! `read_dir` is a concatenation with no sort step. -//! -//! ## Why not extendible hashing -//! -//! The plan called for an extendible hash table, whose cheap-doubling property -//! depends on many slots pointing at one shared bucket block. That is -//! unavailable here: [`crate::crypto::BlockAad`] binds every block to its -//! block index, so one physical block genuinely cannot be read from two -//! positions — which is exactly the property that stops an attacker relocating -//! blocks, and not one worth weakening for a directory layout. Sharing would -//! have to be expressed indirectly anyway, through a level of mapping; a -//! separator index is that mapping, and it also yields sorted iteration for -//! free. - -use std::collections::{BTreeMap, BTreeSet}; - -use crate::errors::{FsError, FsResult}; - -use super::blockstore::BlockStore; -use super::dnode::{Dnode, DnodeKind}; -use super::indirect::{commit_object, read_raw_block}; -use super::slab::SlabWriter; - -const INDEX_MAGIC: u32 = 0x4449_5831; // "DIX1" -const LEAF_MAGIC: u32 = 0x444C_4631; // "DLF1" - -/// The index always lives at data block 0; leaves start at 1. -const INDEX_BLOCK: u64 = 0; - -/// Longest permitted entry name, matching the path validator. -pub const MAX_NAME_LEN: usize = 255; - -/// One directory entry. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Dirent { - pub name: String, - pub objid: u64, - pub kind: DnodeKind, -} - -impl Dirent { - fn encoded_len(&self) -> usize { - 12 + self.name.len() - } - - fn encode(&self, out: &mut Vec) { - out.extend_from_slice(&(self.name.len() as u16).to_be_bytes()); - out.push(self.kind as u8); - out.push(0); // reserved - out.extend_from_slice(&self.objid.to_be_bytes()); - out.extend_from_slice(self.name.as_bytes()); - } -} - -/// Cursor over a byte slice that refuses to read past the end. -struct Reader<'a> { - buf: &'a [u8], - pos: usize, -} - -impl<'a> Reader<'a> { - fn new(buf: &'a [u8]) -> Self { - Reader { buf, pos: 0 } - } - - fn take(&mut self, n: usize, what: &'static str) -> FsResult<&'a [u8]> { - let end = self.pos.checked_add(n).ok_or(FsError::Integrity(what))?; - let slice = self - .buf - .get(self.pos..end) - .ok_or(FsError::Integrity(what))?; - self.pos = end; - Ok(slice) - } - - fn u16(&mut self, what: &'static str) -> FsResult { - Ok(u16::from_be_bytes( - self.take(2, what)?.try_into().expect("2 bytes"), - )) - } - - fn u32(&mut self, what: &'static str) -> FsResult { - Ok(u32::from_be_bytes( - self.take(4, what)?.try_into().expect("4 bytes"), - )) - } - - fn u64(&mut self, what: &'static str) -> FsResult { - Ok(u64::from_be_bytes( - self.take(8, what)?.try_into().expect("8 bytes"), - )) - } -} - -fn encode_leaf(entries: &[Dirent]) -> Vec { - let cap = 8 + entries.iter().map(Dirent::encoded_len).sum::(); - let mut out = Vec::with_capacity(cap); - out.extend_from_slice(&LEAF_MAGIC.to_be_bytes()); - out.extend_from_slice(&(entries.len() as u32).to_be_bytes()); - for e in entries { - e.encode(&mut out); - } - out -} - -fn leaf_encoded_len(entries: &[Dirent]) -> usize { - 8 + entries.iter().map(Dirent::encoded_len).sum::() -} - -fn decode_leaf(buf: &[u8]) -> FsResult> { - const BAD: &str = "dir: malformed leaf block"; - let mut r = Reader::new(buf); - if r.u32(BAD)? != LEAF_MAGIC { - return Err(FsError::Integrity("dir: bad leaf magic")); - } - let count = r.u32(BAD)? as usize; - let mut out: Vec = Vec::with_capacity(count.min(4096)); - for _ in 0..count { - let name_len = r.u16(BAD)? as usize; - let kind = r.take(1, BAD)?[0]; - if r.take(1, BAD)?[0] != 0 { - return Err(FsError::Integrity("dir: entry reserved byte not zero")); - } - let objid = r.u64(BAD)?; - let name = std::str::from_utf8(r.take(name_len, BAD)?) - .map_err(|_| FsError::Integrity("dir: entry name is not valid UTF-8"))? - .to_string(); - out.push(Dirent { - name, - objid, - kind: decode_kind(kind)?, - }); - } - if r.pos != buf.len() { - return Err(FsError::Integrity("dir: trailing bytes in leaf block")); - } - // Strictly increasing order is what makes binary search sound, and it also - // rules out duplicate names — which a forged block could otherwise use to - // make one name resolve to two different objects. - if out.windows(2).any(|w| w[0].name >= w[1].name) { - return Err(FsError::Integrity("dir: leaf entries are not sorted")); - } - Ok(out) -} - -fn decode_kind(v: u8) -> FsResult { - Ok(match v { - 1 => DnodeKind::File, - 2 => DnodeKind::Dir, - 3 => DnodeKind::Symlink, - _ => return Err(FsError::Integrity("dir: entry has a non-linkable kind")), - }) -} - -/// A leaf and the smallest name that may live in it. -/// -/// The separator is a boundary, not necessarily a name that exists: entries -/// are deleted without disturbing it, so an empty leaf keeps its place in the -/// ordering. -#[derive(Debug, Clone, PartialEq, Eq)] -struct LeafRef { - sep: String, - block: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DirIndex { - entry_count: u64, - leaves: Vec, -} - -impl DirIndex { - /// A directory with one empty leaf whose separator matches everything. - fn empty() -> Self { - DirIndex { - entry_count: 0, - leaves: vec![LeafRef { - sep: String::new(), - block: 1, - }], - } - } - - fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(16 + self.leaves.len() * 24); - out.extend_from_slice(&INDEX_MAGIC.to_be_bytes()); - out.extend_from_slice(&(self.leaves.len() as u32).to_be_bytes()); - out.extend_from_slice(&self.entry_count.to_be_bytes()); - for l in &self.leaves { - out.extend_from_slice(&l.block.to_be_bytes()); - out.extend_from_slice(&(l.sep.len() as u16).to_be_bytes()); - out.extend_from_slice(l.sep.as_bytes()); - } - out - } - - fn decode(buf: &[u8]) -> FsResult { - const BAD: &str = "dir: malformed index block"; - let mut r = Reader::new(buf); - if r.u32(BAD)? != INDEX_MAGIC { - return Err(FsError::Integrity("dir: bad index magic")); - } - let leaf_count = r.u32(BAD)? as usize; - let entry_count = r.u64(BAD)?; - let mut leaves = Vec::with_capacity(leaf_count.min(4096)); - for _ in 0..leaf_count { - let block = r.u64(BAD)?; - let sep_len = r.u16(BAD)? as usize; - let sep = std::str::from_utf8(r.take(sep_len, BAD)?) - .map_err(|_| FsError::Integrity("dir: separator is not valid UTF-8"))? - .to_string(); - leaves.push(LeafRef { sep, block }); - } - if r.pos != buf.len() { - return Err(FsError::Integrity("dir: trailing bytes in index block")); - } - if leaves.is_empty() { - return Err(FsError::Integrity("dir: index has no leaves")); - } - if !leaves[0].sep.is_empty() { - return Err(FsError::Integrity("dir: first separator is not empty")); - } - if leaves.windows(2).any(|w| w[0].sep >= w[1].sep) { - return Err(FsError::Integrity("dir: separators are not sorted")); - } - if leaves.iter().any(|l| l.block == INDEX_BLOCK) { - return Err(FsError::Integrity( - "dir: leaf collides with the index block", - )); - } - let mut seen = BTreeSet::new(); - if !leaves.iter().all(|l| seen.insert(l.block)) { - return Err(FsError::Integrity("dir: duplicate leaf block")); - } - Ok(DirIndex { - entry_count, - leaves, - }) - } - - /// Position of the leaf that owns `name`: the last one whose separator - /// does not exceed it. - fn position_of(&self, name: &str) -> usize { - match self.leaves.binary_search_by(|l| l.sep.as_str().cmp(name)) { - Ok(i) => i, - // `partition_point`-style: the insertion point is one past the - // owning leaf. Index 0 has an empty separator, so this never - // underflows. - Err(i) => i - 1, - } - } - - fn next_block(&self) -> u64 { - self.leaves.iter().map(|l| l.block).max().unwrap_or(0) + 1 - } -} - -/// A staged set of changes to one directory. -/// -/// Leaves are loaded on demand, so a lookup or a single insert touches one -/// leaf regardless of how large the directory is. Call [`DirTxn::finish`] to -/// stage the modified blocks into a transaction group. -#[derive(Debug)] -pub struct DirTxn<'a> { - bs: &'a BlockStore, - dnode: Dnode, - index: DirIndex, - loaded: BTreeMap>, - dirty: BTreeSet, -} - -impl<'a> DirTxn<'a> { - /// Open a directory for reading and modification. - pub async fn load(bs: &'a BlockStore, dnode: &Dnode) -> FsResult> { - if dnode.kind != DnodeKind::Dir { - return Err(FsError::NotDirectory); - } - let (index, dirty) = match read_raw_block(bs, dnode, INDEX_BLOCK).await? { - Some(raw) => (DirIndex::decode(&raw)?, BTreeSet::new()), - // A directory that has never been written has no blocks at all. - // Synthesise the empty shape and mark it dirty so the first commit - // materialises it. - None => (DirIndex::empty(), BTreeSet::from([INDEX_BLOCK, 1])), - }; - let mut txn = DirTxn { - bs, - dnode: dnode.clone(), - index, - loaded: BTreeMap::new(), - dirty, - }; - if txn.dirty.contains(&1) { - txn.loaded.insert(1, Vec::new()); - } - Ok(txn) - } - - pub fn entry_count(&self) -> u64 { - self.index.entry_count - } - - async fn leaf(&mut self, block: u64) -> FsResult<&mut Vec> { - if !self.loaded.contains_key(&block) { - let entries = match read_raw_block(self.bs, &self.dnode, block).await? { - Some(raw) => decode_leaf(&raw)?, - None => Vec::new(), - }; - self.loaded.insert(block, entries); - } - Ok(self.loaded.get_mut(&block).expect("just inserted")) - } - - /// Find one entry by name. - pub async fn lookup(&mut self, name: &str) -> FsResult> { - let block = self.index.leaves[self.index.position_of(name)].block; - let entries = self.leaf(block).await?; - Ok(entries - .binary_search_by(|e| e.name.as_str().cmp(name)) - .ok() - .map(|i| entries[i].clone())) - } - - /// Add an entry. Fails with [`FsError::AlreadyExists`] if the name is taken. - pub async fn insert(&mut self, entry: Dirent) -> FsResult<()> { - if entry.name.is_empty() || entry.name.len() > MAX_NAME_LEN { - return Err(FsError::NameTooLong); - } - if entry.kind == DnodeKind::Free || entry.kind == DnodeKind::DnodeArray { - return Err(FsError::Invalid("directory entry has a non-linkable kind")); - } - - let record_size = self.dnode.record_size(); - let pos = self.index.position_of(&entry.name); - let block = self.index.leaves[pos].block; - let entries = self.leaf(block).await?; - - let at = match entries.binary_search_by(|e| e.name.cmp(&entry.name)) { - Ok(_) => return Err(FsError::AlreadyExists), - Err(at) => at, - }; - entries.insert(at, entry); - let overflowed = leaf_encoded_len(entries) > record_size; - - self.dirty.insert(block); - self.index.entry_count += 1; - if overflowed { - self.split_leaf(pos, record_size)?; - } - Ok(()) - } - - /// Split the leaf at index `pos` in half. - /// - /// The new leaf takes the upper half and its separator is that half's first - /// name, so every existing name still resolves to the leaf that holds it. - fn split_leaf(&mut self, pos: usize, record_size: usize) -> FsResult<()> { - let block = self.index.leaves[pos].block; - let entries = self.loaded.get_mut(&block).expect("leaf is loaded"); - if entries.len() < 2 { - // A single entry that does not fit cannot be split apart. With - // names capped at 255 bytes and records at 4 KiB or more, this is - // unreachable; refusing beats looping. - return Err(FsError::FileTooLarge); - } - let mid = entries.len() / 2; - let upper: Vec = entries.split_off(mid); - let sep = upper[0].name.clone(); - let new_block = self.index.next_block(); - - self.loaded.insert(new_block, upper); - self.dirty.insert(new_block); - self.index.leaves.insert( - pos + 1, - LeafRef { - sep, - block: new_block, - }, - ); - - if self.index.encode().len() > record_size { - return Err(FsError::FileTooLarge); - } - Ok(()) - } - - /// Remove an entry by name, returning it. - /// - /// An emptied leaf keeps its slot rather than being merged away. Its - /// separator still partitions the name space correctly, it costs eight - /// stored bytes, and a later insert in that range refills it. - pub async fn remove(&mut self, name: &str) -> FsResult { - let block = self.index.leaves[self.index.position_of(name)].block; - let entries = self.leaf(block).await?; - let at = entries - .binary_search_by(|e| e.name.as_str().cmp(name)) - .map_err(|_| FsError::NotFound)?; - let removed = entries.remove(at); - self.dirty.insert(block); - self.index.entry_count -= 1; - Ok(removed) - } - - /// Every entry, in name order. - pub async fn list(&mut self) -> FsResult> { - let blocks: Vec = self.index.leaves.iter().map(|l| l.block).collect(); - let mut out = Vec::with_capacity(self.index.entry_count as usize); - for block in blocks { - out.extend_from_slice(self.leaf(block).await?); - } - Ok(out) - } - - /// `true` if the directory holds no entries. Used by `rmdir`. - pub fn is_empty(&self) -> bool { - self.index.entry_count == 0 - } - - /// Stage every modified block into `writer` and return the updated dnode. - pub async fn finish(mut self, writer: &mut SlabWriter) -> FsResult { - if self.dirty.is_empty() { - return Ok(self.dnode); - } - self.dirty.insert(INDEX_BLOCK); - - let mut blocks: BTreeMap> = BTreeMap::new(); - for block in &self.dirty { - let bytes = if *block == INDEX_BLOCK { - self.index.encode() - } else { - encode_leaf(self.loaded.get(block).map(Vec::as_slice).unwrap_or(&[])) - }; - blocks.insert(*block, bytes); - } - - // Blocks 0..=highest leaf; the geometry only needs to cover them. - let nblocks = self.index.next_block(); - let new_size = nblocks * self.dnode.record_size() as u64; - commit_object(self.bs, writer, &self.dnode, blocks, new_size).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::crypto::{KeyMaterial, MasterSecret}; - use crate::store::config::StoreConfig; - use std::sync::Arc; - - fn store(record_size: usize) -> BlockStore { - let backend = Arc::new(MemoryBackend::new()); - let keys = - Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([8u8; 32]), [0u8; 16]).unwrap()); - BlockStore::new( - backend, - keys, - Arc::new(StoreConfig { - record_size, - ..Default::default() - }), - ) - } - - fn new_dir(record_shift: u8) -> Dnode { - Dnode::new(7, DnodeKind::Dir, record_shift, 0) - } - - fn entry(name: &str, objid: u64) -> Dirent { - Dirent { - name: name.to_string(), - objid, - kind: DnodeKind::File, - } - } - - /// Apply a closure to the directory and commit it as one transaction group. - async fn apply(bs: &BlockStore, txg: u64, dnode: &Dnode, f: F) -> Dnode - where - F: for<'x> FnOnce( - &'x mut DirTxn<'_>, - ) -> std::pin::Pin< - Box> + 'x>, - >, - { - let mut txn = DirTxn::load(bs, dnode).await.unwrap(); - f(&mut txn).await.unwrap(); - let mut w = SlabWriter::new(txg, bs.config()); - let out = txn.finish(&mut w).await.unwrap(); - bs.write_slabs(txg, w.finish().unwrap()).await.unwrap(); - out - } - - async fn insert_all(bs: &BlockStore, txg: u64, dnode: &Dnode, names: &[&str]) -> Dnode { - let mut txn = DirTxn::load(bs, dnode).await.unwrap(); - for (i, n) in names.iter().enumerate() { - txn.insert(entry(n, 100 + i as u64)).await.unwrap(); - } - let mut w = SlabWriter::new(txg, bs.config()); - let out = txn.finish(&mut w).await.unwrap(); - bs.write_slabs(txg, w.finish().unwrap()).await.unwrap(); - out - } - - async fn names_of(bs: &BlockStore, dnode: &Dnode) -> Vec { - let mut txn = DirTxn::load(bs, dnode).await.unwrap(); - txn.list() - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .collect() - } - - #[tokio::test] - async fn a_fresh_directory_is_empty() { - let bs = store(4096); - let d = new_dir(12); - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - assert!(txn.is_empty()); - assert_eq!(txn.list().await.unwrap(), vec![]); - assert_eq!(txn.lookup("anything").await.unwrap(), None); - } - - #[tokio::test] - async fn insert_then_look_up() { - let bs = store(4096); - let d = insert_all(&bs, 1, &new_dir(12), &["hello.txt"]).await; - - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - assert_eq!( - txn.lookup("hello.txt").await.unwrap(), - Some(entry("hello.txt", 100)) - ); - assert_eq!(txn.lookup("missing").await.unwrap(), None); - assert_eq!(txn.entry_count(), 1); - } - - #[tokio::test] - async fn entries_come_back_in_name_order() { - let bs = store(4096); - let d = insert_all(&bs, 1, &new_dir(12), &["zebra", "apple", "Mango", "banana"]).await; - assert_eq!( - names_of(&bs, &d).await, - vec!["Mango", "apple", "banana", "zebra"], - "byte order, so uppercase sorts first" - ); - } - - #[tokio::test] - async fn duplicate_names_are_rejected() { - let bs = store(4096); - let d = insert_all(&bs, 1, &new_dir(12), &["dup"]).await; - - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - assert!(matches!( - txn.insert(entry("dup", 999)).await, - Err(FsError::AlreadyExists) - )); - } - - #[tokio::test] - async fn remove_takes_the_entry_out() { - let bs = store(4096); - let d = insert_all(&bs, 1, &new_dir(12), &["a", "b", "c"]).await; - let d = apply(&bs, 2, &d, |t| { - Box::pin(async move { - t.remove("b").await?; - Ok(()) - }) - }) - .await; - - assert_eq!(names_of(&bs, &d).await, vec!["a", "c"]); - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - assert_eq!(txn.lookup("b").await.unwrap(), None); - assert_eq!(txn.entry_count(), 2); - assert!(matches!(txn.remove("b").await, Err(FsError::NotFound))); - } - - #[tokio::test] - async fn removing_everything_leaves_an_empty_directory() { - let bs = store(4096); - let d = insert_all(&bs, 1, &new_dir(12), &["only"]).await; - let d = apply(&bs, 2, &d, |t| { - Box::pin(async move { - t.remove("only").await?; - Ok(()) - }) - }) - .await; - - let txn = DirTxn::load(&bs, &d).await.unwrap(); - assert!(txn.is_empty(), "rmdir depends on this"); - assert_eq!(names_of(&bs, &d).await, Vec::::new()); - } - - #[tokio::test] - async fn names_are_validated() { - let bs = store(4096); - let d = new_dir(12); - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - - assert!(matches!( - txn.insert(entry("", 1)).await, - Err(FsError::NameTooLong) - )); - let long = "x".repeat(MAX_NAME_LEN + 1); - assert!(matches!( - txn.insert(entry(&long, 1)).await, - Err(FsError::NameTooLong) - )); - assert!(txn - .insert(entry(&"x".repeat(MAX_NAME_LEN), 1)) - .await - .is_ok()); - } - - #[tokio::test] - async fn a_directory_entry_cannot_point_at_a_free_slot() { - let bs = store(4096); - let d = new_dir(12); - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - for kind in [DnodeKind::Free, DnodeKind::DnodeArray] { - assert!(txn - .insert(Dirent { - name: "x".into(), - objid: 5, - kind - }) - .await - .is_err()); - } - } - - // ---- splitting --------------------------------------------------------- - - #[tokio::test] - async fn a_leaf_that_overflows_splits() { - let bs = store(4096); - // ~40 bytes per entry, so a 4 KiB leaf holds roughly 100. - let names: Vec = (0..300).map(|i| format!("file-{i:04}-padding")).collect(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - let d = insert_all(&bs, 1, &new_dir(12), &refs).await; - - let mut expected = names.clone(); - expected.sort(); - assert_eq!(names_of(&bs, &d).await, expected); - - // Every name must still be individually reachable through the index. - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - assert!(txn.index.leaves.len() > 1, "the leaf should have split"); - for n in &names { - assert!(txn.lookup(n).await.unwrap().is_some(), "lost {n}"); - } - assert_eq!(txn.entry_count(), 300); - } - - #[tokio::test] - async fn splits_survive_across_transaction_groups() { - let bs = store(4096); - let mut d = new_dir(12); - let mut all = Vec::new(); - for txg in 1..=6u64 { - let names: Vec = (0..50) - .map(|i| format!("g{txg}-entry-{i:03}-padding")) - .collect(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - d = insert_all(&bs, txg, &d, &refs).await; - all.extend(names); - } - - all.sort(); - assert_eq!(names_of(&bs, &d).await, all); - - let mut txn = DirTxn::load(&bs, &d).await.unwrap(); - assert_eq!(txn.entry_count(), 300); - for n in &all { - assert!(txn.lookup(n).await.unwrap().is_some(), "lost {n}"); - } - } - - #[tokio::test] - async fn an_emptied_leaf_still_accepts_inserts() { - let bs = store(4096); - let names: Vec = (0..300).map(|i| format!("file-{i:04}-padding")).collect(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - let d = insert_all(&bs, 1, &new_dir(12), &refs).await; - - // Empty one leaf's worth of the name space, then refill part of it. - let d = apply(&bs, 2, &d, |t| { - Box::pin(async move { - for i in 0..300 { - t.remove(&format!("file-{i:04}-padding")).await?; - } - Ok(()) - }) - }) - .await; - assert!(DirTxn::load(&bs, &d).await.unwrap().is_empty()); - - let d = insert_all(&bs, 3, &d, &["file-0100-padding", "file-0200-padding"]).await; - assert_eq!( - names_of(&bs, &d).await, - vec!["file-0100-padding", "file-0200-padding"] - ); - } - - // ---- copy-on-write ----------------------------------------------------- - - #[tokio::test] - async fn a_superseded_directory_still_reads() { - let bs = store(4096); - let old = insert_all(&bs, 1, &new_dir(12), &["a", "b"]).await; - let new = insert_all(&bs, 2, &old, &["c"]).await; - - assert_eq!(names_of(&bs, &new).await, vec!["a", "b", "c"]); - assert_eq!( - names_of(&bs, &old).await, - vec!["a", "b"], - "the previous version of the directory must be unchanged" - ); - } - - #[tokio::test] - async fn a_no_op_transaction_does_not_rewrite_the_directory() { - let bs = store(4096); - let d = insert_all(&bs, 1, &new_dir(12), &["a"]).await; - - let txn = DirTxn::load(&bs, &d).await.unwrap(); - let mut w = SlabWriter::new(2, bs.config()); - let same = txn.finish(&mut w).await.unwrap(); - assert_eq!(same.blkptr, d.blkptr); - assert_eq!(w.block_count(), 0); - } - - #[tokio::test] - async fn loading_a_non_directory_is_refused() { - let bs = store(4096); - let f = Dnode::new(3, DnodeKind::File, 12, 0); - assert!(matches!( - DirTxn::load(&bs, &f).await, - Err(FsError::NotDirectory) - )); - } - - // ---- encoding and tamper resistance ------------------------------------ - - #[test] - fn leaf_round_trips() { - let entries = vec![ - entry("alpha", 1), - Dirent { - name: "beta".into(), - objid: 2, - kind: DnodeKind::Dir, - }, - Dirent { - name: "gamma".into(), - objid: 3, - kind: DnodeKind::Symlink, - }, - ]; - let encoded = encode_leaf(&entries); - assert_eq!(encoded.len(), leaf_encoded_len(&entries)); - assert_eq!(decode_leaf(&encoded).unwrap(), entries); - } - - #[test] - fn empty_leaf_round_trips() { - assert_eq!(decode_leaf(&encode_leaf(&[])).unwrap(), vec![]); - } - - #[test] - fn index_round_trips() { - let idx = DirIndex { - entry_count: 9, - leaves: vec![ - LeafRef { - sep: String::new(), - block: 1, - }, - LeafRef { - sep: "m".into(), - block: 2, - }, - ], - }; - assert_eq!(DirIndex::decode(&idx.encode()).unwrap(), idx); - } - - /// Unsorted entries would break binary search, and duplicates would let one - /// name resolve to two objects depending on which the search happened to - /// land on. - #[test] - fn unsorted_or_duplicated_leaf_entries_are_rejected() { - let unsorted = encode_leaf(&[entry("b", 1), entry("a", 2)]); - assert!(matches!( - decode_leaf(&unsorted), - Err(FsError::Integrity("dir: leaf entries are not sorted")) - )); - - let duplicated = encode_leaf(&[entry("a", 1), entry("a", 2)]); - assert!(decode_leaf(&duplicated).is_err()); - } - - #[test] - fn malformed_blocks_are_rejected() { - assert!(decode_leaf(&[]).is_err()); - assert!(decode_leaf(&[0u8; 4]).is_err()); - assert!(DirIndex::decode(&[]).is_err()); - - // Wrong magic. - let mut leaf = encode_leaf(&[entry("a", 1)]); - leaf[0] ^= 0xff; - assert!(matches!( - decode_leaf(&leaf), - Err(FsError::Integrity("dir: bad leaf magic")) - )); - - // A count that overruns the buffer. - let mut leaf = encode_leaf(&[entry("a", 1)]); - leaf[7] = 9; - assert!(decode_leaf(&leaf).is_err()); - - // Trailing bytes: everything in the block must be accounted for. - let mut leaf = encode_leaf(&[entry("a", 1)]); - leaf.push(0); - assert!(matches!( - decode_leaf(&leaf), - Err(FsError::Integrity("dir: trailing bytes in leaf block")) - )); - } - - #[test] - fn index_invariants_are_enforced() { - let mk = |leaves: Vec| DirIndex { - entry_count: 0, - leaves, - }; - - // No leaves at all. - assert!(DirIndex::decode(&mk(vec![]).encode()).is_err()); - // First separator must be empty or some names resolve nowhere. - assert!(DirIndex::decode( - &mk(vec![LeafRef { - sep: "m".into(), - block: 1 - }]) - .encode() - ) - .is_err()); - // Out-of-order separators break binary search. - assert!(DirIndex::decode( - &mk(vec![ - LeafRef { - sep: String::new(), - block: 1 - }, - LeafRef { - sep: "z".into(), - block: 2 - }, - LeafRef { - sep: "a".into(), - block: 3 - }, - ]) - .encode() - ) - .is_err()); - // A leaf pointing at block 0 would alias the index itself. - assert!(DirIndex::decode( - &mk(vec![LeafRef { - sep: String::new(), - block: 0 - }]) - .encode() - ) - .is_err()); - // Two separators pointing at one block. - assert!(DirIndex::decode( - &mk(vec![ - LeafRef { - sep: String::new(), - block: 1 - }, - LeafRef { - sep: "m".into(), - block: 1 - }, - ]) - .encode() - ) - .is_err()); - } - - #[test] - fn separator_search_finds_the_owning_leaf() { - let idx = DirIndex { - entry_count: 0, - leaves: vec![ - LeafRef { - sep: String::new(), - block: 1, - }, - LeafRef { - sep: "m".into(), - block: 2, - }, - LeafRef { - sep: "t".into(), - block: 3, - }, - ], - }; - assert_eq!(idx.position_of(""), 0); - assert_eq!(idx.position_of("a"), 0); - assert_eq!(idx.position_of("lzzz"), 0); - assert_eq!(idx.position_of("m"), 1, "exact separator match"); - assert_eq!(idx.position_of("s"), 1); - assert_eq!(idx.position_of("t"), 2); - assert_eq!(idx.position_of("zzz"), 2); - } - - #[tokio::test] - async fn a_tampered_directory_block_is_rejected() { - use crate::backend::{Backend, PutBlobInput}; - let backend = Arc::new(MemoryBackend::new()); - let keys = - Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([8u8; 32]), [0u8; 16]).unwrap()); - let bs = BlockStore::new( - backend.clone(), - keys, - Arc::new(StoreConfig { - record_size: 4096, - ..Default::default() - }), - ); - - let d = insert_all(&bs, 1, &new_dir(12), &["a", "b"]).await; - - let key = bs.config().slab_key(1, 0); - let mut body = backend.get_blob(&key, None).await.unwrap().body.to_vec(); - body[0] ^= 0xff; - backend - .put_blob(PutBlobInput::new(key, bytes::Bytes::from(body))) - .await - .unwrap(); - - // Whether the corruption is caught opening the index or reading a leaf - // is an implementation detail; that no entries come back is not. - let result = async { - let mut txn = DirTxn::load(&bs, &d).await?; - txn.list().await - } - .await; - assert!( - matches!(result, Err(FsError::Integrity(_))), - "expected an integrity failure, got {result:?}" - ); - } -} diff --git a/crates/s3fs-core/src/store/dnode.rs b/crates/s3fs-core/src/store/dnode.rs deleted file mode 100644 index a263423..0000000 --- a/crates/s3fs-core/src/store/dnode.rs +++ /dev/null @@ -1,638 +0,0 @@ -//! `Dnode` — the on-disk inode. -//! -//! One dnode per file, directory, or symlink. It holds all POSIX metadata and -//! a single block pointer that roots the object's indirect tree. There is no -//! S3 user metadata anywhere in this design: `set_times` is a field write, not -//! a `CopyObject` with `MetadataDirective=REPLACE`. -//! -//! ## Wire layout — 512 bytes, big-endian -//! -//! ```text -//! offset size field -//! 0 8 objid -//! 8 1 kind Free | File | Dir | Symlink | DnodeArray -//! 9 1 nlevels height of the indirect tree, data level included -//! 10 1 record_shift log2(record size) -//! 11 1 reserved must be zero -//! 12 4 nlink -//! 16 4 mode -//! 20 4 uid -//! 24 4 gid -//! 28 8 size logical size in bytes -//! 36 8 atime_nanos -//! 44 8 mtime_nanos -//! 52 8 ctime_nanos -//! 60 8 btime_nanos -//! 68 8 gen bumped on reuse; with objid, a stable identity -//! 76 2 inline_len -//! 78 2 reserved must be zero -//! 80 8 parent_objid containing directory; self for the root -//! 88 128 blkptr root of the indirect tree -//! 216 294 inline short symlink targets -//! 510 2 reserved must be zero -//! ``` -//! -//! 512 bytes divides the 128 KiB record exactly 256 ways, so a dnode never -//! straddles a block boundary and object id arithmetic is a shift. -//! -//! ## Tree height -//! -//! `nlevels` counts the data level: -//! -//! - `0` — no data at all; the pointer is a hole. -//! - `1` — the pointer *is* the single data block. -//! - `n` — the pointer is a level-`n-1` indirect block. -//! -//! One embedded pointer rather than ZFS's three: the extra two only help -//! objects of exactly two or three blocks, and dropping them buys 256 bytes of -//! inline space, which is what lets almost every symlink target live in the -//! dnode itself. - -use crate::errors::{FsError, FsResult}; - -use super::blkptr::{BlkPtr, BLKPTR_LEN, MAX_LEVEL}; - -/// Encoded size of a dnode. -pub const DNODE_LEN: usize = 512; - -/// Bytes of in-dnode storage for short symlink targets. -pub const INLINE_CAP: usize = 294; - -const PARENT_OFFSET: usize = 80; -const BLKPTR_OFFSET: usize = PARENT_OFFSET + 8; -const INLINE_OFFSET: usize = BLKPTR_OFFSET + BLKPTR_LEN; -const RESERVED_KIND_PAD: usize = 11; -const RESERVED0: std::ops::Range = 78..80; -const RESERVED1: std::ops::Range = 510..512; - -/// Deepest tree we accept: the root pointer sits at `nlevels - 1`, which must -/// itself be a decodable level. -pub const MAX_NLEVELS: u8 = MAX_LEVEL + 1; - -/// Object id of the meta-dnode — the dnode array that holds every other -/// dnode. It is never stored *in* the array; its pointer lives in the root -/// record. -pub const META_OBJID: u64 = 0; - -/// Object id of the root directory. -pub const ROOT_OBJID: u64 = 1; - -/// How many dnodes fit in one block. -pub const fn dnodes_per_block(record_size: usize) -> usize { - record_size / DNODE_LEN -} - -/// What an object is. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum DnodeKind { - /// Unallocated slot in the dnode array. - #[default] - Free = 0, - File = 1, - Dir = 2, - Symlink = 3, - /// The dnode array itself. - DnodeArray = 4, -} - -impl DnodeKind { - fn from_u8(v: u8) -> FsResult { - Ok(match v { - 0 => DnodeKind::Free, - 1 => DnodeKind::File, - 2 => DnodeKind::Dir, - 3 => DnodeKind::Symlink, - 4 => DnodeKind::DnodeArray, - _ => return Err(FsError::Integrity("dnode: unknown kind")), - }) - } -} - -/// An object's metadata and the root of its block tree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Dnode { - pub objid: u64, - pub kind: DnodeKind, - pub nlevels: u8, - pub record_shift: u8, - /// Containing directory. The root directory is its own parent, which is - /// what makes `..` terminate there instead of escaping the mount. - /// - /// Held in the dnode rather than as a `..` directory entry so that - /// `read_dir` needs no filtering, `rmdir`'s emptiness check stays honest, - /// and a hard link to a directory remains inexpressible. - pub parent_objid: u64, - pub nlink: u32, - pub mode: u32, - pub uid: u32, - pub gid: u32, - pub size: u64, - pub atime_nanos: u64, - pub mtime_nanos: u64, - pub ctime_nanos: u64, - pub btime_nanos: u64, - /// Incremented when an object id is reused. `(objid, gen)` is a stable - /// identity across mounts, which is what makes `is-same-object` and - /// `metadata-hash` exact rather than a guess derived from an ETag. - pub gen: u64, - /// Short symlink target, stored in the dnode to avoid a block read. - pub inline: Vec, - /// Root of the indirect tree. - pub blkptr: BlkPtr, -} - -impl Dnode { - /// A free slot. - pub fn free(objid: u64, record_shift: u8) -> Self { - Dnode { - objid, - kind: DnodeKind::Free, - nlevels: 0, - record_shift, - parent_objid: objid, - nlink: 0, - mode: 0, - uid: 0, - gid: 0, - size: 0, - atime_nanos: 0, - mtime_nanos: 0, - ctime_nanos: 0, - btime_nanos: 0, - gen: 0, - inline: Vec::new(), - blkptr: BlkPtr::HOLE, - } - } - - /// A newly allocated object of the given kind. - pub fn new(objid: u64, kind: DnodeKind, record_shift: u8, now_nanos: u64) -> Self { - Dnode { - kind, - nlink: u32::from(kind != DnodeKind::Free), - atime_nanos: now_nanos, - mtime_nanos: now_nanos, - ctime_nanos: now_nanos, - btime_nanos: now_nanos, - ..Dnode::free(objid, record_shift) - } - } - - pub fn record_size(&self) -> usize { - 1usize << self.record_shift - } - - /// Pointers per indirect block at this object's record size. - pub fn fanout(&self) -> u64 { - (self.record_size() / BLKPTR_LEN) as u64 - } - - /// Number of level-0 data blocks the logical size spans. - pub fn block_count(&self) -> u64 { - blocks_for_size(self.size, self.record_size()) - } - - /// Number of blocks at `level`: level 0 is data, higher levels are the - /// indirect blocks needed to address them. - pub fn blocks_at_level(&self, level: u8) -> u64 { - blocks_at_level(self.block_count(), self.fanout(), level) - } - - /// Tree height required for this object's current size. - pub fn required_levels(&self) -> u8 { - levels_for(self.block_count(), self.fanout()) - } - - pub fn encode(&self) -> FsResult<[u8; DNODE_LEN]> { - if self.inline.len() > INLINE_CAP { - return Err(FsError::Invalid("dnode inline data exceeds capacity")); - } - let mut b = [0u8; DNODE_LEN]; - b[0..8].copy_from_slice(&self.objid.to_be_bytes()); - b[8] = self.kind as u8; - b[9] = self.nlevels; - b[10] = self.record_shift; - b[RESERVED_KIND_PAD] = 0; - b[12..16].copy_from_slice(&self.nlink.to_be_bytes()); - b[16..20].copy_from_slice(&self.mode.to_be_bytes()); - b[20..24].copy_from_slice(&self.uid.to_be_bytes()); - b[24..28].copy_from_slice(&self.gid.to_be_bytes()); - b[28..36].copy_from_slice(&self.size.to_be_bytes()); - b[36..44].copy_from_slice(&self.atime_nanos.to_be_bytes()); - b[44..52].copy_from_slice(&self.mtime_nanos.to_be_bytes()); - b[52..60].copy_from_slice(&self.ctime_nanos.to_be_bytes()); - b[60..68].copy_from_slice(&self.btime_nanos.to_be_bytes()); - b[68..76].copy_from_slice(&self.gen.to_be_bytes()); - b[76..78].copy_from_slice(&(self.inline.len() as u16).to_be_bytes()); - b[PARENT_OFFSET..PARENT_OFFSET + 8].copy_from_slice(&self.parent_objid.to_be_bytes()); - b[BLKPTR_OFFSET..BLKPTR_OFFSET + BLKPTR_LEN].copy_from_slice(&self.blkptr.encode()); - b[INLINE_OFFSET..INLINE_OFFSET + self.inline.len()].copy_from_slice(&self.inline); - Ok(b) - } - - pub fn decode(bytes: &[u8]) -> FsResult { - if bytes.len() != DNODE_LEN { - return Err(FsError::Integrity("dnode: wrong length")); - } - if bytes[RESERVED0].iter().any(|&b| b != 0) - || bytes[RESERVED1].iter().any(|&b| b != 0) - || bytes[RESERVED_KIND_PAD] != 0 - { - return Err(FsError::Integrity("dnode: reserved bytes not zero")); - } - - let inline_len = u16::from_be_bytes(bytes[76..78].try_into().expect("2 bytes")) as usize; - if inline_len > INLINE_CAP { - return Err(FsError::Integrity("dnode: inline length exceeds capacity")); - } - // Bytes past `inline_len` must be zero. Otherwise the tail is a free - // channel for smuggling data through a structure that is otherwise - // fully accounted for. - if bytes[INLINE_OFFSET + inline_len..RESERVED1.start] - .iter() - .any(|&b| b != 0) - { - return Err(FsError::Integrity("dnode: inline padding not zero")); - } - - let record_shift = bytes[10]; - if !(12..=20).contains(&record_shift) { - return Err(FsError::Integrity("dnode: record_shift out of range")); - } - - let d = Dnode { - objid: u64::from_be_bytes(bytes[0..8].try_into().expect("8 bytes")), - kind: DnodeKind::from_u8(bytes[8])?, - nlevels: bytes[9], - record_shift, - parent_objid: u64::from_be_bytes( - bytes[PARENT_OFFSET..PARENT_OFFSET + 8] - .try_into() - .expect("8 bytes"), - ), - nlink: u32::from_be_bytes(bytes[12..16].try_into().expect("4 bytes")), - mode: u32::from_be_bytes(bytes[16..20].try_into().expect("4 bytes")), - uid: u32::from_be_bytes(bytes[20..24].try_into().expect("4 bytes")), - gid: u32::from_be_bytes(bytes[24..28].try_into().expect("4 bytes")), - size: u64::from_be_bytes(bytes[28..36].try_into().expect("8 bytes")), - atime_nanos: u64::from_be_bytes(bytes[36..44].try_into().expect("8 bytes")), - mtime_nanos: u64::from_be_bytes(bytes[44..52].try_into().expect("8 bytes")), - ctime_nanos: u64::from_be_bytes(bytes[52..60].try_into().expect("8 bytes")), - btime_nanos: u64::from_be_bytes(bytes[60..68].try_into().expect("8 bytes")), - gen: u64::from_be_bytes(bytes[68..76].try_into().expect("8 bytes")), - inline: bytes[INLINE_OFFSET..INLINE_OFFSET + inline_len].to_vec(), - blkptr: BlkPtr::decode(&bytes[BLKPTR_OFFSET..BLKPTR_OFFSET + BLKPTR_LEN])?, - }; - - if d.nlevels > MAX_NLEVELS { - return Err(FsError::Integrity("dnode: nlevels exceeds maximum")); - } - // The height and the root pointer must agree. Disagreement would let a - // forged dnode redirect a read to a block at the wrong level, where - // the AAD check is the only thing left standing. - if d.nlevels == 0 { - if !d.blkptr.is_hole() { - return Err(FsError::Integrity("dnode: empty tree with a live pointer")); - } - } else if !d.blkptr.is_hole() && d.blkptr.level != d.nlevels - 1 { - return Err(FsError::Integrity("dnode: root pointer level mismatch")); - } - Ok(d) - } -} - -/// Blocks needed to hold `size` bytes. -pub fn blocks_for_size(size: u64, record_size: usize) -> u64 { - let rs = record_size as u64; - size.div_ceil(rs) -} - -/// Blocks at `level` given `nblocks` data blocks and this fan-out. -pub fn blocks_at_level(nblocks: u64, fanout: u64, level: u8) -> u64 { - let mut n = nblocks; - for _ in 0..level { - n = n.div_ceil(fanout); - } - n -} - -/// Tree height needed to address `nblocks` data blocks. -/// -/// `0` for an empty object, `1` for a single block (the dnode's pointer *is* -/// the data block), and one more level for each factor of `fanout` beyond that. -pub fn levels_for(nblocks: u64, fanout: u64) -> u8 { - if nblocks == 0 { - return 0; - } - let mut levels: u8 = 1; - let mut capacity: u64 = 1; - while capacity < nblocks { - capacity = capacity.saturating_mul(fanout); - levels += 1; - } - levels -} - -/// Index of the entry within a level-`level` indirect block that leads toward -/// data block `block_index`. -pub fn entry_index(block_index: u64, level: u8, fanout: u64) -> u64 { - debug_assert!(level >= 1, "level 0 blocks have no entries"); - (block_index / fanout.pow(u32::from(level) - 1)) % fanout -} - -/// Index, within its own level, of the level-`level` block covering data block -/// `block_index`. This is the value fed to the AEAD as `block_index`, so it -/// must be identical on the write and read paths. -pub fn block_index_at_level(block_index: u64, level: u8, fanout: u64) -> u64 { - block_index / fanout.pow(u32::from(level)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::crypto::Hash256; - use crate::store::blkptr::{Compression, Dva}; - - const FANOUT: u64 = 1024; - - fn ptr(level: u8) -> BlkPtr { - BlkPtr { - dva: Dva { - txg: 5, - slab: 1, - offset: 64, - len: 1024 + 16, - }, - logical_len: 1024, - level, - compression: Compression::None, - fill: 1, - birth_txg: 5, - nonce: crate::crypto::BlockNonce::new(5, 2), - checksum: Hash256::of(b"block"), - } - } - - fn sample() -> Dnode { - Dnode { - objid: 42, - kind: DnodeKind::File, - nlevels: 2, - record_shift: 17, - parent_objid: 1, - nlink: 1, - mode: 0o100644, - uid: 1000, - gid: 1000, - size: 300_000, - atime_nanos: 111, - mtime_nanos: 222, - ctime_nanos: 333, - btime_nanos: 444, - gen: 7, - inline: Vec::new(), - blkptr: ptr(1), - } - } - - #[test] - fn dnode_divides_the_record_evenly() { - assert_eq!(DNODE_LEN, 512); - assert_eq!(dnodes_per_block(128 * 1024), 256); - assert_eq!(128 * 1024 % DNODE_LEN, 0); - assert_eq!(INLINE_OFFSET + INLINE_CAP, RESERVED1.start); - assert_eq!(BLKPTR_OFFSET, PARENT_OFFSET + 8); - } - - #[test] - fn round_trips() { - let d = sample(); - assert_eq!(Dnode::decode(&d.encode().unwrap()).unwrap(), d); - } - - #[test] - fn free_dnode_round_trips() { - let d = Dnode::free(9, 17); - let decoded = Dnode::decode(&d.encode().unwrap()).unwrap(); - assert_eq!(decoded, d); - assert_eq!(decoded.kind, DnodeKind::Free); - assert!(decoded.blkptr.is_hole()); - } - - #[test] - fn inline_data_round_trips() { - let mut d = Dnode::new(3, DnodeKind::Symlink, 17, 99); - d.inline = b"../relative/target/path".to_vec(); - d.size = d.inline.len() as u64; - let decoded = Dnode::decode(&d.encode().unwrap()).unwrap(); - assert_eq!(decoded.inline, b"../relative/target/path"); - } - - #[test] - fn inline_at_exact_capacity_round_trips() { - let mut d = Dnode::new(3, DnodeKind::Symlink, 17, 0); - d.inline = vec![0x41; INLINE_CAP]; - let decoded = Dnode::decode(&d.encode().unwrap()).unwrap(); - assert_eq!(decoded.inline.len(), INLINE_CAP); - } - - #[test] - fn inline_beyond_capacity_is_rejected_at_encode() { - let mut d = Dnode::new(3, DnodeKind::Symlink, 17, 0); - d.inline = vec![0x41; INLINE_CAP + 1]; - assert!(d.encode().is_err()); - } - - #[test] - fn field_offsets_are_stable() { - let b = sample().encode().unwrap(); - assert_eq!(&b[0..8], &42u64.to_be_bytes()); - assert_eq!(b[8], DnodeKind::File as u8); - assert_eq!(b[9], 2); - assert_eq!(b[10], 17); - assert_eq!(b[RESERVED_KIND_PAD], 0); - assert_eq!( - &b[PARENT_OFFSET..PARENT_OFFSET + 8], - &1u64.to_be_bytes(), - "parent link must sit immediately before the block pointer" - ); - assert_eq!(&b[28..36], &300_000u64.to_be_bytes()); - assert_eq!(&b[68..76], &7u64.to_be_bytes()); - assert_eq!( - &b[BLKPTR_OFFSET..BLKPTR_OFFSET + BLKPTR_LEN], - &ptr(1).encode() - ); - } - - #[test] - fn rejects_wrong_length() { - assert!(Dnode::decode(&[0u8; 511]).is_err()); - assert!(Dnode::decode(&[0u8; 513]).is_err()); - } - - #[test] - fn rejects_unknown_kind() { - let mut b = sample().encode().unwrap(); - b[8] = 99; - assert!(matches!( - Dnode::decode(&b), - Err(FsError::Integrity("dnode: unknown kind")) - )); - } - - #[test] - fn rejects_reserved_bytes() { - for i in [78, 79, 510, 511] { - let mut b = sample().encode().unwrap(); - b[i] = 1; - assert!(Dnode::decode(&b).is_err(), "byte {i} was ignored"); - } - } - - /// A non-zero tail past `inline_len` would be an unaccounted channel in an - /// otherwise fully-described structure. - #[test] - fn rejects_dirty_inline_padding() { - let mut d = Dnode::new(3, DnodeKind::Symlink, 17, 0); - d.inline = b"short".to_vec(); - let mut b = d.encode().unwrap(); - b[INLINE_OFFSET + 10] = 0xff; - assert!(matches!( - Dnode::decode(&b), - Err(FsError::Integrity("dnode: inline padding not zero")) - )); - } - - #[test] - fn rejects_out_of_range_record_shift() { - for shift in [11u8, 21, 63] { - let mut b = sample().encode().unwrap(); - b[10] = shift; - assert!(Dnode::decode(&b).is_err(), "accepted shift {shift}"); - } - } - - /// The height and the root pointer must agree, or a forged dnode could - /// redirect a read to a block at a level it was never sealed for. - #[test] - fn rejects_level_disagreement() { - let mut d = sample(); - d.nlevels = 3; // pointer is still level 1 - assert!(matches!( - Dnode::decode(&d.encode().unwrap()), - Err(FsError::Integrity("dnode: root pointer level mismatch")) - )); - - let mut d = sample(); - d.nlevels = 0; // but the pointer is live - assert!(matches!( - Dnode::decode(&d.encode().unwrap()), - Err(FsError::Integrity("dnode: empty tree with a live pointer")) - )); - } - - #[test] - fn rejects_excessive_nlevels() { - let mut d = sample(); - d.nlevels = MAX_NLEVELS + 1; - d.blkptr = BlkPtr::HOLE; - assert!(Dnode::decode(&d.encode().unwrap()).is_err()); - } - - // ---- tree geometry ----------------------------------------------------- - - #[test] - fn blocks_for_size_rounds_up() { - assert_eq!(blocks_for_size(0, 4096), 0); - assert_eq!(blocks_for_size(1, 4096), 1); - assert_eq!(blocks_for_size(4096, 4096), 1); - assert_eq!(blocks_for_size(4097, 4096), 2); - } - - #[test] - fn levels_grow_with_the_block_count() { - assert_eq!(levels_for(0, FANOUT), 0); - assert_eq!(levels_for(1, FANOUT), 1); - assert_eq!(levels_for(2, FANOUT), 2); - assert_eq!(levels_for(FANOUT, FANOUT), 2); - assert_eq!(levels_for(FANOUT + 1, FANOUT), 3); - assert_eq!(levels_for(FANOUT * FANOUT, FANOUT), 3); - assert_eq!(levels_for(FANOUT * FANOUT + 1, FANOUT), 4); - } - - /// Height must stay within what a block pointer can encode, for every - /// permitted record size at the largest file `u64` bytes can express. - /// - /// The worst case is the *smallest* record: fewer pointers per indirect - /// block means a taller tree. This is what sets `MAX_LEVEL`. - #[test] - fn levels_stay_within_the_pointer_limit_at_every_record_size() { - for shift in 12u8..=20 { - let record = 1usize << shift; - let fanout = (record / BLKPTR_LEN) as u64; - let max_blocks = blocks_for_size(u64::MAX, record); - let levels = levels_for(max_blocks, fanout); - assert!( - levels <= MAX_NLEVELS, - "record {record}: {levels} levels exceeds {MAX_NLEVELS}" - ); - } - } - - /// The smallest record size is the binding constraint, and it is close - /// enough to the limit to be worth pinning exactly — if `MAX_LEVEL` is - /// ever lowered, this fails rather than silently rejecting large files. - #[test] - fn worst_case_height_is_the_minimum_record_size() { - let fanout = (4096 / BLKPTR_LEN) as u64; - assert_eq!(fanout, 32); - assert_eq!(levels_for(blocks_for_size(u64::MAX, 4096), fanout), 12); - assert_eq!( - levels_for(blocks_for_size(u64::MAX, 128 * 1024), FANOUT), - 6, - "the default record size is nowhere near the limit" - ); - } - - #[test] - fn blocks_at_level_collapses_toward_one() { - assert_eq!(blocks_at_level(2049, FANOUT, 0), 2049); - assert_eq!(blocks_at_level(2049, FANOUT, 1), 3); - assert_eq!(blocks_at_level(2049, FANOUT, 2), 1); - assert_eq!(blocks_at_level(0, FANOUT, 1), 0); - } - - #[test] - fn entry_and_block_indices_are_consistent() { - // Data block 1025 with fan-out 1024 lives in level-1 block 1, entry 1. - assert_eq!(entry_index(1025, 1, FANOUT), 1); - assert_eq!(block_index_at_level(1025, 1, FANOUT), 1); - // That level-1 block is entry 1 of the single level-2 block. - assert_eq!(entry_index(1025, 2, FANOUT), 1); - assert_eq!(block_index_at_level(1025, 2, FANOUT), 0); - } - - #[test] - fn level_zero_index_is_the_block_itself() { - for i in [0u64, 1, 1023, 1024, 1_000_000] { - assert_eq!(block_index_at_level(i, 0, FANOUT), i); - } - } - - /// Walking the entry indices from the root down must reconstruct the - /// original data block index. This is the invariant the tree walker - /// depends on, in both directions. - #[test] - fn entry_path_reconstructs_the_block_index() { - let fanout = 4u64; - for block in 0..64u64 { - let levels = levels_for(block + 1, fanout); - let mut rebuilt = 0u64; - for level in (1..levels).rev() { - rebuilt = rebuilt * fanout + entry_index(block, level, fanout); - } - assert_eq!(rebuilt, block, "path for block {block} did not round-trip"); - } - } -} diff --git a/crates/s3fs-core/src/store/indirect.rs b/crates/s3fs-core/src/store/indirect.rs deleted file mode 100644 index 4b02934..0000000 --- a/crates/s3fs-core/src/store/indirect.rs +++ /dev/null @@ -1,792 +0,0 @@ -//! The indirect block tree: reading down it, and rebuilding it copy-on-write. -//! -//! An object's data blocks hang off a tree of indirect blocks, each of which -//! is an array of [`BlkPtr`]s. The dnode holds the single pointer at the top. -//! Nothing is ever modified in place: a write produces new blocks bottom-up, -//! new indirect blocks above them, and finally a new root pointer. -//! -//! ## Indirect blocks are variable length -//! -//! An indirect block stores only as many pointers as it actually needs, and -//! any entry past the stored length reads as a hole. Without this, a two-block -//! file would pay a full 128 KiB indirect block to hold two used pointers and -//! 1022 zeros — a 50% storage overhead on a 256 KiB file. Trailing holes are -//! trimmed on write for the same reason, which also makes sparse files cheap -//! at every level of the tree rather than only at the leaves. -//! -//! ## What the caller must dirty -//! -//! The rebuild rewrites exactly the blocks it is given plus the indirect path -//! above them, and — when the object's size changed — the boundary block at -//! each level, so that pointers past the new end are dropped rather than left -//! dangling. - -use std::collections::{BTreeMap, BTreeSet}; - -use bytes::Bytes; - -use crate::errors::{FsError, FsResult}; - -use super::blkptr::{BlkPtr, BLKPTR_LEN}; -use super::blockstore::BlockStore; -use super::dnode::{blocks_at_level, blocks_for_size, levels_for, Dnode}; -use super::slab::SlabWriter; - -/// Decode entry `slot` of an indirect block's contents. -/// -/// Slots past the stored length are holes — that is what makes indirect blocks -/// variable length. -fn entry_at(block: &[u8], slot: u64) -> FsResult { - let range = usize::try_from(slot) - .ok() - .and_then(|s| s.checked_mul(BLKPTR_LEN)) - .and_then(|start| Some(start..start.checked_add(BLKPTR_LEN)?)); - match range.and_then(|r| block.get(r)) { - Some(raw) => BlkPtr::decode(raw), - None => Ok(BlkPtr::HOLE), - } -} - -/// Decode a whole indirect block into its pointer array. -fn decode_entries(block: &[u8]) -> FsResult> { - if !block.len().is_multiple_of(BLKPTR_LEN) { - return Err(FsError::Integrity( - "indirect block length is not a multiple of the pointer size", - )); - } - let (entries, _) = block.as_chunks::(); - entries.iter().map(|e| BlkPtr::decode(e)).collect() -} - -fn encode_entries(entries: &[BlkPtr]) -> Vec { - let mut out = Vec::with_capacity(entries.len() * BLKPTR_LEN); - for e in entries { - out.extend_from_slice(&e.encode()); - } - out -} - -/// Logical length of data block `index` given the object's size. -/// -/// The last block is short; blocks past the end are empty. -pub fn block_logical_len(size: u64, record_size: usize, index: u64) -> usize { - let rs = record_size as u64; - let start = index.saturating_mul(rs); - if start >= size { - return 0; - } - (size - start).min(rs) as usize -} - -/// Resolve the pointer stored at `(level, index)` of an object's tree. -/// -/// Walks down from the dnode's root pointer, verifying every block on the way. -/// Returns a hole for anything not present — a sparse region, a level above -/// the current tree height, or an index past the end. -pub async fn resolve_ptr( - bs: &BlockStore, - dnode: &Dnode, - level: u8, - index: u64, -) -> FsResult { - if dnode.nlevels == 0 { - return Ok(BlkPtr::HOLE); - } - let root_level = dnode.nlevels - 1; - if level > root_level { - return Ok(BlkPtr::HOLE); - } - let fanout = dnode.fanout(); - - let mut ptr = dnode.blkptr; - let mut cur_level = root_level; - // Index of the block we are currently sitting on, within its own level. - let mut cur_index = index / pow(fanout, u32::from(cur_level - level))?; - if cur_index != 0 { - // The root is the only block at its level, so an index that maps above - // it lies beyond what this tree currently covers. That is an ordinary - // question during a rebuild — "does the old tree already have a block - // here?" — and the answer is simply no. - return Ok(BlkPtr::HOLE); - } - - while cur_level > level { - if ptr.is_hole() { - return Ok(BlkPtr::HOLE); - } - let block = bs.read_block(&ptr, dnode.objid, cur_index).await?; - let slot = (index / pow(fanout, u32::from(cur_level - 1 - level))?) % fanout; - ptr = entry_at(&block, slot)?; - cur_level -= 1; - cur_index = index / pow(fanout, u32::from(cur_level - level))?; - } - Ok(ptr) -} - -fn pow(fanout: u64, exp: u32) -> FsResult { - fanout - .checked_pow(exp) - .ok_or(FsError::Invalid("tree index arithmetic overflowed")) -} - -/// Read data block `index`, returning exactly the bytes the object's size says -/// that block holds. -/// -/// A stored block shorter or longer than the size implies is padded or clipped -/// rather than rejected: `set_size` can change a block's logical length without -/// changing its contents, and both directions are legitimate. -pub async fn read_data_block(bs: &BlockStore, dnode: &Dnode, index: u64) -> FsResult { - let want = block_logical_len(dnode.size, dnode.record_size(), index); - if want == 0 { - return Ok(Bytes::new()); - } - let ptr = resolve_ptr(bs, dnode, 0, index).await?; - if ptr.is_hole() { - return Ok(Bytes::from(vec![0u8; want])); - } - let got = bs.read_block(&ptr, dnode.objid, index).await?; - Ok(fit(got, want)) -} - -/// Read block `index` exactly as it was stored, with no size-based padding or -/// clipping. -/// -/// [`read_data_block`] fits its result to what the object's `size` implies, -/// which is right for files — a file is a byte range, and the tail block is -/// short. It is wrong for objects whose blocks are self-describing structures -/// of their own length, such as directory blocks, where padding a 200-byte -/// leaf out to a full record would be pure waste. -/// -/// `None` means the block is a hole. -pub async fn read_raw_block(bs: &BlockStore, dnode: &Dnode, index: u64) -> FsResult> { - let ptr = resolve_ptr(bs, dnode, 0, index).await?; - if ptr.is_hole() { - return Ok(None); - } - Ok(Some(bs.read_block(&ptr, dnode.objid, index).await?)) -} - -fn fit(block: Bytes, want: usize) -> Bytes { - match block.len().cmp(&want) { - std::cmp::Ordering::Equal => block, - std::cmp::Ordering::Greater => block.slice(..want), - std::cmp::Ordering::Less => { - let mut v = block.to_vec(); - v.resize(want, 0); - Bytes::from(v) - } - } -} - -/// Rebuild an object's tree with `dirty` level-0 blocks applied and the object -/// resized to `new_size`. -/// -/// Returns the updated dnode. Nothing is written to the backend here — blocks -/// are staged in `writer`, and become durable when the transaction group's -/// slabs are PUT. -pub async fn commit_object( - bs: &BlockStore, - writer: &mut SlabWriter, - dnode: &Dnode, - dirty: BTreeMap>, - new_size: u64, -) -> FsResult { - let record_size = dnode.record_size(); - let fanout = dnode.fanout(); - let old_nblocks = dnode.block_count(); - let new_nblocks = blocks_for_size(new_size, record_size); - let new_levels = levels_for(new_nblocks, fanout); - let size_changed = new_nblocks != old_nblocks; - - // If the object shrank enough to lose levels, the new root is an interior - // node of the old tree. Resolve it now so the rebuild below sees a tree of - // the right height. - let (base_ptr, base_levels) = if new_levels < dnode.nlevels { - if new_levels == 0 { - (BlkPtr::HOLE, 0) - } else { - (resolve_ptr(bs, dnode, new_levels - 1, 0).await?, new_levels) - } - } else { - (dnode.blkptr, dnode.nlevels) - }; - - // Level 0: stage every dirty block that survives the resize. - let mut current: BTreeMap = BTreeMap::new(); - for (index, data) in dirty { - if index >= new_nblocks { - continue; // written and then truncated away in the same txg - } - let ptr = writer.write_block(bs.keys(), dnode.objid, 0, index, 1, &data)?; - current.insert(index, ptr); - } - - for level in 1..new_levels { - // When the tree gains height, the previous root becomes entry 0 of its - // own level in the new tree. It is not dirty, so nothing else would - // carry it upward. - if base_levels >= 1 && level == base_levels && new_levels > base_levels { - current.entry(0).or_insert(base_ptr); - } - - let child_count = blocks_at_level(new_nblocks, fanout, level - 1); - let mut parents: BTreeSet = current.keys().map(|i| i / fanout).collect(); - // A resize moves the end of every level, so the block containing the - // new last child must be rewritten to drop what is now past the end. - if size_changed && child_count > 0 { - parents.insert((child_count - 1) / fanout); - } - - let mut next: BTreeMap = BTreeMap::new(); - for parent in parents { - let first_child = parent.saturating_mul(fanout); - if first_child >= child_count { - continue; // entirely past the new end; simply unreferenced - } - let valid = (child_count - first_child).min(fanout) as usize; - - let mut entries = load_entries(bs, dnode, level, parent, base_levels).await?; - entries.resize(valid, BlkPtr::HOLE); - for (index, ptr) in current.range(first_child..first_child + fanout) { - entries[(index - first_child) as usize] = *ptr; - } - while entries.last().is_some_and(|e| e.is_hole()) { - entries.pop(); - } - - let ptr = if entries.is_empty() { - BlkPtr::HOLE - } else { - let fill = entries.iter().fold(0u32, |a, e| a.saturating_add(e.fill)); - writer.write_block( - bs.keys(), - dnode.objid, - level, - parent, - fill, - &encode_entries(&entries), - )? - }; - next.insert(parent, ptr); - } - current = next; - } - - let root = match new_levels { - 0 => BlkPtr::HOLE, - _ => match current.remove(&0) { - Some(p) => p, - // Nothing under the root changed, so the old root still stands. - None if base_levels == new_levels => base_ptr, - None => BlkPtr::HOLE, - }, - }; - - Ok(Dnode { - size: new_size, - nlevels: if root.is_hole() { 0 } else { new_levels }, - blkptr: root, - ..dnode.clone() - }) -} - -/// Read the existing entries of the level-`level` block at `index`. -/// -/// Empty when that block does not exist yet, which is the case for every level -/// above the old tree's height. -async fn load_entries( - bs: &BlockStore, - dnode: &Dnode, - level: u8, - index: u64, - base_levels: u8, -) -> FsResult> { - if base_levels == 0 || level > base_levels - 1 { - return Ok(Vec::new()); - } - let ptr = resolve_ptr(bs, dnode, level, index).await?; - if ptr.is_hole() { - return Ok(Vec::new()); - } - let block = bs.read_block(&ptr, dnode.objid, index).await?; - decode_entries(&block) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::backend::{Backend, PutBlobInput}; - use crate::crypto::{KeyMaterial, MasterSecret}; - use crate::store::config::StoreConfig; - use crate::store::dnode::DnodeKind; - use std::sync::Arc; - - /// Small records so multi-level trees are reachable in a test: 4 KiB - /// records give a fan-out of 32, so three levels cover 32 768 blocks. - fn small_config() -> StoreConfig { - StoreConfig { - record_size: 4096, - slab_max_bytes: 16 * 1024 * 1024, - ..Default::default() - } - } - - fn store(config: StoreConfig) -> (Arc, BlockStore) { - let backend = Arc::new(MemoryBackend::new()); - let keys = - Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([2u8; 32]), [0u8; 16]).unwrap()); - let bs = BlockStore::new(backend.clone(), keys, Arc::new(config)); - (backend, bs) - } - - fn empty_file(record_shift: u8) -> Dnode { - Dnode::new(42, DnodeKind::File, record_shift, 0) - } - - /// Apply one transaction group: stage the dirty blocks, rebuild the tree, - /// PUT the slabs, and return the new dnode. - async fn commit( - bs: &BlockStore, - txg: u64, - dnode: &Dnode, - dirty: BTreeMap>, - new_size: u64, - ) -> Dnode { - let mut w = SlabWriter::new(txg, bs.config()); - let out = commit_object(bs, &mut w, dnode, dirty, new_size) - .await - .unwrap(); - bs.write_slabs(txg, w.finish().unwrap()).await.unwrap(); - out - } - - fn blocks(spec: &[(u64, u8, usize)]) -> BTreeMap> { - spec.iter() - .map(|&(idx, fill, len)| (idx, vec![fill; len])) - .collect() - } - - async fn read_all(bs: &BlockStore, d: &Dnode) -> Vec { - let mut out = Vec::new(); - for i in 0..d.block_count() { - out.extend_from_slice(&read_data_block(bs, d, i).await.unwrap()); - } - out - } - - #[tokio::test] - async fn empty_object_has_no_tree() { - let (backend, bs) = store(small_config()); - let d = commit(&bs, 1, &empty_file(12), BTreeMap::new(), 0).await; - - assert_eq!(d.nlevels, 0); - assert!(d.blkptr.is_hole()); - assert_eq!(d.size, 0); - assert_eq!(backend.object_count(), 0); - } - - #[tokio::test] - async fn single_block_needs_no_indirect_block() { - let (_b, bs) = store(small_config()); - let d = commit(&bs, 1, &empty_file(12), blocks(&[(0, 0xaa, 4096)]), 4096).await; - - assert_eq!(d.nlevels, 1, "the dnode pointer is the data block itself"); - assert_eq!(d.blkptr.level, 0); - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![0xaa; 4096]); - } - - #[tokio::test] - async fn short_tail_block_reads_at_its_real_length() { - let (_b, bs) = store(small_config()); - let d = commit(&bs, 1, &empty_file(12), blocks(&[(0, 7, 100)]), 100).await; - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![7u8; 100]); - assert_eq!(d.block_count(), 1); - } - - #[tokio::test] - async fn two_blocks_add_one_level() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096)]), - 8192, - ) - .await; - - assert_eq!(d.nlevels, 2); - assert_eq!(d.blkptr.level, 1); - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![1u8; 4096]); - assert_eq!(read_data_block(&bs, &d, 1).await.unwrap(), vec![2u8; 4096]); - } - - /// The economy that variable-length indirect blocks buy: a two-block file - /// must not pay for 32 pointer slots. - #[tokio::test] - async fn indirect_blocks_hold_only_the_pointers_they_need() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096)]), - 8192, - ) - .await; - assert_eq!( - d.blkptr.logical_len as usize, - 2 * BLKPTR_LEN, - "indirect block should hold exactly two pointers" - ); - } - - #[tokio::test] - async fn three_levels() { - let (_b, bs) = store(small_config()); - // Fan-out 32, so block 1000 needs three levels. - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(1000, 9, 4096)]), - 1001 * 4096, - ) - .await; - - assert_eq!(d.nlevels, 3); - assert_eq!(d.blkptr.level, 2); - assert_eq!( - read_data_block(&bs, &d, 1000).await.unwrap(), - vec![9u8; 4096] - ); - } - - #[tokio::test] - async fn sparse_regions_read_as_zeros() { - let (backend, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(100, 5, 4096)]), - 101 * 4096, - ) - .await; - - assert_eq!( - read_data_block(&bs, &d, 100).await.unwrap(), - vec![5u8; 4096] - ); - for i in [0u64, 1, 50, 99] { - assert_eq!( - read_data_block(&bs, &d, i).await.unwrap(), - vec![0u8; 4096], - "block {i} should be a hole" - ); - } - // One data block plus its indirect path — nowhere near 101 blocks. - assert!(backend.object_count() <= 2); - } - - #[tokio::test] - async fn fill_counts_live_blocks_beneath_a_pointer() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (5, 1, 4096), (31, 1, 4096)]), - 32 * 4096, - ) - .await; - assert_eq!(d.blkptr.fill, 3); - } - - // ---- copy-on-write across transaction groups --------------------------- - - #[tokio::test] - async fn rewriting_one_block_preserves_the_others() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096), (2, 3, 4096)]), - 3 * 4096, - ) - .await; - - let d = commit(&bs, 2, &d, blocks(&[(1, 0xff, 4096)]), 3 * 4096).await; - - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![1u8; 4096]); - assert_eq!(read_data_block(&bs, &d, 1).await.unwrap(), vec![0xff; 4096]); - assert_eq!(read_data_block(&bs, &d, 2).await.unwrap(), vec![3u8; 4096]); - } - - /// The old tree must still be readable after a new one is committed — - /// that is what makes an old root a usable snapshot. - #[tokio::test] - async fn the_previous_tree_remains_readable() { - let (_b, bs) = store(small_config()); - let old = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096)]), - 2 * 4096, - ) - .await; - let new = commit(&bs, 2, &old, blocks(&[(0, 0xee, 4096)]), 2 * 4096).await; - - assert_eq!( - read_data_block(&bs, &new, 0).await.unwrap(), - vec![0xee; 4096] - ); - assert_eq!( - read_data_block(&bs, &old, 0).await.unwrap(), - vec![1u8; 4096], - "the superseded tree must be unchanged" - ); - assert_ne!(old.blkptr.dva, new.blkptr.dva); - } - - #[tokio::test] - async fn appending_grows_the_tree_and_keeps_existing_data() { - let (_b, bs) = store(small_config()); - // One block, height 1. - let d = commit(&bs, 1, &empty_file(12), blocks(&[(0, 1, 4096)]), 4096).await; - assert_eq!(d.nlevels, 1); - - // Append a second block: height must grow to 2 and block 0 must - // survive even though it was never dirtied. - let d = commit(&bs, 2, &d, blocks(&[(1, 2, 4096)]), 2 * 4096).await; - assert_eq!(d.nlevels, 2); - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![1u8; 4096]); - assert_eq!(read_data_block(&bs, &d, 1).await.unwrap(), vec![2u8; 4096]); - - // And again, across a second height increase. - let d = commit(&bs, 3, &d, blocks(&[(40, 3, 4096)]), 41 * 4096).await; - assert_eq!(d.nlevels, 3); - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![1u8; 4096]); - assert_eq!(read_data_block(&bs, &d, 1).await.unwrap(), vec![2u8; 4096]); - assert_eq!(read_data_block(&bs, &d, 40).await.unwrap(), vec![3u8; 4096]); - } - - #[tokio::test] - async fn growing_by_many_levels_at_once() { - let (_b, bs) = store(small_config()); - let d = commit(&bs, 1, &empty_file(12), blocks(&[(0, 1, 4096)]), 4096).await; - // Fan-out 32, so 40 001 blocks needs 32^4 = 1 048 576 of capacity: - // one jump from height 1 straight to height 5. - let d = commit(&bs, 2, &d, blocks(&[(40_000, 2, 4096)]), 40_001 * 4096).await; - - assert_eq!(d.nlevels, 5); - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![1u8; 4096]); - assert_eq!( - read_data_block(&bs, &d, 40_000).await.unwrap(), - vec![2u8; 4096] - ); - } - - #[tokio::test] - async fn truncating_shrinks_the_tree() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 4, 4096), (500, 7, 4096)]), - 501 * 4096, - ) - .await; - assert_eq!(d.nlevels, 3); - - let d = commit(&bs, 2, &d, BTreeMap::new(), 4096).await; - assert_eq!(d.nlevels, 1, "one block needs no indirect blocks"); - assert_eq!(d.size, 4096); - assert_eq!(d.block_count(), 1); - assert_eq!( - read_data_block(&bs, &d, 0).await.unwrap(), - vec![4u8; 4096], - "the surviving block keeps its contents through the height change" - ); - } - - /// Truncating a sparse file down to a region that holds no data at all - /// leaves a live object with no tree — every read is a hole. The size is - /// still 4 KiB; there is simply nothing stored behind it. - #[tokio::test] - async fn truncating_onto_a_hole_leaves_an_empty_tree() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(500, 7, 4096)]), - 501 * 4096, - ) - .await; - let d = commit(&bs, 2, &d, BTreeMap::new(), 4096).await; - - assert_eq!(d.nlevels, 0); - assert!(d.blkptr.is_hole()); - assert_eq!(d.size, 4096); - assert_eq!(read_data_block(&bs, &d, 0).await.unwrap(), vec![0u8; 4096]); - } - - #[tokio::test] - async fn truncating_to_zero_empties_the_tree() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096)]), - 2 * 4096, - ) - .await; - let d = commit(&bs, 2, &d, BTreeMap::new(), 0).await; - - assert_eq!(d.nlevels, 0); - assert!(d.blkptr.is_hole()); - assert_eq!(d.size, 0); - } - - #[tokio::test] - async fn truncating_drops_blocks_past_the_new_end() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096), (2, 3, 4096)]), - 3 * 4096, - ) - .await; - let d = commit(&bs, 2, &d, BTreeMap::new(), 2 * 4096).await; - - assert_eq!(d.block_count(), 2); - assert_eq!(read_all(&bs, &d).await.len(), 2 * 4096); - // The pointer array must have shrunk, not merely been ignored. - assert_eq!(d.blkptr.logical_len as usize, 2 * BLKPTR_LEN); - } - - #[tokio::test] - async fn truncate_then_regrow_reads_zeros_not_stale_data() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 0xcc, 4096)]), - 2 * 4096, - ) - .await; - let d = commit(&bs, 2, &d, BTreeMap::new(), 4096).await; - let d = commit(&bs, 3, &d, BTreeMap::new(), 2 * 4096).await; - - assert_eq!( - read_data_block(&bs, &d, 1).await.unwrap(), - vec![0u8; 4096], - "regrown region must not resurrect the old block" - ); - } - - #[tokio::test] - async fn a_committed_block_is_written_only_once() { - let (_b, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096)]), - 2 * 4096, - ) - .await; - - // A no-op commit must not rewrite anything: the root pointer stays put. - let same = commit(&bs, 2, &d, BTreeMap::new(), 2 * 4096).await; - assert_eq!( - same.blkptr, d.blkptr, - "an empty txg must not touch the tree" - ); - } - - #[tokio::test] - async fn many_blocks_round_trip() { - let (_b, bs) = store(small_config()); - let spec: Vec<_> = (0..200u64).map(|i| (i, (i % 251) as u8, 4096)).collect(); - let d = commit(&bs, 1, &empty_file(12), blocks(&spec), 200 * 4096).await; - - assert_eq!(d.nlevels, 3); - for i in 0..200u64 { - assert_eq!( - read_data_block(&bs, &d, i).await.unwrap(), - vec![(i % 251) as u8; 4096], - "block {i}" - ); - } - } - - // ---- verification ------------------------------------------------------ - - /// An indirect block is a block like any other: substituting one must fail - /// the checksum in its parent. - #[tokio::test] - async fn a_tampered_indirect_block_is_rejected() { - let (backend, bs) = store(small_config()); - let d = commit( - &bs, - 1, - &empty_file(12), - blocks(&[(0, 1, 4096), (1, 2, 4096)]), - 2 * 4096, - ) - .await; - - let key = bs.config().slab_key(1, 0); - let mut body = backend.get_blob(&key, None).await.unwrap().body.to_vec(); - // The indirect block is written after the data blocks, so corrupt the tail. - let last = body.len() - 1; - body[last] ^= 0xff; - backend - .put_blob(PutBlobInput::new(key, Bytes::from(body))) - .await - .unwrap(); - - assert!(matches!( - read_data_block(&bs, &d, 0).await, - Err(FsError::Integrity(_)) - )); - } - - #[test] - fn entries_past_the_stored_length_are_holes() { - let block = [0u8; BLKPTR_LEN]; - assert!(entry_at(&block, 0).unwrap().is_hole()); - assert!(entry_at(&block, 1).unwrap().is_hole()); - assert!(entry_at(&[], 0).unwrap().is_hole()); - assert!(entry_at(&block, u64::MAX).unwrap().is_hole()); - } - - #[test] - fn ragged_indirect_block_is_rejected() { - assert!(matches!( - decode_entries(&[0u8; BLKPTR_LEN + 1]), - Err(FsError::Integrity(_)) - )); - assert!(decode_entries(&[0u8; BLKPTR_LEN * 2]).is_ok()); - } - - #[test] - fn block_logical_len_handles_the_tail() { - assert_eq!(block_logical_len(0, 4096, 0), 0); - assert_eq!(block_logical_len(100, 4096, 0), 100); - assert_eq!(block_logical_len(4096, 4096, 0), 4096); - assert_eq!(block_logical_len(4097, 4096, 0), 4096); - assert_eq!(block_logical_len(4097, 4096, 1), 1); - assert_eq!(block_logical_len(4097, 4096, 2), 0); - } -} diff --git a/crates/s3fs-core/src/store/mod.rs b/crates/s3fs-core/src/store/mod.rs deleted file mode 100644 index 5a1c5ad..0000000 --- a/crates/s3fs-core/src/store/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! The block store: a ZFS-style copy-on-write object tree over S3. -//! -//! A write allocates new blocks, rebuilds the indirect blocks above them, and -//! ends by publishing a new *root record* whose Merkle root covers the entire -//! filesystem. Old blocks stay exactly where they were, which is what makes -//! every historical root a usable snapshot. -//! -//! ```text -//! roots/ ← signed, hash-chained, Object Lock COMPLIANCE -//! │ meta_dnode BlkPtr ← the Merkle root -//! ▼ -//! meta-dnode ──▶ indirect blocks ──▶ dnodes (one per file/dir/symlink) -//! │ blkptrs -//! ▼ -//! indirect blocks ──▶ data blocks -//! │ -//! ▼ -//! slabs// (packed per txg, unlocked) -//! ``` -//! -//! Each arrow is a [`BlkPtr`] carrying the BLAKE3 checksum of the block it -//! points at, so verifying the root's signature transitively verifies every -//! byte below it. -//! -//! ## Two different senses of "immutable" -//! -//! These are easy to conflate and mean very different things: -//! -//! **The writer never overwrites.** Copy-on-write plus a txg number that is -//! consumed once and never reused means every block lands at an address -//! nothing has occupied before. This is a property of our code, and it holds -//! for slabs and roots alike. It is also what makes Object Lock usable at all: -//! a mutable object could not live in a COMPLIANCE bucket and keep being -//! updated. -//! -//! **S3 prevents *others* from overwriting — only the roots.** Retention is -//! applied to `roots/` and nothing else. That is the anchor, and it is -//! the whole of the rollback guarantee. Slabs deliberately live in an unlocked -//! bucket so that dead copy-on-write blocks stay reclaimable, which means -//! anyone with write access to the data bucket can delete one. Doing so is a -//! *detectable denial of service* — the read fails with -//! [`crate::errors::FsError::Integrity`] and the root chain still verifies — -//! never a rollback and never a forgery. -//! -//! One object is genuinely mutable by design: `roots/latest`, the tip hint. It -//! is rewritten on every commit, is never trusted, and exists only to turn tip -//! discovery into one HEAD instead of a search. The mount protocol verifies it -//! by probing for `seq + 1`, so a stale or hostile hint costs a round trip and -//! changes nothing else. - -pub mod blkptr; -pub mod blockstore; -pub mod cache; -pub mod config; -pub mod dir; -pub mod dnode; -pub mod indirect; -pub mod objset; -pub mod root; -pub mod slab; -pub mod txg; - -pub use blkptr::{blkptrs_per_block, BlkPtr, Compression, Dva, BLKPTR_LEN, MAX_LEVEL}; -pub use blockstore::BlockStore; -pub use cache::{BlockCache, BlockKey, CacheStats}; -pub use config::StoreConfig; -pub use dir::{DirTxn, Dirent}; -pub use dnode::{Dnode, DnodeKind, DNODE_LEN, META_OBJID, ROOT_OBJID}; -pub use indirect::{commit_object, read_data_block, read_raw_block, resolve_ptr}; -pub use objset::ObjectSet; -pub use root::{RootRecord, RootStore, FORMAT_VERSION}; -pub use slab::{verify_and_open, FinishedSlab, SlabWriter}; -pub use txg::{Snapshot, Store, Transaction}; diff --git a/crates/s3fs-core/src/store/objset.rs b/crates/s3fs-core/src/store/objset.rs deleted file mode 100644 index 7c21870..0000000 --- a/crates/s3fs-core/src/store/objset.rs +++ /dev/null @@ -1,463 +0,0 @@ -//! The object set — every dnode in the filesystem, addressed by object id. -//! -//! Dnodes live in the data blocks of one special object, the **meta-dnode**. -//! Object `N` sits at slot `N % 256` of block `N / 256` (at the default record -//! size). Updating one object therefore copies one 128 KiB block and the -//! indirect path above it, and yields a new pointer at the top — and *that -//! pointer is the filesystem's Merkle root*. It is the single value the root -//! record signs, and it transitively covers every dnode, every indirect block, -//! and every byte of data. -//! -//! ```text -//! root record ──▶ meta_dnode BlkPtr ──▶ indirect ──▶ [dnode][dnode][dnode]… -//! │ -//! └──▶ that object's own tree -//! ``` -//! -//! The meta-dnode is the one object not stored in the array; its pointer lives -//! in the root record. Slot 0 is left permanently free so that slot index and -//! object id are the same number. -//! -//! ## Object ids are never reused -//! -//! Allocation is a monotonic counter. A `u64` cannot be exhausted, and not -//! reusing ids means `(objid, gen)` is a permanently stable identity — which -//! is what lets `is-same-object` and `metadata-hash` be exact, and agree -//! across mounts. Deleted objects leave a `Free` slot behind; reclaiming those -//! is a garbage-collection concern, not an allocation one. - -use std::collections::BTreeMap; - -use crate::errors::{FsError, FsResult}; - -use super::blockstore::BlockStore; -use super::dnode::{dnodes_per_block, Dnode, DnodeKind, DNODE_LEN, META_OBJID, ROOT_OBJID}; -use super::indirect::{commit_object, read_data_block}; -use super::slab::SlabWriter; - -/// Every dnode in the filesystem. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObjectSet { - /// The dnode array's own dnode. Its `blkptr` is the Merkle root. - meta: Dnode, - /// Next object id to hand out. - next_objid: u64, -} - -impl ObjectSet { - /// A brand-new, empty object set. Object ids start after the root - /// directory's reserved id. - pub fn format(record_shift: u8) -> Self { - ObjectSet { - meta: Dnode::new(META_OBJID, DnodeKind::DnodeArray, record_shift, 0), - next_objid: ROOT_OBJID + 1, - } - } - - /// Reconstruct from the fields a root record carries. - pub fn from_root(meta: Dnode, next_objid: u64) -> FsResult { - if meta.kind != DnodeKind::DnodeArray || meta.objid != META_OBJID { - return Err(FsError::Integrity( - "objset: meta-dnode is not a dnode array", - )); - } - if next_objid <= ROOT_OBJID { - return Err(FsError::Integrity( - "objset: next_objid below the root object", - )); - } - Ok(ObjectSet { meta, next_objid }) - } - - pub fn meta(&self) -> &Dnode { - &self.meta - } - - pub fn next_objid(&self) -> u64 { - self.next_objid - } - - /// The Merkle root: the pointer covering every dnode and everything under - /// them. - pub fn merkle_root(&self) -> &super::blkptr::BlkPtr { - &self.meta.blkptr - } - - fn slots_per_block(&self) -> u64 { - dnodes_per_block(self.meta.record_size()) as u64 - } - - /// Claim a fresh object id. - pub fn alloc_objid(&mut self) -> FsResult { - let id = self.next_objid; - self.next_objid = id - .checked_add(1) - .ok_or(FsError::Invalid("object id space exhausted"))?; - Ok(id) - } - - /// Read one dnode. Unallocated slots come back as `Free`. - pub async fn get(&self, bs: &BlockStore, objid: u64) -> FsResult { - if objid == META_OBJID { - return Ok(self.meta.clone()); - } - let per = self.slots_per_block(); - let block_index = objid / per; - let slot = (objid % per) as usize; - - let block = read_data_block(bs, &self.meta, block_index).await?; - let start = slot * DNODE_LEN; - let raw = match block.get(start..start + DNODE_LEN) { - Some(raw) => raw, - // Past the end of the array: never allocated. - None => return Ok(Dnode::free(objid, self.meta.record_shift)), - }; - // An untouched slot is all zeros, which is not a decodable dnode. Treat - // it as free rather than as corruption. - if raw.iter().all(|&b| b == 0) { - return Ok(Dnode::free(objid, self.meta.record_shift)); - } - - let d = Dnode::decode(raw)?; - // The AEAD binds a block to its position, but not a dnode to its slot - // *within* a block. Without this check, two dnodes could be swapped - // inside one block and every cryptographic check would still pass. - if d.objid != objid { - return Err(FsError::Integrity("objset: dnode is in the wrong slot")); - } - Ok(d) - } - - /// Read one dnode, requiring that it is allocated. - pub async fn get_allocated(&self, bs: &BlockStore, objid: u64) -> FsResult { - let d = self.get(bs, objid).await?; - if d.kind == DnodeKind::Free { - return Err(FsError::NotFound); - } - Ok(d) - } - - /// Write `dirty` dnodes into the array and rebuild it copy-on-write. - /// - /// Blocks are staged in `writer`; the returned object set carries the new - /// Merkle root, which the caller seals into a root record. - pub async fn commit( - &self, - bs: &BlockStore, - writer: &mut SlabWriter, - dirty: BTreeMap, - ) -> FsResult { - for (objid, d) in &dirty { - if d.objid != *objid { - return Err(FsError::Invalid( - "objset: dnode objid disagrees with its key", - )); - } - if *objid == META_OBJID { - return Err(FsError::Invalid( - "objset: the meta-dnode is not an array entry", - )); - } - } - - let per = self.slots_per_block(); - let record_size = self.meta.record_size(); - let highest = dirty.keys().next_back().copied().unwrap_or(0); - let next_objid = self.next_objid.max(highest + 1); - - // Every block of the array is full-length, so slot arithmetic never - // has to reason about a short tail block. - let nblocks = next_objid.div_ceil(per); - let new_size = nblocks * record_size as u64; - - // Group by block, then read-modify-write each affected block once. - let mut by_block: BTreeMap> = BTreeMap::new(); - for (objid, d) in &dirty { - by_block.entry(objid / per).or_default().push(d); - } - - let mut dirty_blocks: BTreeMap> = BTreeMap::new(); - for (block_index, dnodes) in by_block { - let old = read_data_block(bs, &self.meta, block_index).await?; - let mut buf = old.to_vec(); - buf.resize(record_size, 0); - for d in dnodes { - let start = (d.objid % per) as usize * DNODE_LEN; - buf[start..start + DNODE_LEN].copy_from_slice(&d.encode()?); - } - dirty_blocks.insert(block_index, buf); - } - - let meta = commit_object(bs, writer, &self.meta, dirty_blocks, new_size).await?; - Ok(ObjectSet { meta, next_objid }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::crypto::{KeyMaterial, MasterSecret}; - use crate::store::config::StoreConfig; - use std::sync::Arc; - - /// 4 KiB records give 8 dnodes per block, so block boundaries are easy to - /// cross in a test. - fn small_config() -> StoreConfig { - StoreConfig { - record_size: 4096, - ..Default::default() - } - } - - fn store(config: StoreConfig) -> (Arc, BlockStore) { - let backend = Arc::new(MemoryBackend::new()); - let keys = - Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([6u8; 32]), [0u8; 16]).unwrap()); - let bs = BlockStore::new(backend.clone(), keys, Arc::new(config)); - (backend, bs) - } - - async fn commit( - bs: &BlockStore, - txg: u64, - os: &ObjectSet, - dirty: BTreeMap, - ) -> ObjectSet { - let mut w = SlabWriter::new(txg, bs.config()); - let out = os.commit(bs, &mut w, dirty).await.unwrap(); - bs.write_slabs(txg, w.finish().unwrap()).await.unwrap(); - out - } - - fn file(objid: u64, size: u64) -> Dnode { - Dnode { - size, - ..Dnode::new(objid, DnodeKind::File, 12, 1000) - } - } - - #[test] - fn a_fresh_object_set_is_empty() { - let os = ObjectSet::format(12); - assert_eq!(os.next_objid(), ROOT_OBJID + 1); - assert!(os.merkle_root().is_hole()); - assert_eq!(os.meta().kind, DnodeKind::DnodeArray); - } - - #[test] - fn object_ids_are_handed_out_monotonically() { - let mut os = ObjectSet::format(12); - let a = os.alloc_objid().unwrap(); - let b = os.alloc_objid().unwrap(); - assert_eq!(b, a + 1); - assert!(a > ROOT_OBJID, "the root directory's id is reserved"); - } - - #[tokio::test] - async fn store_and_load_one_dnode() { - let (_b, bs) = store(small_config()); - let os = ObjectSet::format(12); - - let d = file(ROOT_OBJID + 1, 1234); - let os = commit(&bs, 1, &os, BTreeMap::from([(d.objid, d.clone())])).await; - - assert_eq!(os.get(&bs, d.objid).await.unwrap(), d); - assert!(!os.merkle_root().is_hole(), "the Merkle root must be live"); - } - - #[tokio::test] - async fn unallocated_slots_read_as_free() { - let (_b, bs) = store(small_config()); - let os = ObjectSet::format(12); - let os = commit(&bs, 1, &os, BTreeMap::from([(5, file(5, 10))])).await; - - // A never-written slot inside an allocated block. - assert_eq!(os.get(&bs, 4).await.unwrap().kind, DnodeKind::Free); - // A slot past the end of the array entirely. - assert_eq!(os.get(&bs, 9_999).await.unwrap().kind, DnodeKind::Free); - assert!(matches!( - os.get_allocated(&bs, 4).await, - Err(FsError::NotFound) - )); - } - - #[tokio::test] - async fn many_dnodes_across_several_blocks() { - let (_b, bs) = store(small_config()); - let os = ObjectSet::format(12); - - // 8 dnodes per 4 KiB block, so 40 objects spans several blocks. - let dirty: BTreeMap<_, _> = (2..42u64).map(|i| (i, file(i, i * 100))).collect(); - let os = commit(&bs, 1, &os, dirty).await; - - assert_eq!(os.next_objid(), 42); - for i in 2..42u64 { - let d = os.get(&bs, i).await.unwrap(); - assert_eq!(d.objid, i); - assert_eq!(d.size, i * 100, "object {i}"); - } - } - - #[tokio::test] - async fn updating_one_dnode_leaves_its_block_mates_alone() { - let (_b, bs) = store(small_config()); - let os = ObjectSet::format(12); - // Objects 2..6 share one block at 8 slots per block. - let dirty: BTreeMap<_, _> = (2..6u64).map(|i| (i, file(i, i))).collect(); - let os = commit(&bs, 1, &os, dirty).await; - - let mut changed = file(3, 999_999); - changed.mtime_nanos = 42; - let os = commit(&bs, 2, &os, BTreeMap::from([(3, changed.clone())])).await; - - assert_eq!(os.get(&bs, 3).await.unwrap(), changed); - for i in [2u64, 4, 5] { - assert_eq!( - os.get(&bs, i).await.unwrap().size, - i, - "object {i} was disturbed" - ); - } - } - - /// The Merkle root must move whenever anything beneath it does — that is - /// the entire premise of anchoring the filesystem on one hash. - #[tokio::test] - async fn the_merkle_root_changes_on_every_mutation() { - let (_b, bs) = store(small_config()); - let os = ObjectSet::format(12); - - let os1 = commit(&bs, 1, &os, BTreeMap::from([(2, file(2, 1))])).await; - let root1 = *os1.merkle_root(); - - let os2 = commit(&bs, 2, &os1, BTreeMap::from([(2, file(2, 2))])).await; - let root2 = *os2.merkle_root(); - - assert_ne!(root1.checksum, root2.checksum); - assert_ne!( - root1.dva, root2.dva, - "copy-on-write must not reuse the address" - ); - - // And the superseded object set still reads its own version. - assert_eq!(os1.get(&bs, 2).await.unwrap().size, 1); - assert_eq!(os2.get(&bs, 2).await.unwrap().size, 2); - } - - #[tokio::test] - async fn an_empty_commit_leaves_the_root_untouched() { - let (_b, bs) = store(small_config()); - let os = commit( - &bs, - 1, - &ObjectSet::format(12), - BTreeMap::from([(2, file(2, 1))]), - ) - .await; - let again = commit(&bs, 2, &os, BTreeMap::new()).await; - assert_eq!(again.merkle_root(), os.merkle_root()); - } - - #[tokio::test] - async fn committing_a_high_object_id_extends_the_array() { - let (_b, bs) = store(small_config()); - let os = commit( - &bs, - 1, - &ObjectSet::format(12), - BTreeMap::from([(1000, file(1000, 7))]), - ) - .await; - - assert_eq!(os.next_objid(), 1001); - assert_eq!(os.get(&bs, 1000).await.unwrap().size, 7); - // The intervening slots cost nothing: they are holes. - assert_eq!(os.get(&bs, 500).await.unwrap().kind, DnodeKind::Free); - } - - #[tokio::test] - async fn rejects_a_dnode_filed_under_the_wrong_key() { - let (_b, bs) = store(small_config()); - let mut w = SlabWriter::new(1, bs.config()); - let os = ObjectSet::format(12); - assert!(os - .commit(&bs, &mut w, BTreeMap::from([(5, file(6, 0))])) - .await - .is_err()); - } - - #[tokio::test] - async fn rejects_writing_the_meta_dnode_as_an_entry() { - let (_b, bs) = store(small_config()); - let mut w = SlabWriter::new(1, bs.config()); - let os = ObjectSet::format(12); - assert!(os - .commit( - &bs, - &mut w, - BTreeMap::from([(META_OBJID, file(META_OBJID, 0))]) - ) - .await - .is_err()); - } - - /// Position-binding AAD covers a whole block, not a dnode's slot inside it. - /// Two dnodes swapped within one block would pass every cryptographic - /// check, so the identity field has to be verified explicitly. - #[tokio::test] - async fn rejects_a_dnode_moved_to_another_slot() { - let (_b, bs) = store(small_config()); - let os = ObjectSet::format(12); - - // Hand-build a block where object 2's dnode sits in object 3's slot. - let mut buf = vec![0u8; 4096]; - buf[3 * DNODE_LEN..4 * DNODE_LEN].copy_from_slice(&file(2, 1).encode().unwrap()); - - let mut w = SlabWriter::new(1, bs.config()); - let meta = commit_object(&bs, &mut w, os.meta(), BTreeMap::from([(0u64, buf)]), 4096) - .await - .unwrap(); - bs.write_slabs(1, w.finish().unwrap()).await.unwrap(); - - let forged = ObjectSet::from_root(meta, 10).unwrap(); - assert!(matches!( - forged.get(&bs, 3).await, - Err(FsError::Integrity("objset: dnode is in the wrong slot")) - )); - } - - #[test] - fn from_root_rejects_a_meta_dnode_of_the_wrong_kind() { - let bad = Dnode::new(META_OBJID, DnodeKind::File, 12, 0); - assert!(ObjectSet::from_root(bad, 10).is_err()); - - let wrong_id = Dnode::new(7, DnodeKind::DnodeArray, 12, 0); - assert!(ObjectSet::from_root(wrong_id, 10).is_err()); - - let good = Dnode::new(META_OBJID, DnodeKind::DnodeArray, 12, 0); - assert!(ObjectSet::from_root(good.clone(), 10).is_ok()); - assert!( - ObjectSet::from_root(good, ROOT_OBJID).is_err(), - "next_objid must leave room for the root directory" - ); - } - - #[tokio::test] - async fn round_trips_through_from_root() { - let (_b, bs) = store(small_config()); - let os = commit( - &bs, - 1, - &ObjectSet::format(12), - BTreeMap::from([(2, file(2, 42))]), - ) - .await; - - // What a mount does: rebuild the object set from what the root record - // carries, then read through it. - let remounted = ObjectSet::from_root(os.meta().clone(), os.next_objid()).unwrap(); - assert_eq!(remounted, os); - assert_eq!(remounted.get(&bs, 2).await.unwrap().size, 42); - } -} diff --git a/crates/s3fs-core/src/store/root.rs b/crates/s3fs-core/src/store/root.rs deleted file mode 100644 index 64fd529..0000000 --- a/crates/s3fs-core/src/store/root.rs +++ /dev/null @@ -1,905 +0,0 @@ -//! Root records — the signed, hash-chained anchor that makes rollback -//! detectable. -//! -//! A root record is the only thing in the store that is signed, and the only -//! thing S3 Object Lock protects. Everything else derives its trust from it: -//! the record carries the meta-dnode, whose block pointer's checksum covers -//! every dnode, every indirect block, and every byte of data beneath. -//! -//! ## What each mechanism actually buys -//! -//! | Threat | Defence | -//! |---|---| -//! | Forge a root | Ed25519 signature over the whole record, verified against the key we derived — not the key the record names | -//! | Alter data under a valid root | The signed `meta_dnode` pointer's BLAKE3 checksum | -//! | Replay an old root at its own key | `seq` is checked against the key it was read from, and against an in-session floor | -//! | Destroy a root to erase history | Object Lock COMPLIANCE: the version is undeletable by every principal, including the account root | -//! | Hide a root behind a delete marker | Tip discovery and loading read the *retained* version, so a marker changes nothing | -//! | Splice two histories together | `prev_root_hash`, which the signature covers | -//! | Two writers racing | `If-None-Match: *`, then a read-back of the retained version, so exactly one wins `seq + 1` even past a delete marker | -//! -//! ## The residual risk -//! -//! A cold mount cannot distinguish "the tip is N" from "the tip is N, and the -//! store is hiding N+1". Closing it needs an anchor outside S3 (a conditional -//! counter in another service, or a floor supplied through the enclave's KMS -//! encryption context, which [`RootStore::mount`] accepts as `min_seq`). -//! -//! What *used* to make this reachable through a legal API call has been closed. -//! Object Lock protects a version, not the name it lives under: a -//! `DeleteObject` with no version id writes a delete marker, and `HEAD` and -//! `GetObject` then report the tip missing while it sits underneath, -//! undeletable. So hiding N+1 needed no lie from S3 at all — just a delete -//! nobody was allowed to refuse. [`RootStore::exists`] and [`RootStore::load`] -//! now read the retained version, which a marker cannot conceal. Hiding a root -//! now requires S3 itself to lie, which is where this paragraph always assumed -//! the bar was. -//! -//! There was a second and worse version of this, now closed. A cold mount also -//! could not distinguish "this filesystem is new" from "everything has been -//! hidden", because `Store::open` created one when it found none — so an -//! enclave pointed at an emptied store served a fresh, correctly-signed, -//! entirely wrong filesystem, and every check above passed because they all -//! attested to the new one. Mounting and creating are now separate calls, and -//! `enclave_runtime::boot` decides which it is entitled to make by checking an -//! NSM attestation the host cannot forge. -//! -//! Within a session the gap does not exist: `expected_seq` only ever rises, so -//! a root older than one already accepted is rejected outright. - -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; - -use bytes::Bytes; - -use crate::backend::{Backend, PutBlobInput}; -use crate::crypto::{sign, Hash256, KeyMaterial, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN}; -use crate::errors::{FsError, FsResult}; - -use super::config::StoreConfig; -use super::dnode::{Dnode, DNODE_LEN}; - -/// On-disk format version. A mount refuses anything else rather than guessing. -pub const FORMAT_VERSION: u32 = 1; - -const ROOT_MAGIC: &[u8; 8] = b"S3FSROOT"; - -// Field offsets. Derived rather than written out, because hand-computed -// offsets are exactly the sort of thing that silently reads the wrong field — -// a mis-sited `signer_pubkey` would compare the wrong 32 bytes and reject -// every valid record. `field_offsets_are_stable` pins them against the encoder. -const OFF_MAGIC: usize = 0; -const OFF_FORMAT: usize = OFF_MAGIC + 8; -const OFF_SEQ: usize = OFF_FORMAT + 4; -const OFF_PREV_HASH: usize = OFF_SEQ + 8; -const OFF_TXG: usize = OFF_PREV_HASH + 32; -const OFF_TIMESTAMP: usize = OFF_TXG + 8; -const OFF_NEXT_OBJID: usize = OFF_TIMESTAMP + 8; -const OFF_FS_UUID: usize = OFF_NEXT_OBJID + 8; -const OFF_PUBKEY: usize = OFF_FS_UUID + 16; -const OFF_META_DNODE: usize = OFF_PUBKEY + ED25519_PUBLIC_KEY_LEN; - -/// Bytes covered by the signature. -const SIGNED_LEN: usize = OFF_META_DNODE + DNODE_LEN; -/// Total encoded length. -pub const ROOT_LEN: usize = SIGNED_LEN + ED25519_SIGNATURE_LEN; - -fn u64_at(bytes: &[u8], off: usize) -> u64 { - u64::from_be_bytes(bytes[off..off + 8].try_into().expect("8 bytes")) -} - -/// A committed state of the entire filesystem. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RootRecord { - pub format_version: u32, - /// Sequence number. Also the object key this record lives at, so a replay - /// to a different key is caught by comparing the two. - pub seq: u64, - /// BLAKE3 of the previous record's full encoding. Zero at genesis. - pub prev_root_hash: Hash256, - pub txg: u64, - pub timestamp_nanos: u64, - pub next_objid: u64, - pub fs_uuid: [u8; 16], - pub signer_pubkey: [u8; ED25519_PUBLIC_KEY_LEN], - /// The dnode array. Its `blkptr` checksum is the Merkle root. - pub meta_dnode: Dnode, - pub signature: [u8; ED25519_SIGNATURE_LEN], -} - -impl RootRecord { - /// Build and sign a record. - pub fn seal( - keys: &KeyMaterial, - seq: u64, - prev: Option<&RootRecord>, - txg: u64, - timestamp_nanos: u64, - meta_dnode: Dnode, - next_objid: u64, - ) -> FsResult { - let mut record = RootRecord { - format_version: FORMAT_VERSION, - seq, - prev_root_hash: prev.map(RootRecord::hash).unwrap_or(Hash256::ZERO), - txg, - timestamp_nanos, - next_objid, - fs_uuid: *keys.fs_uuid(), - signer_pubkey: *keys.public_key(), - meta_dnode, - signature: [0u8; ED25519_SIGNATURE_LEN], - }; - record.signature = sign::sign(keys.signing_key(), &record.signed_bytes()?); - Ok(record) - } - - fn signed_bytes(&self) -> FsResult> { - let mut b = Vec::with_capacity(SIGNED_LEN); - b.extend_from_slice(ROOT_MAGIC); - b.extend_from_slice(&self.format_version.to_be_bytes()); - b.extend_from_slice(&self.seq.to_be_bytes()); - b.extend_from_slice(self.prev_root_hash.as_bytes()); - b.extend_from_slice(&self.txg.to_be_bytes()); - b.extend_from_slice(&self.timestamp_nanos.to_be_bytes()); - b.extend_from_slice(&self.next_objid.to_be_bytes()); - b.extend_from_slice(&self.fs_uuid); - b.extend_from_slice(&self.signer_pubkey); - b.extend_from_slice(&self.meta_dnode.encode()?); - debug_assert_eq!(b.len(), SIGNED_LEN); - Ok(b) - } - - pub fn encode(&self) -> FsResult> { - let mut b = self.signed_bytes()?; - b.extend_from_slice(&self.signature); - Ok(b) - } - - /// Identity of this record for chaining. Covers the signature too, so a - /// link commits to one exact set of bytes. - pub fn hash(&self) -> Hash256 { - Hash256::of(&self.encode().expect("a sealed record always encodes")) - } - - /// The Merkle root: one checksum covering the whole filesystem. - pub fn merkle_root(&self) -> &Hash256 { - &self.meta_dnode.blkptr.checksum - } - - /// Decode and fully verify. - /// - /// `expected_seq` is the key the bytes were read from. Checking it against - /// the record's own `seq` is what stops a valid old record being replayed - /// at a newer key — the signature alone would happily accept that. - pub fn decode_and_verify( - bytes: &[u8], - keys: &KeyMaterial, - expected_seq: u64, - ) -> FsResult { - if bytes.len() != ROOT_LEN { - return Err(FsError::Integrity("root: wrong length")); - } - if &bytes[OFF_MAGIC..OFF_MAGIC + 8] != ROOT_MAGIC { - return Err(FsError::Integrity("root: bad magic")); - } - let format_version = u32::from_be_bytes( - bytes[OFF_FORMAT..OFF_FORMAT + 4] - .try_into() - .expect("4 bytes"), - ); - if format_version != FORMAT_VERSION { - return Err(FsError::Integrity("root: unsupported format version")); - } - - let mut signer_pubkey = [0u8; ED25519_PUBLIC_KEY_LEN]; - signer_pubkey.copy_from_slice(&bytes[OFF_PUBKEY..OFF_PUBKEY + ED25519_PUBLIC_KEY_LEN]); - // Verify against the key *we* derived, never the one the record names. - // Otherwise an attacker signs a forged record with their own key and - // ships the matching public key alongside it. - if &signer_pubkey != keys.public_key() { - return Err(FsError::Integrity("root: signed by an unexpected key")); - } - let mut signature = [0u8; ED25519_SIGNATURE_LEN]; - signature.copy_from_slice(&bytes[SIGNED_LEN..]); - sign::verify(keys.public_key(), &bytes[..SIGNED_LEN], &signature)?; - - let mut fs_uuid = [0u8; 16]; - fs_uuid.copy_from_slice(&bytes[OFF_FS_UUID..OFF_FS_UUID + 16]); - if &fs_uuid != keys.fs_uuid() { - return Err(FsError::Integrity("root: belongs to another filesystem")); - } - - let seq = u64_at(bytes, OFF_SEQ); - if seq != expected_seq { - return Err(FsError::Integrity("root: sequence does not match its key")); - } - - let record = RootRecord { - format_version, - seq, - prev_root_hash: Hash256::from_bytes( - bytes[OFF_PREV_HASH..OFF_PREV_HASH + 32] - .try_into() - .expect("32 bytes"), - ), - txg: u64_at(bytes, OFF_TXG), - timestamp_nanos: u64_at(bytes, OFF_TIMESTAMP), - next_objid: u64_at(bytes, OFF_NEXT_OBJID), - fs_uuid, - signer_pubkey, - meta_dnode: Dnode::decode(&bytes[OFF_META_DNODE..SIGNED_LEN])?, - signature, - }; - if seq == 0 && !record.prev_root_hash.is_zero() { - return Err(FsError::Integrity("root: genesis record has a predecessor")); - } - Ok(record) - } -} - -/// Reads and publishes root records against the locked bucket. -#[derive(Debug)] -pub struct RootStore { - backend: Arc, - keys: Arc, - config: Arc, - /// Floor for this session. Only ever rises, so a root older than one we - /// have already accepted can never be served to us again. - expected_seq: AtomicU64, -} - -impl RootStore { - pub fn new( - backend: Arc, - keys: Arc, - config: Arc, - ) -> Self { - RootStore { - backend, - keys, - config, - expected_seq: AtomicU64::new(0), - } - } - - /// Highest sequence accepted in this session. - pub fn expected_seq(&self) -> u64 { - self.expected_seq.load(Ordering::SeqCst) - } - - /// Is there a record at this sequence? - /// - /// Asks for the *retained* version rather than `HEAD`-ing the current one. - /// A `DeleteObject` without a version id writes a delete marker that hides - /// an Object-Locked record from `HEAD` and `GetObject` while leaving it - /// undeletable underneath — so a `HEAD` here would let a host roll the - /// chain back by hiding its tip, using nothing but a legal S3 call. - /// - /// Costs a `ListObjectVersions` per probe, on a binary search that runs - /// once per mount. Not the commit path. - /// - /// If the enclave's role lacks `s3:ListBucketVersions` this errors rather - /// than answering `false`, so a missing permission is a refusal to mount - /// and not a silent rollback. - async fn exists(&self, seq: u64) -> FsResult { - match self - .backend - .get_retained_blob(&self.config.root_key(seq)) - .await - { - Ok(_) => Ok(true), - Err(FsError::NotFound) => Ok(false), - Err(e) => Err(e), - } - } - - /// Fetch and verify the record at `seq`. - /// - /// The retained version, for the reason [`RootStore::exists`] gives: a - /// record that has been hidden behind a delete marker is still the record. - pub async fn load(&self, seq: u64) -> FsResult { - let got = self - .backend - .get_retained_blob(&self.config.root_key(seq)) - .await?; - let record = RootRecord::decode_and_verify(&got.body, &self.keys, seq)?; - let floor = self.expected_seq(); - if record.seq < floor { - return Err(FsError::Rollback { - expected: floor, - found: record.seq, - }); - } - Ok(record) - } - - /// Fetch and verify a *historical* root, bypassing the session floor. - /// - /// Only for read-only access to a past state. The floor exists to stop the - /// store rewinding the filesystem under us, so nothing that establishes - /// the live state may use this — and nothing here raises the floor either, - /// so reading an old snapshot cannot be turned into accepting one. - /// - /// Every other check still applies: signature, key, filesystem id, and - /// that the record's own sequence matches the key it was read from. - pub async fn load_snapshot(&self, seq: u64) -> FsResult { - self.verify_at(seq).await - } - - /// Locate the newest root. - /// - /// Roots are contiguous — every commit takes `seq + 1` and none can be - /// deleted — so existence is monotone in `seq` and a galloping search - /// finds the tip in O(log n) HEADs. A full LIST is not an option: after - /// years of commits there may be millions of keys, and none of them ever - /// go away. - /// - /// The `roots/latest` hint only chooses where the search starts, and it is - /// verified before being trusted, so a stale or hostile hint costs a round - /// trip and nothing else. - pub async fn find_tip(&self) -> FsResult> { - if !self.exists(0).await? { - return Ok(None); // not formatted - } - - let mut lo = 0u64; - if let Some(hint) = self.read_hint().await { - if hint > 0 && self.verify_at(hint).await.is_ok() { - lo = hint; - } - } - - let mut step = 1u64; - loop { - match lo.checked_add(step) { - Some(next) if self.exists(next).await? => { - lo = next; - step = step.saturating_mul(2); - } - _ => break, - } - } - - let mut hi = lo.saturating_add(step); // known absent - while hi - lo > 1 { - let mid = lo + (hi - lo) / 2; - if self.exists(mid).await? { - lo = mid; - } else { - hi = mid; - } - } - Ok(Some(lo)) - } - - async fn verify_at(&self, seq: u64) -> FsResult { - let got = self - .backend - .get_retained_blob(&self.config.root_key(seq)) - .await?; - RootRecord::decode_and_verify(&got.body, &self.keys, seq) - } - - async fn read_hint(&self) -> Option { - let got = self - .backend - .get_blob(&self.config.root_hint_key(), None) - .await - .ok()?; - std::str::from_utf8(&got.body).ok()?.trim().parse().ok() - } - - /// Open the filesystem at its newest root. - /// - /// `min_seq` is an externally supplied floor — from a KMS encryption - /// context, a configuration flag, or anywhere else outside the store's - /// control. It is the only thing that can close the cold-mount gap - /// described in this module's documentation. - /// - /// Returns `None` if the bucket holds no filesystem at all. - pub async fn mount(&self, min_seq: Option) -> FsResult> { - let Some(tip) = self.find_tip().await? else { - return Ok(None); - }; - if let Some(min) = min_seq { - if tip < min { - return Err(FsError::Rollback { - expected: min, - found: tip, - }); - } - } - let record = self.verify_at(tip).await?; - self.verify_chain(&record, self.config.root_chain_verify_depth) - .await?; - self.raise_floor(record.seq); - Ok(Some(record)) - } - - /// Walk `depth` links back, checking each `prev_root_hash`. - /// - /// Depth 1 catches a spliced history at the tip, which is the live attack. - /// Walking the whole chain costs one GET per root and is an audit - /// operation, not something a mount should pay for. - pub async fn verify_chain(&self, tip: &RootRecord, depth: u32) -> FsResult<()> { - let mut current = tip.clone(); - for _ in 0..depth { - if current.seq == 0 { - return Ok(()); // reached genesis - } - let prev = self.verify_at(current.seq - 1).await?; - if prev.hash() != current.prev_root_hash { - return Err(FsError::Integrity("root: broken hash chain")); - } - current = prev; - } - Ok(()) - } - - /// Publish a record, winning or losing the race for its sequence number. - /// - /// `If-None-Match: *` alone does not decide that race. It tests the - /// *current* version, and a delete marker is a current version that is not - /// an object — so a host that hides the winner's record lets a second - /// conditional create through, and two writers would each believe they - /// own the sequence. What decides it is the *retained* version: the first - /// ever written, and the one every reader loads. Versions only stack on - /// top of it, so once ours exists, whether it is that one can no longer - /// change — reading it back after the PUT is exact, where a check before - /// the PUT would race another marker. - /// - /// Losing is [`FsError::Conflict`], and the caller must treat that as - /// fatal for the mount rather than retrying: a retry would reuse the - /// transaction group number, and with it every AEAD nonce in the commit. - pub async fn publish(&self, record: &RootRecord) -> FsResult<()> { - let key = self.config.root_key(record.seq); - let body = Bytes::from(record.encode()?); - let mut input = PutBlobInput::new(key.clone(), body.clone()); - input.object_lock = self.root_retention(); - - match self.backend.put_blob_if_not_exists(input).await { - Ok(_) => {} - Err(FsError::AlreadyExists) => return Err(FsError::Conflict), - Err(e) => return Err(e), - } - // ponytail: a LIST and a GET per publish. Comparing the PUT's version - // id with the oldest listed one drops the GET, if commits need it. - if self.backend.get_retained_blob(&key).await?.body != body { - return Err(FsError::Conflict); - } - self.raise_floor(record.seq); - self.write_hint(record.seq).await; - Ok(()) - } - - fn root_retention(&self) -> Option { - self.config - .root_retention - .map(|d| crate::backend::ObjectLock { - mode: crate::backend::ObjectLockMode::Compliance, - retain_until: std::time::SystemTime::now() + d, - }) - } - - /// Best-effort tip hint. Never trusted on read, so a failure here costs a - /// slower mount and nothing more. - async fn write_hint(&self, seq: u64) { - let _ = self - .backend - .put_blob(PutBlobInput::new( - self.config.root_hint_key(), - Bytes::from(seq.to_string()), - )) - .await; - } - - fn raise_floor(&self, seq: u64) { - self.expected_seq.fetch_max(seq, Ordering::SeqCst); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::crypto::MasterSecret; - use crate::store::dnode::{DnodeKind, META_OBJID}; - - fn keys_for(master: u8) -> Arc { - Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([master; 32]), [1u8; 16]).unwrap()) - } - - fn setup() -> (Arc, Arc, RootStore) { - let backend = Arc::new(MemoryBackend::new()); - let keys = keys_for(11); - let config = Arc::new(StoreConfig { - // Short retention: the tests need to be able to demonstrate that a - // committed root is refused, not to keep one for a decade. - root_retention: Some(std::time::Duration::from_secs(3600)), - ..Default::default() - }); - let rs = RootStore::new(backend.clone(), keys.clone(), config); - (backend, keys, rs) - } - - fn meta() -> Dnode { - Dnode::new(META_OBJID, DnodeKind::DnodeArray, 17, 0) - } - - fn seal(keys: &KeyMaterial, seq: u64, prev: Option<&RootRecord>) -> RootRecord { - RootRecord::seal(keys, seq, prev, seq + 1, 1_000 + seq, meta(), 2).unwrap() - } - - /// Publish a chain of `n` roots, seq 0..n-1. - async fn publish_chain(rs: &RootStore, keys: &KeyMaterial, n: u64) -> Vec { - let mut out: Vec = Vec::new(); - for seq in 0..n { - let r = seal(keys, seq, out.last()); - rs.publish(&r).await.unwrap(); - out.push(r); - } - out - } - - // ---- encoding ---------------------------------------------------------- - - #[test] - fn round_trips() { - let keys = keys_for(11); - let r = seal(&keys, 5, None); - assert_eq!(r.encode().unwrap().len(), ROOT_LEN); - assert_eq!( - RootRecord::decode_and_verify(&r.encode().unwrap(), &keys, 5).unwrap(), - r - ); - } - - /// The decoder reads by offset; the encoder writes in order. Pin every - /// boundary so a field added or resized cannot silently shift the ones - /// after it into reading the wrong bytes. - #[test] - fn field_offsets_are_stable() { - let keys = keys_for(11); - let r = RootRecord::seal(&keys, 0x1122, None, 0x3344, 0x5566, meta(), 0x7788).unwrap(); - let b = r.encode().unwrap(); - - assert_eq!(&b[OFF_MAGIC..OFF_MAGIC + 8], ROOT_MAGIC); - assert_eq!( - &b[OFF_FORMAT..OFF_FORMAT + 4], - &FORMAT_VERSION.to_be_bytes() - ); - assert_eq!(u64_at(&b, OFF_SEQ), 0x1122); - assert_eq!( - &b[OFF_PREV_HASH..OFF_PREV_HASH + 32], - Hash256::ZERO.as_bytes() - ); - assert_eq!(u64_at(&b, OFF_TXG), 0x3344); - assert_eq!(u64_at(&b, OFF_TIMESTAMP), 0x5566); - assert_eq!(u64_at(&b, OFF_NEXT_OBJID), 0x7788); - assert_eq!(&b[OFF_FS_UUID..OFF_FS_UUID + 16], keys.fs_uuid()); - assert_eq!(&b[OFF_PUBKEY..OFF_PUBKEY + 32], keys.public_key()); - assert_eq!(&b[OFF_META_DNODE..SIGNED_LEN], &meta().encode().unwrap()); - assert_eq!(&b[SIGNED_LEN..], &r.signature); - assert_eq!(b.len(), ROOT_LEN); - } - - #[test] - fn every_field_survives_the_round_trip() { - let keys = keys_for(11); - let prev = seal(&keys, 0, None); - let r = seal(&keys, 1, Some(&prev)); - let decoded = RootRecord::decode_and_verify(&r.encode().unwrap(), &keys, 1).unwrap(); - - assert_eq!(decoded.seq, 1); - assert_eq!(decoded.txg, 2); - assert_eq!(decoded.timestamp_nanos, 1001); - assert_eq!(decoded.next_objid, 2); - assert_eq!(decoded.prev_root_hash, prev.hash()); - assert_eq!(&decoded.fs_uuid, keys.fs_uuid()); - assert_eq!(&decoded.signer_pubkey, keys.public_key()); - assert_eq!(decoded.meta_dnode, meta()); - } - - // ---- verification failures --------------------------------------------- - - #[test] - fn a_tampered_record_fails_its_signature() { - let keys = keys_for(11); - let r = seal(&keys, 3, None); - let mut bytes = r.encode().unwrap(); - // Move the Merkle root: the single most valuable field to an attacker. - bytes[SIGNED_LEN - 40] ^= 0x01; - assert!(matches!( - RootRecord::decode_and_verify(&bytes, &keys, 3), - Err(FsError::Integrity(_)) - )); - } - - /// The forged-record case: an attacker signs their own record and ships - /// the matching public key. Verifying against the key in the record would - /// accept it; verifying against the key we derived does not. - #[test] - fn a_record_signed_by_another_key_is_refused() { - let ours = keys_for(11); - let theirs = keys_for(22); - let forged = seal(&theirs, 3, None); - assert!(matches!( - RootRecord::decode_and_verify(&forged.encode().unwrap(), &ours, 3), - Err(FsError::Integrity("root: signed by an unexpected key")) - )); - } - - /// A perfectly valid record replayed to a different key. - #[test] - fn a_record_replayed_to_another_key_is_refused() { - let keys = keys_for(11); - let r = seal(&keys, 3, None); - assert!(matches!( - RootRecord::decode_and_verify(&r.encode().unwrap(), &keys, 7), - Err(FsError::Integrity("root: sequence does not match its key")) - )); - } - - #[test] - fn malformed_records_are_refused() { - let keys = keys_for(11); - let r = seal(&keys, 0, None); - - assert!(RootRecord::decode_and_verify(&[], &keys, 0).is_err()); - assert!(RootRecord::decode_and_verify(&[0u8; ROOT_LEN], &keys, 0).is_err()); - - let mut short = r.encode().unwrap(); - short.pop(); - assert!(RootRecord::decode_and_verify(&short, &keys, 0).is_err()); - - let mut bad_magic = r.encode().unwrap(); - bad_magic[0] ^= 0xff; - assert!(matches!( - RootRecord::decode_and_verify(&bad_magic, &keys, 0), - Err(FsError::Integrity("root: bad magic")) - )); - - let mut bad_version = r.encode().unwrap(); - bad_version[11] = 99; - assert!(matches!( - RootRecord::decode_and_verify(&bad_version, &keys, 0), - Err(FsError::Integrity("root: unsupported format version")) - )); - } - - #[test] - fn a_record_from_another_filesystem_is_refused() { - let master = MasterSecret::from_bytes([11u8; 32]); - let ours = KeyMaterial::derive(&master, [1u8; 16]).unwrap(); - let theirs = KeyMaterial::derive(&master, [2u8; 16]).unwrap(); - let r = seal(&theirs, 0, None); - // Same master secret, different filesystem: the derived signing key - // differs, so this is caught at the signature. - assert!(RootRecord::decode_and_verify(&r.encode().unwrap(), &ours, 0).is_err()); - } - - // ---- publishing -------------------------------------------------------- - - #[tokio::test] - async fn publish_then_load() { - let (_b, keys, rs) = setup(); - let r = seal(&keys, 0, None); - rs.publish(&r).await.unwrap(); - assert_eq!(rs.load(0).await.unwrap(), r); - } - - /// Exactly one writer can take a sequence number. The loser must fail, not - /// retry: a retry would reuse the transaction group number and with it - /// every AEAD nonce in the commit. - #[tokio::test] - async fn only_one_writer_can_claim_a_sequence() { - let (_b, keys, rs) = setup(); - let first = seal(&keys, 0, None); - rs.publish(&first).await.unwrap(); - - // A different record competing for the same sequence. - let second = RootRecord::seal(&keys, 0, None, 1, 9999, meta(), 2).unwrap(); - assert!(matches!(rs.publish(&second).await, Err(FsError::Conflict))); - - // The winner's record is what remains. - assert_eq!(rs.load(0).await.unwrap(), first); - } - - /// A delete marker makes a taken sequence look free — `If-None-Match: *` - /// tests the current version, and the marker is it — so the second PUT - /// goes through. Publishing must lose anyway. This is the one place every - /// root goes through, so it covers a commit as much as a claim: a commit - /// that "won" over a hidden claim would go on sealing under the number - /// that claim's mount is about to use. - #[tokio::test] - async fn a_hidden_record_still_holds_its_sequence() { - let (backend, keys, rs) = setup(); - let first = seal(&keys, 0, None); - rs.publish(&first).await.unwrap(); - backend.delete_blob(&rs.config.root_key(0)).await.unwrap(); - - let second = RootRecord::seal(&keys, 0, None, 1, 9999, meta(), 2).unwrap(); - assert!(matches!(rs.publish(&second).await, Err(FsError::Conflict))); - assert_eq!(rs.load(0).await.unwrap(), first); - } - - /// Retention makes a record impossible to *destroy*, not impossible to - /// hide: a `DeleteObject` without a version id succeeds and writes a delete - /// marker over it. What matters is that the store looks past one. - #[tokio::test] - async fn a_published_root_can_be_hidden_but_still_loads() { - let (backend, keys, rs) = setup(); - let r = seal(&keys, 0, None); - rs.publish(&r).await.unwrap(); - - let key = rs.config.root_key(0); - backend - .delete_blob(&key) - .await - .expect("S3 permits a delete marker over a retained version"); - assert!( - matches!(backend.get_blob(&key, None).await, Err(FsError::NotFound)), - "the marker hides it from an ordinary read" - ); - - assert!(matches!( - backend - .put_blob(PutBlobInput::new(key, Bytes::from_static(b"forged"))) - .await, - Err(FsError::AccessDenied) - )); - assert_eq!(rs.load(0).await.unwrap(), r, "and the store still finds it"); - } - - // ---- tip discovery ----------------------------------------------------- - - #[tokio::test] - async fn an_unformatted_bucket_has_no_tip() { - let (_b, _k, rs) = setup(); - assert_eq!(rs.find_tip().await.unwrap(), None); - assert_eq!(rs.mount(None).await.unwrap(), None); - } - - #[tokio::test] - async fn tip_is_found_at_every_chain_length() { - for n in [1u64, 2, 3, 7, 8, 9, 33, 64] { - let (_b, keys, rs) = setup(); - publish_chain(&rs, &keys, n).await; - assert_eq!(rs.find_tip().await.unwrap(), Some(n - 1), "chain of {n}"); - } - } - - #[tokio::test] - async fn tip_is_found_without_any_hint() { - let (backend, keys, rs) = setup(); - publish_chain(&rs, &keys, 20).await; - backend - .delete_blob(&rs.config.root_hint_key()) - .await - .unwrap(); - assert_eq!(rs.find_tip().await.unwrap(), Some(19)); - } - - /// The hint chooses where the search starts and nothing else. A lie can - /// slow a mount down; it cannot change which root is accepted. - #[tokio::test] - async fn a_lying_hint_does_not_change_the_tip() { - let (backend, keys, rs) = setup(); - publish_chain(&rs, &keys, 20).await; - let hint_key = rs.config.root_hint_key(); - - for lie in ["0", "5", "999999", "garbage", ""] { - backend - .put_blob(PutBlobInput::new( - hint_key.clone(), - Bytes::from(lie.to_string()), - )) - .await - .unwrap(); - assert_eq!( - rs.find_tip().await.unwrap(), - Some(19), - "hint {lie:?} changed the tip" - ); - } - } - - // ---- mounting ---------------------------------------------------------- - - #[tokio::test] - async fn mount_returns_the_newest_root() { - let (_b, keys, rs) = setup(); - let chain = publish_chain(&rs, &keys, 5).await; - let mounted = rs.mount(None).await.unwrap().unwrap(); - assert_eq!(mounted, chain[4]); - assert_eq!(rs.expected_seq(), 4); - } - - #[tokio::test] - async fn mount_verifies_the_chain() { - let (_b, keys, rs) = setup(); - publish_chain(&rs, &keys, 3).await; - - // A record whose predecessor link is wrong: individually valid, and - // correctly signed, but not a continuation of this history. - let spliced = RootRecord::seal(&keys, 3, None, 99, 0, meta(), 2).unwrap(); - rs.publish(&spliced).await.unwrap(); - - assert!(matches!( - rs.mount(None).await, - Err(FsError::Integrity("root: broken hash chain")) - )); - } - - #[tokio::test] - async fn mount_honours_an_external_floor() { - let (_b, keys, rs) = setup(); - publish_chain(&rs, &keys, 5).await; - - assert!(rs.mount(Some(4)).await.is_ok()); - assert!(matches!( - rs.mount(Some(10)).await, - Err(FsError::Rollback { - expected: 10, - found: 4 - }) - )); - } - - /// The in-session guarantee: once a sequence has been accepted, nothing - /// older is ever served again, whatever the store offers. - #[tokio::test] - async fn an_older_root_is_refused_once_a_newer_one_is_seen() { - let (_b, keys, rs) = setup(); - publish_chain(&rs, &keys, 5).await; - rs.mount(None).await.unwrap(); - assert_eq!(rs.expected_seq(), 4); - - assert!(matches!( - rs.load(2).await, - Err(FsError::Rollback { - expected: 4, - found: 2 - }) - )); - assert!(rs.load(4).await.is_ok()); - } - - #[tokio::test] - async fn the_floor_never_falls() { - let (_b, keys, rs) = setup(); - publish_chain(&rs, &keys, 5).await; - rs.mount(None).await.unwrap(); - rs.raise_floor(2); - assert_eq!(rs.expected_seq(), 4, "the floor must only ever rise"); - } - - #[tokio::test] - async fn publishing_raises_the_floor() { - let (_b, keys, rs) = setup(); - let chain = publish_chain(&rs, &keys, 3).await; - assert_eq!(rs.expected_seq(), 2); - assert!( - rs.load(1).await.is_err(), - "our own history is now behind us" - ); - assert_eq!(rs.load(2).await.unwrap(), chain[2]); - } - - #[tokio::test] - async fn a_full_chain_walk_verifies_every_link() { - let (_b, keys, rs) = setup(); - let chain = publish_chain(&rs, &keys, 10).await; - rs.verify_chain(&chain[9], 9).await.unwrap(); - // Walking past genesis stops rather than failing. - rs.verify_chain(&chain[9], 100).await.unwrap(); - } - - #[tokio::test] - async fn the_merkle_root_is_the_meta_dnode_checksum() { - let keys = keys_for(11); - let r = seal(&keys, 0, None); - assert_eq!(r.merkle_root(), &r.meta_dnode.blkptr.checksum); - } -} diff --git a/crates/s3fs-core/src/store/slab.rs b/crates/s3fs-core/src/store/slab.rs deleted file mode 100644 index ffbacd9..0000000 --- a/crates/s3fs-core/src/store/slab.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Slab packing — how a transaction group's blocks become S3 objects. -//! -//! Every block dirtied in a txg is sealed and appended to a slab buffer. When -//! a slab reaches `slab_max_bytes` a new one starts. At commit time each slab -//! becomes one immutable `slabs//` object. -//! -//! This is where the design earns its keep: a commit costs `ceil(dirty_bytes / -//! slab_max_bytes)` PUTs plus one for the root, whether the txg touched one -//! block or ten thousand. Addressing blocks by content hash instead would cost -//! one PUT per block, which for a SQLite page-write workload means a round -//! trip per 4 KiB. -//! -//! Slabs are write-once and are never revisited: an aborted or crashed commit -//! leaves orphaned slab objects that no root references, which are invisible -//! to readers and reclaimable by lifecycle policy. - -use bytes::Bytes; - -use crate::crypto::aead::{self, BlockAad, BlockNonce}; -use crate::crypto::{Hash256, KeyMaterial}; -use crate::errors::{FsError, FsResult}; - -use super::blkptr::{BlkPtr, Compression, Dva}; -use super::config::StoreConfig; - -/// One finished slab, ready to PUT. -#[derive(Debug, Clone)] -pub struct FinishedSlab { - /// Index within the txg. - pub index: u16, - pub body: Bytes, -} - -/// Accumulates sealed blocks for one transaction group. -/// -/// Holds no key material and performs no I/O; it is a pure byte-packer, which -/// keeps it trivially testable. -#[derive(Debug)] -pub struct SlabWriter { - txg: u64, - slab_max_bytes: usize, - /// Sealed slabs already closed out. - finished: Vec, - /// The slab currently being filled. - current: Vec, - /// Index of `current` within the txg. - current_index: u16, - /// Monotonic counter over every block written in this txg. Combined with - /// the txg it forms the AEAD nonce, so it must increment for every single - /// sealed block regardless of which slab the block lands in. - next_block_seq: u32, -} - -impl SlabWriter { - pub fn new(txg: u64, config: &StoreConfig) -> Self { - Self { - txg, - slab_max_bytes: config.slab_max_bytes, - finished: Vec::new(), - current: Vec::new(), - current_index: 0, - next_block_seq: 0, - } - } - - pub fn txg(&self) -> u64 { - self.txg - } - - /// Total bytes staged so far, across finished and in-progress slabs. - pub fn staged_bytes(&self) -> usize { - self.finished.iter().map(|s| s.body.len()).sum::() + self.current.len() - } - - /// Number of blocks sealed so far. - pub fn block_count(&self) -> u32 { - self.next_block_seq - } - - /// Seal `plaintext` and append it, returning the pointer that addresses it. - /// - /// The AAD is built here rather than taken from the caller so that - /// `birth_txg` is always this writer's txg. That closes the failure mode - /// where a block is sealed under one txg and its pointer records another, - /// which would produce a block that can never be opened. - pub fn write_block( - &mut self, - keys: &KeyMaterial, - objid: u64, - level: u8, - block_index: u64, - fill: u32, - plaintext: &[u8], - ) -> FsResult { - if plaintext.len() > super::blkptr::MAX_BLOCK_LEN as usize { - return Err(FsError::Invalid("block exceeds maximum block length")); - } - - let block_seq = self.next_block_seq; - // 2^32 blocks in one txg is ~500 TiB at the default record size; a txg - // that large means something has gone wrong upstream. Refuse rather - // than wrap, because wrapping would repeat a nonce. - self.next_block_seq = block_seq - .checked_add(1) - .ok_or(FsError::Invalid("too many blocks in one transaction group"))?; - - let nonce = BlockNonce::new(self.txg, block_seq); - let aad = BlockAad { - objid, - level, - block_index, - birth_txg: self.txg, - }; - let sealed = aead::seal(keys.block_key(), nonce, aad, plaintext)?; - - // Start a new slab if this block would overflow the current one. A - // block is never split across slabs — a pointer names one contiguous - // range, and splitting would cost a second GET on every read. - if !self.current.is_empty() && self.current.len() + sealed.len() > self.slab_max_bytes { - self.close_current()?; - } - - let offset = - u32::try_from(self.current.len()).expect("slab_max_bytes is validated to fit in u32"); - let len = u32::try_from(sealed.len()).expect("block length is bounded by MAX_BLOCK_LEN"); - let checksum = Hash256::of(&sealed); - self.current.extend_from_slice(&sealed); - - Ok(BlkPtr { - dva: Dva { - txg: self.txg, - slab: self.current_index, - offset, - len, - }, - logical_len: plaintext.len() as u32, - level, - compression: Compression::None, - fill, - birth_txg: self.txg, - nonce, - checksum, - }) - } - - fn close_current(&mut self) -> FsResult<()> { - let body = Bytes::from(std::mem::take(&mut self.current)); - self.finished.push(FinishedSlab { - index: self.current_index, - body, - }); - self.current_index = self - .current_index - .checked_add(1) - .ok_or(FsError::Invalid("too many slabs in one transaction group"))?; - Ok(()) - } - - /// Close out the in-progress slab and return everything to PUT. - /// - /// An empty txg yields no slabs — a commit that only changed metadata - /// already fitting in existing blocks still writes a root, but need not - /// write any data. - pub fn finish(mut self) -> FsResult> { - if !self.current.is_empty() { - self.close_current()?; - } - Ok(self.finished) - } -} - -/// Decrypt and verify one block read from a slab. -/// -/// Both checks run, in this order, and both must pass: -/// -/// 1. **Checksum against the parent pointer.** This is the Merkle link. It is -/// checked first because it needs no key, so a corrupt or substituted block -/// is rejected before the cipher ever sees it. -/// 2. **AEAD open with position-binding AAD.** This catches a block that is -/// genuine but served from the wrong place in the tree. -pub fn verify_and_open( - keys: &KeyMaterial, - ptr: &BlkPtr, - objid: u64, - block_index: u64, - stored: &[u8], -) -> FsResult> { - if stored.len() != ptr.dva.len as usize { - return Err(FsError::Integrity("block: short read from slab")); - } - Hash256::of(stored).verify(&ptr.checksum, "block: checksum mismatch")?; - - let aad = BlockAad { - objid, - level: ptr.level, - block_index, - birth_txg: ptr.birth_txg, - }; - let plaintext = aead::open(keys.block_key(), ptr.nonce, aad, stored)?; - if plaintext.len() != ptr.logical_len as usize { - return Err(FsError::Integrity("block: logical length mismatch")); - } - Ok(plaintext) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::crypto::MasterSecret; - - fn keys() -> KeyMaterial { - KeyMaterial::derive(&MasterSecret::from_bytes([3u8; 32]), [0u8; 16]).unwrap() - } - - fn config() -> StoreConfig { - StoreConfig::default() - } - - /// Pull a block's stored bytes back out of the slab it was packed into. - fn extract(slabs: &[FinishedSlab], ptr: &BlkPtr) -> Vec { - let slab = slabs - .iter() - .find(|s| s.index == ptr.dva.slab) - .expect("slab exists"); - let r = ptr.dva.range(); - slab.body[r.start as usize..r.end as usize].to_vec() - } - - #[test] - fn write_then_read_back_round_trips() { - let k = keys(); - let mut w = SlabWriter::new(7, &config()); - let data = vec![0xabu8; 4096]; - let ptr = w.write_block(&k, 10, 0, 0, 1, &data).unwrap(); - let slabs = w.finish().unwrap(); - - assert_eq!(slabs.len(), 1); - assert_eq!(ptr.dva.txg, 7); - assert_eq!(ptr.birth_txg, 7); - assert_eq!(ptr.logical_len, 4096); - assert_eq!(ptr.dva.len, 4096 + crate::crypto::TAG_LEN as u32); - - let stored = extract(&slabs, &ptr); - assert_eq!(verify_and_open(&k, &ptr, 10, 0, &stored).unwrap(), data); - } - - #[test] - fn blocks_are_packed_contiguously() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let a = w.write_block(&k, 1, 0, 0, 1, &[1u8; 100]).unwrap(); - let b = w.write_block(&k, 1, 0, 1, 1, &[2u8; 200]).unwrap(); - - assert_eq!(a.dva.offset, 0); - assert_eq!(b.dva.offset, a.dva.len, "second block follows the first"); - assert_eq!(a.dva.slab, b.dva.slab); - - let slabs = w.finish().unwrap(); - assert_eq!(slabs[0].body.len() as u32, a.dva.len + b.dva.len); - } - - #[test] - fn empty_txg_produces_no_slabs() { - let w = SlabWriter::new(1, &config()); - assert!(w.finish().unwrap().is_empty()); - } - - #[test] - fn oversized_txg_rolls_to_a_new_slab() { - let k = keys(); - // A stored block is 4096 + 16 bytes of AEAD tag = 4112, so a cap of - // 8300 admits exactly two per slab and the third must roll over. - const STORED: u32 = 4096 + crate::crypto::TAG_LEN as u32; - let cfg = StoreConfig { - slab_max_bytes: 2 * STORED as usize + 76, - record_size: 4096, - ..Default::default() - }; - let mut w = SlabWriter::new(1, &cfg); - - let ptrs: Vec<_> = (0..3) - .map(|i| w.write_block(&k, 1, 0, i, 1, &[i as u8; 4096]).unwrap()) - .collect(); - - assert_eq!((ptrs[0].dva.slab, ptrs[0].dva.offset), (0, 0)); - assert_eq!((ptrs[1].dva.slab, ptrs[1].dva.offset), (0, STORED)); - assert_eq!( - (ptrs[2].dva.slab, ptrs[2].dva.offset), - (1, 0), - "a new slab restarts the offset" - ); - - let slabs = w.finish().unwrap(); - assert_eq!(slabs.len(), 2); - for (i, p) in ptrs.iter().enumerate() { - let stored = extract(&slabs, p); - assert_eq!( - verify_and_open(&k, p, 1, i as u64, &stored).unwrap(), - vec![i as u8; 4096], - "block {i} failed to verify" - ); - } - } - - /// A block never straddles two slabs, because a pointer names exactly one - /// contiguous range and a split would double the reads. - #[test] - fn a_block_is_never_split_across_slabs() { - let k = keys(); - let cfg = StoreConfig { - slab_max_bytes: 5000, - record_size: 4096, - ..Default::default() - }; - let mut w = SlabWriter::new(1, &cfg); - let a = w.write_block(&k, 1, 0, 0, 1, &[0u8; 4096]).unwrap(); - let b = w.write_block(&k, 1, 0, 1, 1, &[0u8; 4096]).unwrap(); - assert_ne!(a.dva.slab, b.dva.slab); - - let slabs = w.finish().unwrap(); - for (s, p) in slabs.iter().zip([a, b]) { - assert_eq!(s.body.len(), p.dva.len as usize); - } - } - - /// The nonce-uniqueness invariant, checked at the level where it is - /// actually established: the sequence counter spans the whole txg, so - /// rolling to a new slab must not restart it. - #[test] - fn block_sequence_is_unique_across_the_whole_txg() { - let k = keys(); - let cfg = StoreConfig { - slab_max_bytes: 8192, - record_size: 4096, - ..Default::default() - }; - let mut w = SlabWriter::new(1, &cfg); - let mut nonces = std::collections::HashSet::new(); - for i in 0..10u64 { - let p = w.write_block(&k, 1, 0, i, 1, &[0u8; 4096]).unwrap(); - assert!( - nonces.insert(*p.nonce.as_bytes()), - "nonce reused at block {i}" - ); - } - assert_eq!(w.block_count(), 10); - } - - #[test] - fn identical_plaintext_in_one_txg_gets_distinct_ciphertext() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let a = w.write_block(&k, 1, 0, 0, 1, &[7u8; 1024]).unwrap(); - let b = w.write_block(&k, 1, 0, 1, 1, &[7u8; 1024]).unwrap(); - assert_ne!( - a.checksum, b.checksum, - "distinct nonces must yield distinct ciphertext" - ); - } - - #[test] - fn staged_bytes_tracks_both_finished_and_current() { - let k = keys(); - let cfg = StoreConfig { - slab_max_bytes: 8192, - record_size: 4096, - ..Default::default() - }; - let mut w = SlabWriter::new(1, &cfg); - assert_eq!(w.staged_bytes(), 0); - w.write_block(&k, 1, 0, 0, 1, &[0u8; 4096]).unwrap(); - assert_eq!(w.staged_bytes(), 4096 + 16); - w.write_block(&k, 1, 0, 1, 1, &[0u8; 4096]).unwrap(); - w.write_block(&k, 1, 0, 2, 1, &[0u8; 4096]).unwrap(); - assert_eq!(w.staged_bytes(), 3 * (4096 + 16)); - } - - // ---- verification failures --------------------------------------------- - - #[test] - fn rejects_corrupted_bytes() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let ptr = w.write_block(&k, 1, 0, 0, 1, b"payload").unwrap(); - let slabs = w.finish().unwrap(); - - let mut stored = extract(&slabs, &ptr); - stored[0] ^= 0x01; - assert!(matches!( - verify_and_open(&k, &ptr, 1, 0, &stored), - Err(FsError::Integrity("block: checksum mismatch")) - )); - } - - #[test] - fn rejects_short_read() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let ptr = w.write_block(&k, 1, 0, 0, 1, b"payload").unwrap(); - let slabs = w.finish().unwrap(); - - let stored = extract(&slabs, &ptr); - assert!(matches!( - verify_and_open(&k, &ptr, 1, 0, &stored[..stored.len() - 1]), - Err(FsError::Integrity("block: short read from slab")) - )); - } - - /// The substitution attack: swap two genuine, correctly-checksummed blocks - /// and adjust the pointer to match. The checksum passes; the AAD does not. - #[test] - fn rejects_a_genuine_block_moved_to_another_position() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let a = w.write_block(&k, 1, 0, 0, 1, b"block a").unwrap(); - let b = w.write_block(&k, 7, 0, 3, 1, b"block b").unwrap(); - let slabs = w.finish().unwrap(); - - // Serve block b's bytes under a pointer that has b's checksum but - // claims a's position. The Merkle check passes. - let stored_b = extract(&slabs, &b); - assert!(Hash256::of(&stored_b).verify(&b.checksum, "x").is_ok()); - - // Wrong objid. - assert!(verify_and_open(&k, &b, 1, 3, &stored_b).is_err()); - // Wrong block index. - assert!(verify_and_open(&k, &b, 7, 0, &stored_b).is_err()); - // Right position still works. - assert!(verify_and_open(&k, &b, 7, 3, &stored_b).is_ok()); - assert!(verify_and_open(&k, &a, 1, 0, &extract(&slabs, &a)).is_ok()); - } - - #[test] - fn rejects_wrong_key() { - let mut w = SlabWriter::new(1, &config()); - let ptr = w.write_block(&keys(), 1, 0, 0, 1, b"payload").unwrap(); - let slabs = w.finish().unwrap(); - - let other = KeyMaterial::derive(&MasterSecret::from_bytes([9u8; 32]), [0u8; 16]).unwrap(); - assert!(verify_and_open(&other, &ptr, 1, 0, &extract(&slabs, &ptr)).is_err()); - } - - #[test] - fn rejects_tampered_logical_length() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let mut ptr = w.write_block(&k, 1, 0, 0, 1, b"payload").unwrap(); - let slabs = w.finish().unwrap(); - let stored = extract(&slabs, &ptr); - - ptr.logical_len -= 1; - assert!(matches!( - verify_and_open(&k, &ptr, 1, 0, &stored), - Err(FsError::Integrity("block: logical length mismatch")) - )); - } - - #[test] - fn zero_length_block_round_trips() { - let k = keys(); - let mut w = SlabWriter::new(1, &config()); - let ptr = w.write_block(&k, 1, 0, 0, 0, b"").unwrap(); - let slabs = w.finish().unwrap(); - assert_eq!(ptr.logical_len, 0); - assert!(verify_and_open(&k, &ptr, 1, 0, &extract(&slabs, &ptr)) - .unwrap() - .is_empty()); - } - - #[test] - fn pointers_decode_after_a_round_trip_through_bytes() { - let k = keys(); - let mut w = SlabWriter::new(42, &config()); - let ptr = w.write_block(&k, 5, 2, 9, 3, &[1u8; 1000]).unwrap(); - assert_eq!(BlkPtr::decode(&ptr.encode()).unwrap(), ptr); - assert!(!ptr.is_hole()); - } -} diff --git a/crates/s3fs-core/src/store/txg.rs b/crates/s3fs-core/src/store/txg.rs deleted file mode 100644 index a2479e2..0000000 --- a/crates/s3fs-core/src/store/txg.rs +++ /dev/null @@ -1,1151 +0,0 @@ -//! Transaction groups — the commit protocol, and the mounted filesystem. -//! -//! A commit is the only way state changes, and it is atomic at exactly one -//! point: the conditional PUT of the root record. -//! -//! ```text -//! 0. first commit of a session only: claim a transaction group (below) -//! 1. consume a transaction group number (never reused, see below) -//! 2. rebuild the dnode array copy-on-write (staged in memory) -//! 3. PUT every slab, and wait for all of them ← durability barrier -//! 4. seal and sign a root record -//! 5. PUT roots/ with If-None-Match: * ← the atomic commit point -//! ``` -//! -//! **Crash consistency** falls out of the ordering. Slabs written without a -//! root are orphans: nothing references them, no reader can reach them, and -//! they are reclaimable by lifecycle policy. A root that exists implies every -//! block it names was already durable, because step 3 completed before step 4 -//! began. There is no journal and no fsck. -//! -//! **Losing step 5 is fatal, not retryable.** Another writer took the sequence -//! number. Retrying would reuse this transaction group, and with it every AEAD -//! nonce in the commit — which for AES-GCM leaks the authentication subkey. -//! So the mount is poisoned and every later operation fails. -//! -//! ## Transaction group numbers are never reused -//! -//! This is the invariant the whole encryption scheme rests on: a block nonce -//! is `(txg, seq)` under one key, so sealing two different blocks under the -//! same transaction group is the GCM nonce-reuse break. Two things threaten -//! it, and both come from a number being burned without a root recording it: -//! a crash *between* steps 3 and 4, and a second mount opened at the same tip. -//! A naive remount, or the second mount, would resume at `tip + 1` — the very -//! number whose nonces the orphaned slabs already spent. -//! -//! The rule that closes both: **every burned transaction group is at most one -//! past the highest published root.** A session earns the right to seal -//! blocks by first publishing a *claim* — a root that names `tip + 1` and no -//! new blocks — and after any transaction that did not end in a published -//! root, whether it failed or was simply dropped, it must claim again before -//! sealing under the next number. The claim goes through the same -//! [`RootStore::publish`] as a commit, so of two mounts at one tip exactly one -//! claims — a delete marker over the winner's claim included — and the other -//! is poisoned before it has encrypted a byte. And because the rule is -//! enforced in the locked root chain, nothing the host can delete from the -//! data bucket weakens it. - -use std::collections::BTreeMap; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -use tokio::sync::Mutex; - -use crate::backend::Backend; -use crate::crypto::KeyMaterial; -use crate::errors::{FsError, FsResult}; - -use super::blockstore::BlockStore; -use super::config::StoreConfig; -use super::dnode::{Dnode, DnodeKind, ROOT_OBJID}; -use super::objset::ObjectSet; -use super::root::{RootRecord, RootStore}; -use super::slab::SlabWriter; - -/// Default permissions for the root directory. -const ROOT_DIR_MODE: u32 = 0o040755; - -fn now_nanos() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0) -} - -#[derive(Debug)] -struct State { - objset: ObjectSet, - root: RootRecord, - next_txg: u64, - /// Whether the root chain already records `next_txg - 1`, so sealing - /// under `next_txg` cannot collide with anything an earlier session or a - /// rival mount burned. True only when the last number handed out ended - /// in a published root: false at mount, and from `begin` until `commit`. - claimed: bool, - /// Once set, every operation fails with this. A poisoned mount has either - /// lost a race for its sequence number or seen the store misbehave; in - /// both cases continuing would risk silently diverging from what is - /// actually committed. - poison: Option, -} - -/// A mounted filesystem: the block store, the root chain, and the currently -/// committed object set. -#[derive(Debug)] -pub struct Store { - blocks: Arc, - roots: Arc, - keys: Arc, - state: Mutex, -} - -impl Store { - /// Mount an existing filesystem. Fails with [`FsError::NoFilesystem`] if - /// the store is empty. - /// - /// It used to format in that case, and that was the single most dangerous - /// line in the crate: an enclave pointed at a store that answered - /// "nothing" created a fresh filesystem and served it. Every check below - /// then passed — the signature, the hash chain, the Merkle root — because - /// they all attested to the *new* filesystem, while the guest saw an empty - /// database where its data should have been. - /// - /// Creating a filesystem is now something a caller has to ask for by name, - /// because only the caller can know whether it is entitled to. Inside an - /// enclave that entitlement is a state-origin receipt. - pub async fn open_existing( - data: Arc, - roots: Arc, - keys: Arc, - config: Arc, - min_seq: Option, - ) -> FsResult { - config.validate()?; - let blocks = Arc::new(BlockStore::new(data, keys.clone(), config.clone())); - let root_store = Arc::new(RootStore::new(roots, keys.clone(), config.clone())); - - match root_store.mount(min_seq).await? { - Some(root) => Store::from_root(blocks, root_store, keys, root).await, - None => Err(FsError::NoFilesystem), - } - } - - /// Create a brand-new filesystem in an empty store. - /// - /// Refuses if one already exists, so a caller that has misjudged which - /// mode it is in cannot overwrite a filesystem it should have mounted. - pub async fn create( - data: Arc, - roots: Arc, - keys: Arc, - config: Arc, - ) -> FsResult { - config.validate()?; - let blocks = Arc::new(BlockStore::new(data, keys.clone(), config.clone())); - let root_store = Arc::new(RootStore::new(roots, keys.clone(), config.clone())); - - if root_store.mount(None).await?.is_some() { - return Err(FsError::AlreadyExists); - } - Store::format(blocks, root_store, keys, config).await - } - - async fn from_root( - blocks: Arc, - roots: Arc, - keys: Arc, - root: RootRecord, - ) -> FsResult { - let objset = ObjectSet::from_root(root.meta_dnode.clone(), root.next_objid)?; - let next_txg = root - .txg - .checked_add(1) - .ok_or(FsError::Invalid("transaction group space exhausted"))?; - Ok(Store { - blocks, - roots, - keys, - state: Mutex::new(State { - objset, - root, - next_txg, - claimed: false, - poison: None, - }), - }) - } - - /// Create a brand-new filesystem: an empty root directory, committed as - /// root record 0. - async fn format( - blocks: Arc, - roots: Arc, - keys: Arc, - config: Arc, - ) -> FsResult { - let shift = config.record_shift(); - let now = now_nanos(); - let txg = 1; - - let mut root_dir = Dnode::new(ROOT_OBJID, DnodeKind::Dir, shift, now); - root_dir.mode = ROOT_DIR_MODE; - - let mut writer = SlabWriter::new(txg, &config); - let objset = ObjectSet::format(shift) - .commit( - &blocks, - &mut writer, - BTreeMap::from([(ROOT_OBJID, root_dir)]), - ) - .await?; - blocks.write_slabs(txg, writer.finish()?).await?; - - let root = RootRecord::seal( - &keys, - 0, - None, - txg, - now, - objset.meta().clone(), - objset.next_objid(), - )?; - roots.publish(&root).await?; - - Ok(Store { - blocks, - roots, - keys, - state: Mutex::new(State { - objset, - root, - next_txg: txg + 1, - claimed: true, - poison: None, - }), - }) - } - - pub fn blocks(&self) -> &Arc { - &self.blocks - } - - pub fn roots(&self) -> &Arc { - &self.roots - } - - /// Snapshot of the committed object set. Reads go through this. - pub async fn objset(&self) -> FsResult { - let st = self.state.lock().await; - st.check()?; - Ok(st.objset.clone()) - } - - /// The currently committed root record. - /// Fetch a historical root without disturbing the session floor. - /// - /// The boot machine needs the seq-0 record to identify *which history* - /// this filesystem is, and that record is by definition below the floor. - pub async fn snapshot_root(&self, seq: u64) -> FsResult { - self.roots.load_snapshot(seq).await - } - - pub async fn root(&self) -> FsResult { - let st = self.state.lock().await; - st.check()?; - Ok(st.root.clone()) - } - - /// Claim an object id for a new file, directory, or symlink. - pub async fn reserve_objid(&self) -> FsResult { - let mut st = self.state.lock().await; - st.check()?; - st.objset.alloc_objid() - } - - /// Whether this mount has been poisoned, and why. - pub async fn poison(&self) -> Option { - self.state.lock().await.poison.clone() - } - - /// Open a past state for reading. - /// - /// Every root record ever committed is a complete, self-verifying snapshot - /// — copy-on-write means the blocks it names were never overwritten. So a - /// snapshot costs nothing to keep and nothing to take; it is simply an - /// older root that was never deleted. - /// - /// Read-only by construction: a [`Snapshot`] has no transaction, and - /// opening one does not disturb the live mount or its rollback floor. - pub async fn snapshot(&self, seq: u64) -> FsResult { - let root = self.roots.load_snapshot(seq).await?; - let objset = ObjectSet::from_root(root.meta_dnode.clone(), root.next_objid)?; - Ok(Snapshot { root, objset }) - } - - /// The newest `limit` root records, most recent first. - pub async fn list_snapshots(&self, limit: usize) -> FsResult> { - if limit == 0 { - return Ok(Vec::new()); - } - let tip = self.root().await?.seq; - let mut out = Vec::new(); - let mut seq = tip; - loop { - out.push(self.roots.load_snapshot(seq).await?); - if seq == 0 || out.len() >= limit { - return Ok(out); - } - seq -= 1; - } - } - - /// Publish a root naming `next_txg` and no new blocks, so that number is - /// on record before anything is sealed under the one after it. - /// - /// Losing the race for the sequence number means another mount is live at - /// this tip; that is fatal for the same reason losing a commit is, except - /// that here nothing has been encrypted yet, which is the point. - async fn claim(&self, state: &mut State) -> FsResult<()> { - let txg = state.next_txg; - let root = RootRecord::seal( - &self.keys, - state.root.seq + 1, - Some(&state.root), - txg, - now_nanos(), - state.objset.meta().clone(), - state.objset.next_objid(), - )?; - if let Err(e) = self.roots.publish(&root).await { - if !e.is_transient() { - state.poison = Some(e.clone()); - } - return Err(e); - } - state.root = root; - state.next_txg = txg - .checked_add(1) - .ok_or(FsError::Invalid("transaction group space exhausted"))?; - state.claimed = true; - Ok(()) - } - - /// Begin a transaction. - /// - /// The returned handle holds the store's lock, so transactions serialise. - /// It also owns the transaction group's slab writer: object blocks and the - /// dnodes naming them **must** be staged through the same transaction, or - /// the root would reference blocks written under a different transaction - /// group — a number that may never have been committed at all. - pub async fn begin(&self) -> FsResult> { - let mut state = self.state.lock().await; - state.check()?; - if !state.claimed { - self.claim(&mut state).await?; - } - - // Consume the transaction group up front and never give it back. An - // abandoned transaction burns its number, which is exactly right: - // nothing may ever reuse it. - let txg = state.next_txg; - state.next_txg = txg - .checked_add(1) - .ok_or(FsError::Invalid("transaction group space exhausted"))?; - // Handed out, not yet on record. Stays false if this transaction is - // dropped or fails, and the next `begin` claims before sealing. - state.claimed = false; - - Ok(Transaction { - store: self, - writer: SlabWriter::new(txg, self.blocks.config()), - state, - txg, - dirty: BTreeMap::new(), - }) - } - - /// Commit a set of modified dnodes whose blocks are already durable. - /// - /// Convenience for callers that change only dnode metadata. Anything that - /// writes object blocks must use [`Store::begin`] instead. - pub async fn commit(&self, dirty: BTreeMap) -> FsResult { - if dirty.is_empty() { - return self.root().await; - } - let mut txn = self.begin().await?; - for (_, d) in dirty { - txn.stage(d); - } - txn.commit().await - } -} - -/// A past state of the filesystem, opened read-only. -#[derive(Debug, Clone)] -pub struct Snapshot { - /// The root record this snapshot was taken from. - pub root: RootRecord, - objset: ObjectSet, -} - -impl Snapshot { - pub fn objset(&self) -> &ObjectSet { - &self.objset - } -} - -/// One transaction group in progress. -/// -/// Stage object blocks through [`Transaction::writer`] and the dnodes that -/// name them through [`Transaction::stage`], then [`Transaction::commit`]. -/// Dropping without committing abandons the work: nothing was published, and -/// the staged blocks were never written. -#[derive(Debug)] -pub struct Transaction<'a> { - store: &'a Store, - state: tokio::sync::MutexGuard<'a, State>, - txg: u64, - writer: SlabWriter, - dirty: BTreeMap, -} - -impl Transaction<'_> { - pub fn txg(&self) -> u64 { - self.txg - } - - pub fn blocks(&self) -> &Arc { - &self.store.blocks - } - - /// The slab writer for this transaction group. - pub fn writer(&mut self) -> &mut SlabWriter { - &mut self.writer - } - - /// The committed object set this transaction builds on. - pub fn objset(&self) -> &ObjectSet { - &self.state.objset - } - - /// The root record this transaction will supersede. - pub fn base_root(&self) -> &RootRecord { - &self.state.root - } - - /// Claim an object id for a new object. - pub fn reserve_objid(&mut self) -> FsResult { - self.state.objset.alloc_objid() - } - - /// Record a modified dnode. - pub fn stage(&mut self, dnode: Dnode) { - self.dirty.insert(dnode.objid, dnode); - } - - /// Whether anything at all has been staged. - pub fn is_empty(&self) -> bool { - self.dirty.is_empty() && self.writer.block_count() == 0 - } - - /// Publish. See the module documentation for the ordering and why losing - /// the final conditional PUT is fatal rather than retryable. - pub async fn commit(mut self) -> FsResult { - if self.is_empty() { - return Ok(self.state.root.clone()); - } - match self.run().await { - Ok((objset, root)) => { - self.state.objset = objset; - self.state.root = root.clone(); - self.state.claimed = true; - Ok(root) - } - Err(e) => { - // A transient failure before the root PUT leaves the store - // exactly as it was — the slabs are orphans — so the mount - // stays usable and the caller may retry with a fresh - // transaction group, once the next `begin` has claimed it: - // this one was burned with no root to say so. Anything else - // means we no longer know what is committed. - if !e.is_transient() { - self.state.poison = Some(e.clone()); - } - Err(e) - } - } - } - - async fn run(&mut self) -> FsResult<(ObjectSet, RootRecord)> { - let blocks = &self.store.blocks; - let dirty = std::mem::take(&mut self.dirty); - let objset = self - .state - .objset - .commit(blocks, &mut self.writer, dirty) - .await?; - - // Durability barrier: every block the root will name must be readable - // before the root exists, or a crash here would publish a root - // pointing at data that was never written. - let slabs = std::mem::replace(&mut self.writer, SlabWriter::new(self.txg, blocks.config())) - .finish()?; - blocks.write_slabs(self.txg, slabs).await?; - - let root = RootRecord::seal( - &self.store.keys, - self.state.root.seq + 1, - Some(&self.state.root), - self.txg, - now_nanos(), - objset.meta().clone(), - objset.next_objid(), - )?; - self.store.roots.publish(&root).await?; - Ok((objset, root)) - } -} - -impl State { - fn check(&self) -> FsResult<()> { - match &self.poison { - Some(e) => Err(e.clone()), - None => Ok(()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::memory::MemoryBackend; - use crate::backend::PutBlobInput; - use crate::crypto::MasterSecret; - use crate::store::dir::{DirTxn, Dirent}; - use bytes::Bytes; - - struct Harness { - data: Arc, - roots: Arc, - keys: Arc, - config: Arc, - } - - impl Harness { - fn new() -> Self { - Harness { - data: Arc::new(MemoryBackend::new()), - roots: Arc::new(MemoryBackend::new()), - keys: Arc::new( - KeyMaterial::derive(&MasterSecret::from_bytes([13u8; 32]), [3u8; 16]).unwrap(), - ), - config: Arc::new(StoreConfig { - record_size: 4096, - root_retention: Some(std::time::Duration::from_secs(3600)), - ..Default::default() - }), - } - } - - /// Create on first use, mount thereafter — what `Store::open` used to - /// do implicitly, now spelled out because the implicit version was the - /// bug. - async fn open(&self) -> FsResult { - match self.mount(None).await { - Err(FsError::NoFilesystem) => { - Store::create( - self.data.clone(), - self.roots.clone(), - self.keys.clone(), - self.config.clone(), - ) - .await - } - other => other, - } - } - - async fn mount(&self, min_seq: Option) -> FsResult { - Store::open_existing( - self.data.clone(), - self.roots.clone(), - self.keys.clone(), - self.config.clone(), - min_seq, - ) - .await - } - } - - fn file(objid: u64, size: u64) -> Dnode { - Dnode { - size, - ..Dnode::new(objid, DnodeKind::File, 12, 0) - } - } - - #[tokio::test] - async fn formatting_creates_a_root_directory() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - - let root = store.root().await.unwrap(); - assert_eq!(root.seq, 0); - assert!(root.prev_root_hash.is_zero(), "genesis has no predecessor"); - - let objset = store.objset().await.unwrap(); - let dir = objset - .get_allocated(store.blocks(), ROOT_OBJID) - .await - .unwrap(); - assert_eq!(dir.kind, DnodeKind::Dir); - assert_eq!(dir.mode, ROOT_DIR_MODE); - } - - #[tokio::test] - async fn reopening_finds_the_same_filesystem() { - let h = Harness::new(); - let first = h.open().await.unwrap(); - let root = first.root().await.unwrap(); - drop(first); - - let second = h.open().await.unwrap(); - assert_eq!(second.root().await.unwrap(), root, "must not re-format"); - } - - #[tokio::test] - async fn each_commit_advances_the_sequence_and_the_merkle_root() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let genesis = store.root().await.unwrap(); - - let objid = store.reserve_objid().await.unwrap(); - let r1 = store - .commit(BTreeMap::from([(objid, file(objid, 100))])) - .await - .unwrap(); - assert_eq!(r1.seq, 1); - assert_eq!(r1.prev_root_hash, genesis.hash()); - assert_ne!(r1.merkle_root(), genesis.merkle_root()); - assert!(r1.txg > genesis.txg); - - let r2 = store - .commit(BTreeMap::from([(objid, file(objid, 200))])) - .await - .unwrap(); - assert_eq!(r2.seq, 2); - assert_eq!(r2.prev_root_hash, r1.hash()); - assert_ne!(r2.merkle_root(), r1.merkle_root()); - } - - #[tokio::test] - async fn committed_state_survives_a_remount() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 4242))])) - .await - .unwrap(); - drop(store); - - let store = h.open().await.unwrap(); - let objset = store.objset().await.unwrap(); - let d = objset.get_allocated(store.blocks(), objid).await.unwrap(); - assert_eq!(d.size, 4242); - } - - #[tokio::test] - async fn an_empty_commit_costs_nothing() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let before = store.root().await.unwrap(); - let objects_before = h.roots.object_count(); - - let after = store.commit(BTreeMap::new()).await.unwrap(); - assert_eq!(after, before); - assert_eq!(h.roots.object_count(), objects_before, "no root published"); - } - - #[tokio::test] - async fn a_directory_survives_a_remount() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - - let mut txn = store.begin().await.unwrap(); - // Own the handle rather than borrowing it from the transaction, so - // reads can interleave with staging blocks into the same transaction. - let blocks = txn.blocks().clone(); - let objid = txn.reserve_objid().unwrap(); - let dir = txn - .objset() - .get_allocated(&blocks, ROOT_OBJID) - .await - .unwrap(); - - let mut dirtxn = DirTxn::load(&blocks, &dir).await.unwrap(); - dirtxn - .insert(Dirent { - name: "hello.txt".into(), - objid, - kind: DnodeKind::File, - }) - .await - .unwrap(); - - // The directory's blocks and the dnodes naming them belong to one - // transaction group. Staging them separately would leave the root - // pointing at blocks from a group that may never be committed. - let updated_dir = dirtxn.finish(txn.writer()).await.unwrap(); - txn.stage(updated_dir); - txn.stage(file(objid, 5)); - txn.commit().await.unwrap(); - drop(store); - - let store = h.open().await.unwrap(); - let objset = store.objset().await.unwrap(); - let dir = objset - .get_allocated(store.blocks(), ROOT_OBJID) - .await - .unwrap(); - let mut txn = DirTxn::load(store.blocks(), &dir).await.unwrap(); - assert_eq!( - txn.lookup("hello.txt").await.unwrap().map(|e| e.objid), - Some(objid) - ); - } - - // ---- transaction group discipline -------------------------------------- - - /// The crash that matters: slabs written, no root published. Resuming at - /// the tip's transaction group plus one would land on the number whose - /// nonces were already spent. - #[tokio::test] - async fn a_crash_between_slabs_and_root_does_not_reuse_a_transaction_group() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - - // The real thing: slabs land, the root PUT dies, the process dies. - h.roots.fail_next_put(FsError::IoTimeout); - let objid = store.reserve_objid().await.unwrap(); - assert!(store - .commit(BTreeMap::from([(objid, file(objid, 2))])) - .await - .is_err()); - let orphan_txg = store.state.lock().await.next_txg - 1; - let orphan_key = h.config.slab_key(orphan_txg, 0); - let orphan = h.data.get_blob(&orphan_key, None).await.unwrap().body; - drop(store); - - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - let root = store - .commit(BTreeMap::from([(objid, file(objid, 3))])) - .await - .unwrap(); - - assert!( - root.txg > orphan_txg, - "commit reused transaction group {} (orphan at {orphan_txg})", - root.txg - ); - // And the orphan is untouched, not overwritten. - assert_eq!( - h.data.get_blob(&orphan_key, None).await.unwrap().body, - orphan - ); - } - - /// A transaction dropped without committing consumed a number that no - /// root records. The next commit has to claim before it seals, or a - /// crash after its slabs would leave that number two past the tip — - /// exactly where the next session would start. - #[tokio::test] - async fn an_abandoned_transaction_forces_a_claim() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let before = store.root().await.unwrap(); - - let txn = store.begin().await.unwrap(); - let abandoned = txn.txg(); - drop(txn); - - let objid = store.reserve_objid().await.unwrap(); - let root = store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - - // One claim root in between, naming a number past the abandoned one. - assert_eq!(root.seq, before.seq + 2); - let claim = store.roots.load_snapshot(before.seq + 1).await.unwrap(); - assert!(claim.txg > abandoned); - assert_eq!(root.txg, claim.txg + 1); - } - - /// A commit that dies short of its root has already sealed blocks under - /// its transaction group. The retry must not only skip that number but - /// put it on record first, so a crash before the retry lands does not - /// hand it to the next session. - #[tokio::test] - async fn a_transaction_group_is_burned_even_when_the_commit_fails() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let before = store.root().await.unwrap(); - - h.data.fail_next_put(FsError::IoTimeout); - let objid = store.reserve_objid().await.unwrap(); - assert!(matches!( - store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await, - Err(FsError::IoTimeout) - )); - let failed_txg = store.state.lock().await.next_txg - 1; - assert!(store.poison().await.is_none(), "a timeout is retryable"); - - let root = store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - assert!(root.txg > failed_txg, "reused {}", root.txg); - - // The retry re-claimed first: the chain now holds a root past the - // burned number, so no later session can start on it. - let claim = store.roots.load_snapshot(root.seq - 1).await.unwrap(); - assert_eq!(claim.seq, before.seq + 1); - assert!(claim.txg > failed_txg); - assert_eq!(root.txg, claim.txg + 1); - } - - /// The number a session seals under comes from the locked root chain, - /// never from what happens to be in the data bucket. A host that deletes - /// an orphan gains nothing. - #[tokio::test] - async fn a_new_session_starts_past_what_the_last_one_could_have_sealed() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let tip = store.root().await.unwrap().txg; - - // Claim, then die before writing anything: the data bucket has no - // trace of the number this session was about to use. - let txn = store.begin().await.unwrap(); - let burned = txn.txg(); - drop(txn); - drop(store); - assert!(h - .data - .keys() - .iter() - .all(|k| !k.contains(&format!("{burned:016x}")))); - - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - let root = store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - assert!( - root.txg > burned, - "{} <= {burned} (tip was {tip})", - root.txg - ); - } - - /// Two mounts at one tip would allocate the same slab names and the same - /// nonces. The claim decides between them before either has ciphertext. - #[tokio::test] - async fn a_mount_that_loses_the_claim_never_seals_a_block() { - let h = Harness::new(); - let a = h.open().await.unwrap(); - let b = h.open().await.unwrap(); - - let objid = a.reserve_objid().await.unwrap(); - a.commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - - let slabs_before = h.data.object_count(); - let objid = b.reserve_objid().await.unwrap(); - assert!(matches!( - b.commit(BTreeMap::from([(objid, file(objid, 2))])).await, - Err(FsError::Conflict) - )); - assert!(matches!(b.poison().await, Some(FsError::Conflict))); - assert_eq!( - h.data.object_count(), - slabs_before, - "the loser wrote a slab" - ); - - // And the winner's data is intact for the next session. - let c = h.open().await.unwrap(); - let root = c.root().await.unwrap(); - assert_eq!(root.seq, a.root().await.unwrap().seq); - } - - /// The same race with a delete marker laid over the winner's claim. The - /// marker lets the loser's conditional create through, because - /// `If-None-Match: *` sees only the current version — and if that were - /// the end of it, both mounts would seal under one transaction group: the - /// same nonces under the same key, into the same slab names. - #[tokio::test] - async fn a_delete_marker_does_not_hand_the_claim_to_a_second_mount() { - let h = Harness::new(); - drop(h.open().await.unwrap()); - let a = h.open().await.unwrap(); - let b = h.open().await.unwrap(); - - let _sealing = a.begin().await.unwrap(); - h.roots.delete_blob(&h.config.root_key(1)).await.unwrap(); - - assert!(matches!(b.begin().await, Err(FsError::Conflict))); - assert!(matches!(b.poison().await, Some(FsError::Conflict))); - } - - // ---- poisoning --------------------------------------------------------- - - /// Losing the race for a sequence number is fatal. Retrying would reuse - /// the transaction group and repeat every nonce in it. - #[tokio::test] - async fn losing_the_sequence_race_poisons_the_mount() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let genesis = store.root().await.unwrap(); - - let rs = RootStore::new(h.roots.clone(), h.keys.clone(), h.config.clone()); - let stolen = RootRecord::seal( - &h.keys, - 1, - Some(&genesis), - 999, - 0, - genesis.meta_dnode.clone(), - 2, - ) - .unwrap(); - rs.publish(&stolen).await.unwrap(); - - let objid = store.reserve_objid().await.unwrap(); - assert!(matches!( - store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await, - Err(FsError::Conflict) - )); - - assert!(matches!(store.poison().await, Some(FsError::Conflict))); - // Everything afterwards fails, including reads: we no longer know what - // is committed. - assert!(store.root().await.is_err()); - assert!(store.objset().await.is_err()); - assert!(store.commit(BTreeMap::new()).await.is_err()); - } - - #[tokio::test] - async fn a_healthy_mount_is_not_poisoned() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - assert!(store.poison().await.is_none()); - } - - // ---- genesis is not a fallback ---------------------------------------- - - /// The whole point of the split. An empty store used to mean "make me a - /// filesystem"; a store that answers "nothing" is now an error, because - /// only the caller can tell a genuinely new filesystem from one that has - /// been hidden. - #[tokio::test] - async fn mounting_an_empty_store_refuses_rather_than_formatting() { - let h = Harness::new(); - assert!(matches!(h.mount(None).await, Err(FsError::NoFilesystem))); - - // And it really was left empty — the failed mount must not have - // created anything on its way out. - assert!(matches!(h.mount(None).await, Err(FsError::NoFilesystem))); - } - - /// The other direction: a caller that has misjudged which mode it is in - /// must not be able to overwrite a filesystem it should have mounted. - #[tokio::test] - async fn creating_over_an_existing_filesystem_is_refused() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 7))])) - .await - .unwrap(); - let before = store.root().await.unwrap(); - drop(store); - - assert!(matches!( - Store::create( - h.data.clone(), - h.roots.clone(), - h.keys.clone(), - h.config.clone() - ) - .await, - Err(FsError::AlreadyExists) - )); - - // The refusal left the existing filesystem exactly as it was. - let after = h.mount(None).await.unwrap().root().await.unwrap(); - assert_eq!(after, before); - } - - #[tokio::test] - async fn a_created_filesystem_mounts_back() { - let h = Harness::new(); - let genesis = Store::create( - h.data.clone(), - h.roots.clone(), - h.keys.clone(), - h.config.clone(), - ) - .await - .unwrap() - .root() - .await - .unwrap(); - - assert_eq!(genesis.seq, 0); - assert!( - genesis.prev_root_hash.is_zero(), - "genesis has no predecessor" - ); - assert_eq!(h.mount(None).await.unwrap().root().await.unwrap(), genesis); - } - - // ---- rollback ---------------------------------------------------------- - - #[tokio::test] - async fn a_mount_floor_rejects_a_rewound_store() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - for i in 0..3u64 { - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, i))])) - .await - .unwrap(); - } - drop(store); - - // Mounting with a floor at or below the real tip is fine. - assert!(h.mount(Some(3)).await.is_ok()); - - // A floor above it means the store is behind where we know it to be. - assert!(matches!( - h.mount(Some(4)).await, - Err(FsError::Rollback { - expected: 4, - found: 3 - }) - )); - } - - /// The end-to-end rollback story: an adversary who can write to the bucket - /// cannot make a mount accept an older state. - /// - /// Not because the newer root cannot be deleted — it can be *hidden*, and - /// this test does exactly that — but because a hidden version is still a - /// version, and tip discovery reads the retained one. The sequence is - /// checked against the key on top of that. - #[tokio::test] - async fn an_adversary_cannot_rewind_the_filesystem() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 111))])) - .await - .unwrap(); - let old_root = store.root().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 222))])) - .await - .unwrap(); - drop(store); - - // Hide the newest root. S3 allows this: the delete marker goes on top - // and the retained version stays underneath, undeletable. - h.roots - .delete_blob(&h.config.root_key(2)) - .await - .expect("a delete marker is a legal write"); - assert!( - matches!( - h.roots.get_blob(&h.config.root_key(2), None).await, - Err(FsError::NotFound) - ), - "an ordinary read now reports the tip missing — which is the whole \ - attack, and why tip discovery must not use one" - ); - - // Try to replay the older root over the newer key: also refused, and - // even if it landed, its `seq` would not match the key. - assert!(matches!( - h.roots - .put_blob(PutBlobInput::new( - h.config.root_key(2), - Bytes::from(old_root.encode().unwrap()) - )) - .await, - Err(FsError::AccessDenied) - )); - - // The filesystem still mounts at the newest state. - let store = h.open().await.unwrap(); - assert_eq!(store.root().await.unwrap().seq, 2); - let objset = store.objset().await.unwrap(); - assert_eq!( - objset - .get_allocated(store.blocks(), objid) - .await - .unwrap() - .size, - 222 - ); - } - - #[tokio::test] - async fn a_replayed_root_at_the_wrong_key_is_refused() { - let h = Harness::new(); - let store = h.open().await.unwrap(); - let objid = store.reserve_objid().await.unwrap(); - store - .commit(BTreeMap::from([(objid, file(objid, 1))])) - .await - .unwrap(); - let old = store.root().await.unwrap(); - drop(store); - - // Plant root 1's bytes at key 2, which no one has claimed yet. - h.roots - .put_blob_if_not_exists(PutBlobInput::new( - h.config.root_key(2), - Bytes::from(old.encode().unwrap()), - )) - .await - .unwrap(); - - // Tip discovery finds key 2; verification rejects it, and the mount - // fails closed rather than silently falling back to key 1. - assert!(matches!( - h.open().await, - Err(FsError::Integrity("root: sequence does not match its key")) - )); - } -} diff --git a/deploy/Dockerfile b/deploy/Dockerfile deleted file mode 100644 index c2c28ce..0000000 --- a/deploy/Dockerfile +++ /dev/null @@ -1,115 +0,0 @@ -# Enclave image for `nitro-cli build-enclave`. -# -# The guest component is NOT in this image. The runtime fetches it at boot from -# `S3FS_GUEST_OBJECT`, a key in the roots bucket, extends PCR16 with its hash -# and locks that register before it asks KMS for anything. This image — PCR0 — -# covers the runtime and *where* the guest comes from; PCR16 covers *what* -# arrived. A key policy pinning both still attests to exactly which guest code -# will read the data, not merely to the runtime that loads it. -# -# Changing the guest therefore means uploading it and replacing PCR16 in the -# key policy, not rebuilding this image. The approval was always the feature; -# the rebuild never was. -# -# docker build -f deploy/Dockerfile -t s3fs-enclave . -# nitro-cli build-enclave --docker-uri s3fs-enclave:latest --output-file s3fs.eif -# nitro-cli run-enclave --eif-path s3fs.eif --cpu-count 2 --memory 2048 -# -# `build-enclave` prints PCR0/PCR1/PCR2; PCR0 is what the key policy pins for -# the runtime. The guest is built, measured and uploaded on its own: -# -# (cd examples/guest-http && cargo build --release --target wasm32-wasip2) -# GUEST=examples/guest-http/target/wasm32-wasip2/release/guest-http.wasm -# nitro-attest --measure "$GUEST" # the PCR16 the key policy pins -# aws s3 cp "$GUEST" s3:///guest/guest.wasm - -# ---- build ------------------------------------------------------------------ -FROM rust:1-bookworm AS build - -# aws-lc-rs builds a bundled C library. -RUN apt-get update && apt-get install -y --no-install-recommends \ - cmake clang \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src -COPY . . - -RUN cargo build --release -p enclave-runtime - -# ---- the tap forwarder ------------------------------------------------------ -# An enclave has no NIC, so nothing that speaks TCP works until this turns the -# vsock channel into an interface. It ships *inside* the image rather than -# being fetched at boot, so PCR0 covers it: a forwarder streamed in later would -# be code the attestation says nothing about, sitting on every packet. -# -# Pinned by digest. A floating tag would change PCR0 without anything in this -# repository changing, which would look like the image had been tampered with. -FROM golang:1.23-bookworm AS gvforwarder -ARG GVISOR_TAP_VSOCK_VERSION=v0.8.6 -RUN git clone --depth 1 --branch ${GVISOR_TAP_VSOCK_VERSION} \ - https://github.com/containers/gvisor-tap-vsock /src \ - && cd /src \ - && CGO_ENABLED=0 go build -o /gvforwarder ./cmd/gvforwarder - -# ---- runtime ---------------------------------------------------------------- -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=build /src/target/release/enclave-runtime /usr/local/bin/enclave-runtime -COPY --from=gvforwarder /gvforwarder /usr/local/bin/gvforwarder - -# Deployment configuration. Every setting also has a flag, but inside an -# enclave there is no shell to pass one, so these are what actually apply. -# -# Where the guest comes from, as a key in S3FS_ROOTS_BUCKET. The location is -# measured here, by PCR0; what is found there is measured into PCR16 at boot, -# so the object does not have to be trusted — a substituted one gets no key. -ENV S3FS_GUEST_OBJECT=guest/guest.wasm -# Opt in for a guest implementing enclave:tasks/background@0.1.0, with WebAuthn -# configured. See docs/BACKGROUND_TASKS.md for standing authorization semantics. -# ENV S3FS_BACKGROUND_TASKS=true -# ENV S3FS_BACKGROUND_CONCURRENCY=1 -# Read the Nitro PTP hardware clock and refuse to start without it. The -# alternative is running on a clock the parent instance sets, which is the -# party this whole design excludes. -ENV S3FS_CLOCK_SOURCE=ptp -# Take entropy from the Nitro Security Module and refuse to start without it. -# The alternative is a kernel pool that is NSM-seeded in an enclave but says -# nothing about it, and the guest builds keys from these bytes. -ENV S3FS_RANDOM_SOURCE=nsm -# Bring up the tap device over vsock before anything opens a socket. The parent -# instance must be running `gvproxy --listen vsock://:1024` — see -# deploy/parent/run-parent.sh. Without it the runtime waits, then says so. -ENV S3FS_NETWORK=gvproxy -# Serve the guest over HTTPS, terminated here. A certificate generated inside -# the enclave, with its hash signed into the attestation document, is what lets -# a client prove its session ends in *this* code rather than in the parent. -ENV S3FS_HTTP_LISTEN=0.0.0.0:443 -# ACME, and only ACME. A self-signed certificate is one an operator can mint -# too, so it cannot tell this enclave apart from something impersonating it — -# the runtime refuses the setting rather than generating one. -ENV S3FS_TLS=acme -ENV S3FS_TLS_DOMAINS=enclave.example.com -# ENV S3FS_BUCKET=prod-data -# ENV S3FS_ROOTS_BUCKET=prod-roots # Object Lock COMPLIANCE lives here -# ENV AWS_REGION=eu-west-2 -# ENV S3FS_MIN_ROOT_SEQ=0 # freshness floor from outside the store -# -# The master secret is minted by KMS inside the enclave and released only -# against an attestation whose PCR0 and PCR16 match the key policy. A wrong -# image or a wrong guest does not get a refused mount; it gets no key at all. -ENV S3FS_MASTER_KEY_SOURCE=kms -# ENV S3FS_KMS_KEY_ID=arn:aws:kms:eu-west-2:...:key/... -# ENV S3FS_MASTER_KEY_PARAMETER=/enclave-runtime/prod//master-key -# ENV S3FS_ENVIRONMENT=production # part of the encryption context -# -# S3FS_MASTER_KEY is deliberately NOT set, and the runtime *refuses to start* -# if it is set alongside the line above. A key baked into the image is readable -# by anyone who can pull the image, and one passed by the parent is readable by -# the party the enclave exists to exclude — so the combination is an error -# rather than a precedence rule. - -ENTRYPOINT ["/usr/local/bin/enclave-runtime"] diff --git a/deploy/ami/enclave-parent.pkr.hcl b/deploy/ami/enclave-parent.pkr.hcl index 8d24c2c..3649c26 100644 --- a/deploy/ami/enclave-parent.pkr.hcl +++ b/deploy/ami/enclave-parent.pkr.hcl @@ -15,7 +15,7 @@ # nix build .#gvproxy # packer init deploy/ami # packer build \ -# -var eif=$(readlink -f result)/s3fs.eif \ +# -var eif=$(readlink -f result)/enclave.eif \ # -var pcr_json=$(readlink -f result)/pcr.json \ # -var gvproxy=$(readlink -f result-1)/bin/gvproxy \ # deploy/ami @@ -75,7 +75,7 @@ variable "enclave_memory_mib" { variable "ami_name_prefix" { type = string - default = "s3fs-enclave-parent" + default = "enclave-parent" } locals { @@ -88,7 +88,7 @@ source "amazon-ebs" "parent" { ssh_username = "ec2-user" ami_name = "${var.ami_name_prefix}-${local.timestamp}" - ami_description = "Nitro Enclaves parent: nitro-cli, gvproxy, and a pinned s3fs EIF" + ami_description = "Nitro Enclaves parent: nitro-cli, gvproxy, and a pinned enclave EIF" source_ami_filter { filters = { @@ -100,7 +100,7 @@ source "amazon-ebs" "parent" { most_recent = true } - # The EIF alone is ~140 MiB; the default 8 GiB root leaves ample room but is + # The EIF alone is ~530 MiB; the default 8 GiB root leaves ample room but is # stated so a larger image does not silently fill the disk. launch_block_device_mappings { device_name = "/dev/xvda" @@ -128,7 +128,10 @@ build { inline = [ "set -euxo pipefail", "sudo dnf -y update", - "sudo dnf -y install aws-nitro-enclaves-cli jq", + # nbdkit serves the pool's EBS volume to the enclave over vsock: stock, + # from the Amazon Linux repositories. See units/nbdkit.service. + "sudo dnf -y install aws-nitro-enclaves-cli jq nbdkit-server nbdkit-basic-plugins", + "nbdkit --version", "sudo usermod -aG ne ec2-user", "nitro-cli --version", ] @@ -141,7 +144,7 @@ build { provisioner "file" { source = var.eif - destination = "/opt/enclave/s3fs.eif" + destination = "/opt/enclave/enclave.eif" } # The measurements the image claims, kept next to it. This is what lets an @@ -186,12 +189,14 @@ build { "set -euxo pipefail", "sudo install -m 0644 /tmp/units/gvproxy.service /etc/systemd/system/", "sudo install -D -m 0644 /tmp/units/gvproxy.yml /etc/gvproxy/config.yml", + "sudo install -m 0644 /tmp/units/nbdkit.service /etc/systemd/system/", "sudo install -m 0644 /tmp/units/enclave.service /etc/systemd/system/", "sudo install -m 0755 /tmp/units/enclave-start.sh /usr/local/bin/enclave-start", - "sudo systemctl enable gvproxy.service enclave.service", + "sudo systemctl enable gvproxy.service nbdkit.service enclave.service", # Fail the build rather than the boot: a unit that will not even parse is # discovered here, not at 3am on a machine with no shell access. "sudo systemd-analyze verify /etc/systemd/system/gvproxy.service", + "sudo systemd-analyze verify /etc/systemd/system/nbdkit.service", "sudo systemd-analyze verify /etc/systemd/system/enclave.service", ] } diff --git a/deploy/ami/units/enclave-start.sh b/deploy/ami/units/enclave-start.sh index 3cf7648..34ec441 100644 --- a/deploy/ami/units/enclave-start.sh +++ b/deploy/ami/units/enclave-start.sh @@ -14,7 +14,7 @@ # firewall problem rather than a missing enclave. set -euo pipefail -EIF="${EIF:-/opt/enclave/s3fs.eif}" +EIF="${EIF:-/opt/enclave/enclave.eif}" CPU_COUNT="${CPU_COUNT:-2}" MEMORY_MIB="${MEMORY_MIB:-3072}" ENCLAVE_IP="${ENCLAVE_IP:-192.168.127.2}" diff --git a/deploy/ami/units/enclave.service b/deploy/ami/units/enclave.service index 4d9884e..58adb59 100644 --- a/deploy/ami/units/enclave.service +++ b/deploy/ami/units/enclave.service @@ -1,10 +1,10 @@ [Unit] -Description=s3fs Nitro Enclave -# Ordered after both, because the enclave needs memory the allocator reserves -# and a network gvproxy provides. Started before either, it fails in ways that -# name neither. -After=nitro-enclaves-allocator.service gvproxy.service -Requires=nitro-enclaves-allocator.service gvproxy.service +Description=Nitro Enclave runtime +# Ordered after all three, because the enclave needs memory the allocator +# reserves, a network gvproxy provides and a disk nbdkit serves. Started before +# any of them, it fails in ways that name none of them. +After=nitro-enclaves-allocator.service gvproxy.service nbdkit.service +Requires=nitro-enclaves-allocator.service gvproxy.service nbdkit.service [Service] # `run-enclave` returns once the enclave is running rather than staying in the @@ -17,12 +17,12 @@ RemainAfterExit=yes ExecStart=/usr/local/bin/enclave-start ExecStop=/usr/bin/nitro-cli terminate-enclave --all -Environment=EIF=/opt/enclave/s3fs.eif +Environment=EIF=/opt/enclave/enclave.eif Environment=CPU_COUNT=2 Environment=MEMORY_MIB=3072 Environment=FORWARD_PORTS=443 -# The enclave takes a while to load a ~140 MiB image and mount its filesystem. +# The enclave takes a while to load a ~530 MiB image and import its pool. TimeoutStartSec=300 Restart=on-failure RestartSec=10 diff --git a/deploy/ami/units/nbdkit.service b/deploy/ami/units/nbdkit.service new file mode 100644 index 0000000..1c1d04c --- /dev/null +++ b/deploy/ami/units/nbdkit.service @@ -0,0 +1,38 @@ +# The enclave's disk: the pool's EBS volume, served over vsock. +# +# An enclave has no devices. The pool lives on an EBS volume attached to this +# instance, and this hands its blocks to the enclave as NBD on vsock port +# 10809, where the runtime attaches it as /dev/nbd0 (runtime/src/zfs.rs). +# +# Everything it serves is dm-crypt ciphertext under a key this machine never +# sees, and the enclave checks every block it reads against ZFS's checksums +# and the anchor in S3. So this can stop serving, which stops the enclave, but +# it cannot make the enclave read anything wrong or old. Stock nbdkit from the +# Amazon Linux repositories, unmodified. +[Unit] +Description=nbdkit, the enclave's disk +Before=enclave.service + +[Service] +Type=exec +# POOL_DISK, written by user-data: /dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_vol. +EnvironmentFile=/etc/enclave/disk.env +# The volume is attached after the instance boots, so wait for it rather than +# fail and leave the enclave to discover a missing disk on its own. +ExecStartPre=/bin/sh -c 'for i in $(seq 300); do [ -b "$POOL_DISK" ] && exit 0; sleep 1; done; echo "no block device at $POOL_DISK" >&2; exit 1' +ExecStart=/usr/sbin/nbdkit --foreground --vsock --port 10809 file ${POOL_DISK} +Restart=always +RestartSec=2 + +# It serves one block device to one untrusted enclave, so it gets no more of +# the host than that needs. +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nix/README.md b/deploy/nix/README.md index 2512754..28bd89e 100644 --- a/deploy/nix/README.md +++ b/deploy/nix/README.md @@ -1,7 +1,7 @@ # Building the enclave image ```console -$ nix build .#eif # → result/s3fs.eif, result/pcr.json +$ nix build .#eif # → result/enclave.eif, result/pcr.json $ jq -r .PCR0 result/pcr.json 42dfa2f7f828fd1dbb955a8d8c00537c4545d56467e850254f72bd80627c35fa… ``` diff --git a/deploy/nix/deployment.nix b/deploy/nix/deployment.nix index 369076b..26166f8 100644 --- a/deploy/nix/deployment.nix +++ b/deploy/nix/deployment.nix @@ -11,13 +11,12 @@ # pinned to one will not release to the other, and clients pin different # measurements. The enclave's identity includes what it operates on. # -# `S3FS_ID` is not a secret. It is the HKDF salt, so two filesystems under one +# `ENCLAVE_ID` is not a secret. It is the HKDF salt, so two filesystems under one # master secret stay independent, and it must be supplied rather than read from # the store: the keys that verify a root record derive from it, so taking it # from the store would mean trusting the store to say which key checks its own # signature. { - dataBucket = "CHANGE-ME-data"; rootsBucket = "CHANGE-ME-roots"; bucketPrefix = ""; @@ -69,37 +68,28 @@ # be served too. Measured by PCR0 like `rpId`: adding an app is a new image. webauthnAllowedOrigins = [ ]; - # Where guest stdout and stderr go, on top of the enclave console. + # Where guest stdout and stderr go, on top of the enclave console: this + # group's `guest` stream, or "" for the console only. # # Both must already exist — `deploy/tofu` creates them, and the enclave holds - # `logs:PutLogEvents` and nothing more, so it cannot create them itself. They - # are baked into the image and therefore measured by PCR0, which is why a + # `logs:PutLogEvents` and nothing more, so it cannot create them itself. The + # group is baked into the image and therefore measured by PCR0, which is why a # client can tell from an attestation where an enclave ships guest output. # # The group must match `aws_cloudwatch_log_group.guest` in `deploy/tofu`, # which names it "/${name_prefix}-${environment}/guest". guestLogGroup = "/CHANGE-ME-production/guest"; - guestLogStream = "guest"; - # Where the runtime fetches its guest: a key in `rootsBucket`, used verbatim - # (`bucketPrefix` is not applied). + # The guest is not here either. The runtime fetches it from + # `guest/guest.wasm` in `rootsBucket` (`bucketPrefix` is not applied) and + # measures it into PCR16 before it asks KMS for a key, so changing the guest + # is an upload and a key-policy edit, not a new image. Background tasks run + # when the guest exports `run-task`. # - # The key is measured by PCR0 like everything else here. The object behind it - # is measured by the enclave into PCR16 at boot, before it asks KMS for a key, - # so it need not be trusted: changing the guest is an upload and a key-policy - # edit, not a new image. Upload `guest-release/guest.wasm` here. - guestObject = "guest/guest.wasm"; - - # Opt in only for a guest implementing enclave:tasks/background@0.1.0. - # Each task is authorized by an authenticated tenant interaction. Queued - # work survives restarts; only this active enclave may own its scheduler. - backgroundTasks = false; - backgroundConcurrency = 1; - - # The guest's own settings are not here. They travel in the guest file, written - # into it by `deploy/qemu-nitro/guest-env.py` before it is uploaded as - # `guestObject`, so they are measured into PCR16 with its code and the image - # serves any deployment of any guest. A guest reaches the public internet and + # Nor are the guest's own settings. They travel in the guest file, written + # into it by `deploy/qemu-nitro/guest-env.py` before it is uploaded, so they + # are measured into PCR16 with its code and the image serves any deployment + # of any guest. A guest reaches the public internet and # nothing else, by address — see `runtime/src/serve/egress.rs` — so there is no # list of where it may send. diff --git a/deploy/parent/run-parent.sh b/deploy/parent/run-parent.sh index d30c793..85fedfd 100755 --- a/deploy/parent/run-parent.sh +++ b/deploy/parent/run-parent.sh @@ -44,7 +44,7 @@ gvproxy not found. Install it from containers/gvisor-tap-vsock: go install github.com/containers/gvisor-tap-vsock/cmd/gvproxy@latest or take a release binary. The matching `gvforwarder` goes *inside* the enclave -image, not here — see deploy/Dockerfile. +image, not here — flake.nix builds it in. EOF exit 1 } @@ -82,7 +82,7 @@ done echo echo "gvproxy is up. Start the enclave with:" -echo " nitro-cli run-enclave --eif-path s3fs.eif --cpu-count 2 --memory 2048" +echo " nitro-cli run-enclave --eif-path enclave.eif --cpu-count 2 --memory 2048" echo echo "Forwarded: $FORWARD_PORTS → $ENCLAVE_IP" echo "API: $API_SOCKET" diff --git a/deploy/ptp-check.sh b/deploy/ptp-check.sh index 69c9936..2eaac33 100755 --- a/deploy/ptp-check.sh +++ b/deploy/ptp-check.sh @@ -35,11 +35,13 @@ done REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BIN="$REPO/target/release/enclave-runtime" -# clock-check never mounts anything, but the CLI requires these. -DUMMY_ARGS=(--clock-check --clock-source ptp --bucket unused - --master-key 00000000000000000000000000000000000000000000000000000000000000ab) +# A `testing` build, since only it lets a check choose its clock and entropy: +# a production binary always reads PTP and the NSM, and neither machine here has +# an NSM. --self-check exits before anything mounts, but the CLI requires a bucket. +DUMMY_ARGS=(--self-check --roots-bucket unused --clock-source ptp --random-source host) -[[ -x "$BIN" ]] || { echo "build it first: cargo build --release -p enclave-runtime" >&2; exit 1; } +[[ -x "$BIN" ]] || { + echo "build it first: cargo build --release -p enclave-runtime --features testing" >&2; exit 1; } case "$MODE" in container) @@ -65,7 +67,7 @@ qemu) echo "not produce the device, so this mode cannot work without it." >&2 exit 1; } - WORK="${TMPDIR:-/tmp}/s3fs-ptp-qemu" + WORK="${TMPDIR:-/tmp}/enclave-ptp-qemu" mkdir -p "$WORK" IMG="$WORK/noble.img" SEED="$WORK/seed.iso" @@ -89,7 +91,7 @@ runcmd: - echo "PTP-CHECK-DONE" - poweroff EOF - echo "instance-id: s3fs-ptp" > "$WORK/meta-data" + echo "instance-id: enclave-ptp" > "$WORK/meta-data" cloud-localds "$SEED" "$WORK/user-data" "$WORK/meta-data" echo "== booting VM; ptp_kvm exposes the host clock as /dev/ptp0 inside ==" diff --git a/deploy/qemu-nitro/dev-enclave.sh b/deploy/qemu-nitro/dev-enclave.sh index 2d05a2c..39d33eb 100755 --- a/deploy/qemu-nitro/dev-enclave.sh +++ b/deploy/qemu-nitro/dev-enclave.sh @@ -190,8 +190,8 @@ IFS=, read -ra guest_env <<<"$GUEST_ENV" for kv in "${guest_env[@]}"; do [[ "$kv" =~ ^[A-Z_][A-Z0-9_]*=[A-Za-z0-9:/._-]*$ ]] \ || { echo "--guest-env $kv: expected NAME=VALUE (letters, digits and :/._- in the value)" >&2; exit 1; } - [[ "$kv" != S3FS_* && "$kv" != AWS_* ]] \ - || { echo "--guest-env $kv: S3FS_ and AWS_ variables are withheld from guests" >&2; exit 1; } + [[ "$kv" != ENCLAVE_* && "$kv" != AWS_* ]] \ + || { echo "--guest-env $kv: ENCLAVE_ and AWS_ variables are withheld from guests" >&2; exit 1; } done if [[ -n "$WEBAUTHN_ALLOWED_ORIGINS" && -z "$WEBAUTHN_RP_ID" ]]; then echo "--allowed-origin needs --rp-id: an app's origin is only ever vouched for by its own domain" >&2 @@ -325,7 +325,8 @@ Or verify the attestation on its own, without touching the guest: --pcr0 $EXPECTED_PCR0 --pcr16 $EXPECTED_PCR16 Console: $CONSOLE -Store: http://127.0.0.1:9000 (minioadmin/minioadmin, buckets $DATA_BUCKET and $ROOTS_BUCKET) +Store: http://127.0.0.1:9000 (minioadmin/minioadmin, bucket $ROOTS_BUCKET) +Disk: $ZFS_IMG (the pool, served on vsock 10809) Wakes: $wakes To run a new build of your guest, stop this and start it again with the new diff --git a/deploy/qemu-nitro/lib.sh b/deploy/qemu-nitro/lib.sh index b3f33cc..05ca49b 100644 --- a/deploy/qemu-nitro/lib.sh +++ b/deploy/qemu-nitro/lib.sh @@ -8,7 +8,8 @@ # # host QEMU enclave # ──── ──────────── -# MinIO :9000 ◀── gvproxy ──192.168.127.254── s3fs mount, and the guest +# MinIO :9000 ◀── gvproxy ──192.168.127.254── anchors, boot records, the guest +# nbd-stub.py vsock:10809 ◀── NBD over vsock ── the ZFS pool, on dm-crypt # gvproxy --listen vsock://:1024 ────────────▶ gvforwarder → tap0 .2 # expose :8443 → 192.168.127.2:443 ──▶ rustls :443 # vhost-device-vsock --forward-cid 1 @@ -73,7 +74,7 @@ if [[ -n "$BUNDLE" && -f "$BUNDLE/image.env" ]]; then # shellcheck source=/dev/null source "$BUNDLE/image.env" fi -IMAGE="${QEMU_IMAGE:-s3fs-qemu-nitro:latest}" +IMAGE="${QEMU_IMAGE:-enclave-qemu-nitro:latest}" TIMEOUT="${TIMEOUT:-240}" QEMU_MEMORY="${QEMU_MEMORY:-3G}" STORE_BIND="${STORE_BIND:-}" @@ -101,12 +102,11 @@ PEBBLE_IMAGE="${PEBBLE_IMAGE:-ghcr.io/letsencrypt/pebble@sha256:ddf230642b1a584f LABEL="enclave-harness=$PREFIX" # These do *not* follow PREFIX, and must not. They are baked into the emulator -# image as S3FS_BUCKET and S3FS_ROOTS_BUCKET (flake.nix, `eif-qemu`), which +# image as ENCLAVE_ROOTS_BUCKET (flake.nix, `eif-qemu`), which # means they are covered by PCR0 — an enclave image is its configuration as much # as its code. A run that named its buckets after itself would stand up a store -# the enclave does not look in, and the enclave would fail at mount with -# "connecting to the data bucket: not found". -DATA_BUCKET=e2e-data +# the enclave does not look in, and the enclave would fail at boot with +# "connecting to the roots bucket: not found". ROOTS_BUCKET=e2e-roots say() { printf '\n== %s ==\n' "$*"; } @@ -169,7 +169,7 @@ enclave_preflight() { # build's tools, so this is all it checks. enclave_preflight_host() { if [[ -n "$BUNDLE" ]]; then - [[ -f "$BUNDLE/eif/s3fs-qemu.eif" && -x "$BUNDLE/bin/gvproxy" ]] \ + [[ -f "$BUNDLE/eif/enclave-qemu.eif" && -x "$BUNDLE/bin/gvproxy" ]] \ || { echo "$BUNDLE is not a bundle: pack one with dev-enclave.sh --pack" >&2; exit 1; } VSOCK_BIN="$BUNDLE/bin/vhost-device-vsock" # The container images travel in the bundle, tagged as image.env names them. Loaded only @@ -291,7 +291,7 @@ nix_expr() { enclave_build_image() { if [[ -n "$BUNDLE" ]]; then EIF_DIR="$BUNDLE/eif" - EIF="$EIF_DIR/s3fs-qemu.eif" + EIF="$EIF_DIR/enclave-qemu.eif" EXPECTED_PCR0="$(jq -r .PCR0 "$EIF_DIR/pcr.json")" echo "EIF $EIF (prebuilt)" echo "PCR0 $EXPECTED_PCR0" @@ -327,7 +327,7 @@ enclave_build_image() { nix_build eif-qemu "$RUNDIR/eif" fi EIF_DIR="$(resolve_out_link "$RUNDIR/eif")" - EIF="$EIF_DIR/s3fs-qemu.eif" + EIF="$EIF_DIR/enclave-qemu.eif" EXPECTED_PCR0="$(jq -r .PCR0 "$EIF_DIR/pcr.json")" echo "EIF $EIF ($(du -h "$EIF" | cut -f1))" echo "PCR0 $EXPECTED_PCR0" @@ -464,12 +464,12 @@ enclave_start_store() { # The same recipe the SQLite CI job used. It lived in run-e2e.sh in full # until both copies had to agree with nothing making them agree. MINIO_LABEL="$LABEL" MINIO_BIND="$STORE_BIND" "$REPO/scripts/minio-up.sh" \ - "$PREFIX-minio" 9000 "$DATA_BUCKET" "$ROOTS_BUCKET" $data_dir + "$PREFIX-minio" 9000 "$ROOTS_BUCKET" $data_dir upload_guest guest.wasm - echo "MinIO ready with $DATA_BUCKET and $ROOTS_BUCKET, and the guest at $ROOTS_BUCKET/guest/guest.wasm" + echo "MinIO ready with $ROOTS_BUCKET, and the guest at $ROOTS_BUCKET/guest/guest.wasm" } -# Where the emulator image looks: `deployment.guestObject` in the roots bucket. +# Where every image looks: `guest/guest.wasm` in the roots bucket. upload_guest() { local image image="$("$REPO/scripts/minio-image.sh")" @@ -490,6 +490,7 @@ enclave_start_parent() { # kernel boots and then nothing happens at all. python3 "$REPO/deploy/qemu-nitro/heartbeat.py" 9000 > "$RUNDIR/heartbeat.log" 2>&1 & pids+=($!) + hooks_start # AWS would refuse an invented registration token, and a laptop has no # instance role to sign with. The stub answers the two calls the runtime @@ -528,6 +529,14 @@ enclave_start_parent() { [[ -S "$RUNDIR/vhost.socket" ]] \ || { echo "vhost-device-vsock never came up:" >&2; cat "$RUNDIR/vsock.log" >&2; exit 1; } + # Tenant data's disk, where nbdkit and an EBS volume are on Nitro. Kept + # with the store when the store is kept, so it is cleared and kept with it. + ZFS_IMG="$RUNDIR/zfs.img" + [[ -n "$KEEP_STORE" ]] && ZFS_IMG="$STORE_DIR/zfs.img" + [[ -e "$ZFS_IMG" ]] || truncate -s "${ZFS_SIZE:-4G}" "$ZFS_IMG" + python3 "$REPO/deploy/qemu-nitro/nbd-stub.py" 10809 "$ZFS_IMG" > "$RUNDIR/nbd.log" 2>&1 & + pids+=($!) + # The enclave's only route to anything. Without it the runtime waits for the # gateway and then says so. # @@ -652,7 +661,7 @@ boot_enclave() { qemu-system-x86_64 \ -M nitro-enclave,vsock=chr0,id="$PREFIX" \ -accel kvm -cpu host \ - -kernel /eif/s3fs-qemu.eif \ + -kernel /eif/enclave-qemu.eif \ -chardev socket,id=chr0,path=/run/vsock/vhost.socket \ -m "$QEMU_MEMORY" -smp 2 -nographic -no-reboot >/dev/null docker logs -f "$1" > "$2" 2>&1 & @@ -681,6 +690,34 @@ fail() { nonce() { openssl rand 20 | basenc --base64url | tr -d '='; } +# Points in an anchor where the testing build asks the host whether to go on +# (test-hooks.py). The parent always runs the listener: with no rule every +# point continues at once, where with nobody listening each ask would wait out +# vsock's two-second connect timeout. +# hooks_start a fresh listener, with no rules +# hook_rule after-marker hold the next anchor to reach it waits there +# hook_wait after-marker until one has arrived +# hook_release after-marker and lets it go +hooks_start() { + [[ -n "${HOOKS_PID:-}" ]] && { kill "$HOOKS_PID" 2>/dev/null || true; wait "$HOOKS_PID" 2>/dev/null || true; } + : > "$RUNDIR/hooks.rules"; : > "$RUNDIR/hooks.release" + python3 "$REPO/deploy/qemu-nitro/test-hooks.py" 9101 "$RUNDIR" > "$RUNDIR/hooks.log" 2>&1 & + HOOKS_PID=$! + pids+=("$HOOKS_PID") + for _ in $(seq 50); do grep -q listening "$RUNDIR/hooks.log" && return; sleep 0.1; done + fail "test-hooks.py did not start: $(cat "$RUNDIR/hooks.log")" +} +hooks_stop() { : > "$RUNDIR/hooks.rules"; } +hook_rule() { echo "$1 $2" >> "$RUNDIR/hooks.rules"; } +hook_release() { echo "$1" >> "$RUNDIR/hooks.release"; } +hook_wait() { + for _ in $(seq $((TIMEOUT * 5))); do + grep -q "^$1 " "$RUNDIR/hooks.log" && return + sleep 0.2 + done + fail "no anchor reached $1 within ${TIMEOUT}s" +} + # The enclave takes its address by DHCP from gvproxy, fetches and measures its # guest, then mounts over the network before it listens, so this waits on the # whole chain rather than on the boot alone. @@ -700,7 +737,7 @@ wait_for_serving() { # Capture the root this boot's attestation documents chain to, and point the # clients at it. # -# The emulator image sets `S3FS_COSIGN_ATTESTATIONS`, so the runtime re-signs +# The emulator image sets `ENCLAVE_COSIGN_ATTESTATIONS`, so the runtime re-signs # what QEMU's NSM produces — QEMU does not sign at all, and a client meeting an # unsigned document has to skip the signature, the chain and the validity # windows, which is most of what a client does. The contents stay the device's; @@ -725,7 +762,7 @@ enclave_trust_root() { [[ -n "$b64" ]] && break sleep 1 done - [[ -n "$b64" ]] || fail "the enclave never reported a trust root; is S3FS_COSIGN_ATTESTATIONS set in the image?" + [[ -n "$b64" ]] || fail "the enclave never reported a trust root; is ENCLAVE_COSIGN_ATTESTATIONS set in the image?" TRUST_ROOT="$RUNDIR/trust-root.der" printf '%s' "$b64" | base64 -d > "$TRUST_ROOT" \ @@ -859,7 +896,7 @@ enclave_bring_up() { say "waiting for the enclave to come up" wait_for_serving - plain | grep -E "enclave networking is up|guest measured into PCR16|mounted |serving +addr=" | tail -4 + plain | grep -E "enclave networking is up|guest measured into PCR16|zfs pool|serving +addr=" | tail -4 # Measured before anything asked for a key, and measured as the build said # it would be. @@ -903,7 +940,7 @@ enclave_pack() { say "packing into $PACK_DIR" rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR/eif" "$PACK_DIR/bin" "$PACK_DIR/images" - cp -L "$EIF_DIR/s3fs-qemu.eif" "$EIF_DIR/pcr.json" "$PACK_DIR/eif/" + cp -L "$EIF_DIR/enclave-qemu.eif" "$EIF_DIR/pcr.json" "$PACK_DIR/eif/" cp -L "$GV_DIR/bin/gvproxy" "$ATTEST" "$PASSKEY" "$VSOCK_BIN" "$PACK_DIR/bin/" chmod -R u+w "$PACK_DIR" @@ -920,7 +957,7 @@ enclave_pack() { # looks for. local qemu_id qemu_tag minio_image qemu_id="$(docker image inspect -f '{{.Id}}' "$IMAGE")" - qemu_tag="s3fs-qemu-nitro:${qemu_id:7:12}" + qemu_tag="enclave-qemu-nitro:${qemu_id:7:12}" docker tag "$IMAGE" "$qemu_tag" say "saving $qemu_tag" docker save "$qemu_tag" | gzip -1 > "$PACK_DIR/images/qemu.tar.gz" diff --git a/deploy/qemu-nitro/nbd-stub.py b/deploy/qemu-nitro/nbd-stub.py new file mode 100755 index 0000000..509310b --- /dev/null +++ b/deploy/qemu-nitro/nbd-stub.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Serve a disk image to the enclave over vsock, as the parent's EBS volume. + +The ZFS storage spike (runtime/src/zfs.rs) keeps tenant data on a block device +the parent serves with NBD. On Nitro that is `nbdkit --vsock` over an attached +EBS volume. Here it is this script over a sparse file, for the same reason the +heartbeat is a script: nbdkit is not on a developer's machine, and the store's +own nbdkit from Nix cannot run outside nix-portable's namespace. + +Only what the enclave's client sends is implemented: the fixed-newstyle +handshake with NBD_OPT_EXPORT_NAME, then READ, WRITE (with FUA), FLUSH and +DISC. Requests are answered in order, one connection at a time. Slow next to +nbdkit, which makes the spike's latency figures pessimistic, not optimistic. + +The parent sees only dm-crypt ciphertext, so nothing here is trusted: tests +replay or roll back this file to play the hostile host. + +Usage: nbd-stub.py +""" +import os +import socket +import struct +import sys + +NBDMAGIC = 0x4E42444D41474943 +IHAVEOPT = 0x49484156454F5054 +REQUEST_MAGIC, REPLY_MAGIC = 0x25609513, 0x67446698 +FIXED_NEWSTYLE, NO_ZEROES = 1, 2 +HAS_FLAGS, SEND_FLUSH, SEND_FUA = 1, 4, 8 +READ, WRITE, DISC, FLUSH = 0, 1, 2, 3 +CMD_FLAG_FUA = 1 +EINVAL = 22 + +port, image = int(sys.argv[1]), sys.argv[2] + + +def read_exact(conn, n): + b = bytearray() + while len(b) < n: + chunk = conn.recv(n - len(b)) + if not chunk: + raise EOFError + b += chunk + return bytes(b) + + +def serve(conn, fd): + size = os.fstat(fd).st_size + conn.sendall(struct.pack(">QQH", NBDMAGIC, IHAVEOPT, FIXED_NEWSTYLE | NO_ZEROES)) + (client_flags,) = struct.unpack(">I", read_exact(conn, 4)) + magic, option, length = struct.unpack(">QII", read_exact(conn, 16)) + read_exact(conn, length) # the export name; there is only one + if magic != IHAVEOPT or option != 1: + print(f"nbd: unsupported option {option}, closing", flush=True) + return + conn.sendall(struct.pack(">QH", size, HAS_FLAGS | SEND_FLUSH | SEND_FUA)) + if not client_flags & NO_ZEROES: + conn.sendall(bytes(124)) + print(f"nbd: serving {image} ({size} bytes)", flush=True) + + while True: + magic, flags, kind, handle, offset, length = struct.unpack(">IHHQQI", read_exact(conn, 28)) + if magic != REQUEST_MAGIC: + print("nbd: bad request magic, closing", flush=True) + return + if kind == DISC: + return + error, data = 0, b"" + if kind == READ: + data = os.pread(fd, length, offset) + elif kind == WRITE: + os.pwrite(fd, read_exact(conn, length), offset) + if flags & CMD_FLAG_FUA: + os.fdatasync(fd) + elif kind == FLUSH: + os.fdatasync(fd) + else: + error = EINVAL + conn.sendall(struct.pack(">IIQ", REPLY_MAGIC, error, handle) + data) + + +srv = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) +srv.bind((socket.VMADDR_CID_ANY, port)) +srv.listen(1) +print(f"nbd: listening on vsock port {port}", flush=True) + +while True: + conn, _ = srv.accept() + # Reopened per connection, so a test can swap the image between boots. + fd = os.open(image, os.O_RDWR) + try: + with conn: + serve(conn, fd) + except (EOFError, ConnectionError) as e: + print(f"nbd: connection ended: {e!r}", flush=True) + finally: + os.close(fd) diff --git a/deploy/qemu-nitro/pebble/README.md b/deploy/qemu-nitro/pebble/README.md index eff6920..e5ef1be 100644 --- a/deploy/qemu-nitro/pebble/README.md +++ b/deploy/qemu-nitro/pebble/README.md @@ -15,7 +15,7 @@ so the harness supplies its own. | | | |---|---| -| `ca.pem` | The root. Baked into `eif-qemu` at `/pebble-ca.pem` and named by `S3FS_ACME_CA`, so it is covered by PCR0 like any other configuration. | +| `ca.pem` | The root. Baked into `eif-qemu` at `/pebble-ca.pem` and named by `ENCLAVE_ACME_CA`, so it is covered by PCR0 like any other configuration. | | `cert.pem`, `key.pem` | Pebble's API certificate, SANs `192.168.127.254`, `127.0.0.1`, `localhost`, `pebble`. Mounted into the container. | The CA's private key is **deliberately not here**: it was destroyed after diff --git a/deploy/qemu-nitro/push-stub.py b/deploy/qemu-nitro/push-stub.py index b0bb158..75f5e27 100755 --- a/deploy/qemu-nitro/push-stub.py +++ b/deploy/qemu-nitro/push-stub.py @@ -22,7 +22,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer RECORD = sys.argv[1] if len(sys.argv) > 1 else "/tmp/push-messages.jsonl" -# The image dials this (flake.nix, S3FS_PUSH_ENDPOINT). Not 9100 and up: Dart DevTools takes those, +# The image dials this (flake.nix, ENCLAVE_PUSH_ENDPOINT). Not 9100 and up: Dart DevTools takes those, # one per VS Code window, and whatever holds the port answers the runtime in the stub's place. PORT = 9180 diff --git a/deploy/qemu-nitro/run-e2e.sh b/deploy/qemu-nitro/run-e2e.sh index 0c3db21..dad2751 100755 --- a/deploy/qemu-nitro/run-e2e.sh +++ b/deploy/qemu-nitro/run-e2e.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash # The whole stack, in an emulated enclave. # -# Everything below has been verified in pieces — the block store against MinIO, +# Everything below has been verified in pieces — the S3 backend against MinIO, # TLS and the attestation binding in unit tests, NSM entropy under QEMU. None # of it had ever run together, because until gvproxy the emulated enclave had # no way to reach anything. # # host QEMU enclave # ──── ──────────── -# MinIO :9000 ◀── gvproxy ──192.168.127.254── s3fs mount, and the guest +# MinIO :9000 ◀── gvproxy ──192.168.127.254── anchors, receipts, the guest +# nbd-stub.py vsock:10809 ◀── NBD over vsock ── the ZFS pool, on dm-crypt # gvproxy --listen vsock://:1024 ────────────▶ gvforwarder → tap0 .2 # expose :8443 → 192.168.127.2:443 ──▶ rustls :443 # vhost-device-vsock --forward-cid 1 @@ -22,8 +23,8 @@ # reproducible build claimed. # 2. user_data binds the certificate from this connection's own handshake, # so the TLS session terminates in the attested enclave. -# 3. The guest's counter advances, so writes crossed gvproxy to MinIO and -# came back — the filesystem really is mounted over the emulated vsock. +# 3. The guest's counter advances, so writes reached the pool on the parent's +# disk over vsock, were anchored in MinIO through gvproxy, and came back. # 4. The guest came from the store, not the image. The enclave measured the # object it fetched into PCR16 and locked it; the attested PCR16 is the one # the release build computed; and a substituted object boots an enclave @@ -98,7 +99,7 @@ first="$(signed get --path /counter)" || fail "no answer from the guest" second="$(signed get --path /counter)" || fail "no answer from the guest" echo "counter: $first then $second" [[ "${second//[^0-9]/}" -eq $(( ${first//[^0-9]/} + 1 )) ]] \ - || fail "the counter did not advance ($first → $second); writes are not reaching MinIO" + || fail "the counter did not advance ($first → $second); writes are not reaching the pool" say "2b/8 signed requests drive a real amount of filesystem work" # Two round trips to /counter prove the mount answers. They do not prove much @@ -189,24 +190,23 @@ echo "PCR16 release: $EXPECTED_PCR16" echo " attested: $(grep -oE '^PCR16 +[0-9a-f]+' "$RUNDIR/attest.log" | awk '{print $2}')" # --------------------------------------------------------------------------- -# 5/8 — one instance per tenant, and one approval per interaction. +# 5/8 — one instance per request, and one approval per interaction. # --------------------------------------------------------------------------- -say "5/8 a tenant keeps its instance, and no two tenants share one" +say "5/8 every request gets a fresh instance, and no two tenants share one" # `/memory` counts in the guest's linear memory and writes nowhere. What it -# answers is the whole per-tenant model in one number. +# answers is the whole instance model in one number. # -# The image runs with warm instances, so the *same* tenant asking twice must -# see the count rise — that is the instance being kept. A *different* tenant -# must see 1, because the boundary between two clients is a `Store` and not -# anything the guest does. An earlier version of this leg asserted the -# opposite, having been written before warm instances existed; it contradicted -# the image it was testing. +# Every request gets a fresh instance, so the *same* tenant asking twice must +# see 1 both times: a second call inheriting the first one's memory would be +# running on state it never approved. A *different* tenant must see 1 too, +# because the boundary between two clients is a `Store` and not anything the +# guest does. m1="$(signed get --path /memory)" m2="$(signed get --path /memory)" echo "alice memory: $m1 then $m2" -[[ "${m2//[^0-9]/}" -eq $(( ${m1//[^0-9]/} + 1 )) ]] \ - || fail "a tenant's instance was not kept between its requests ($m1, $m2)" +[[ "${m1//[^0-9]/}" -eq 1 && "${m2//[^0-9]/}" -eq 1 ]] \ + || fail "a tenant's request reused an instance ($m1, $m2)" signed2 enrol >/dev/null || fail "the second registration failed" b1="$(signed2 get --path /memory)" @@ -348,7 +348,7 @@ fi # Polled, not slept on. The first occurrence is due immediately, but # "immediately" still means a worker picking it up, instantiating the guest, and -# writing the result through the filesystem to MinIO. +# writing the result to the pool and anchoring it. completed="" for _ in $(seq "$TIMEOUT"); do record="$(signed get --path /tasks/e2e-job)" || fail "the task record became unreadable" @@ -518,7 +518,7 @@ docker rm -f "$PREFIX-qemu-substitute" >/dev/null 2>&1 || true cat </dev/null || { echo "nix is not on PATH; see deploy/nix/README.md" >&2; exit 1; } -nix build "$REPO#eif-selftest" --out-link "$WORK/eif-selftest" 2>&1 | tail -2 +nix build "$REPO#${SELFTEST_ATTR:-eif-selftest}" --out-link "$WORK/eif-selftest" 2>&1 | tail -2 EIF_DIR="$(readlink -f "$WORK/eif-selftest" 2>/dev/null || true)" # nix-portable keeps its store outside /nix except inside its own namespace; on # a normal Nix install the first branch always wins. diff --git a/deploy/qemu-nitro/run-zfs-spike.sh b/deploy/qemu-nitro/run-zfs-spike.sh new file mode 100755 index 0000000..bc6dd21 --- /dev/null +++ b/deploy/qemu-nitro/run-zfs-spike.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# The ZFS storage spike, end to end on the emulator. +# +# Tenant data on ZFS, over a disk image the "parent" serves on vsock +# (nbd-stub.py, standing in for nbdkit and EBS), anchored in the roots bucket +# after every request (runtime/src/zfs.rs). This plays the hostile host against +# it — rolling the disk back, killing the enclave between a sync and its +# anchor, serving an abandoned fork — measures what an anchor costs, and shows +# two tenants' pools anchoring independently. +# +# deploy/qemu-nitro/run-zfs-spike.sh a fresh store +# BENCH=200 deploy/qemu-nitro/run-zfs-spike.sh +set -euo pipefail + +PREFIX=zfs +KEEP_STORE=1 +FRESH_STORE=1 +# shellcheck source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +enclave_bring_up +CURRENT="$PREFIX-qemu" +IMG="$STORE_DIR/zfs.img" +N=0 + +# Stop this enclave, boot the image built last, and wait for it to serve or to +# refuse. Sets BOOT to "served" or "refused"; a refusal is the outcome some legs +# want, so it is not a failure here. +reboot() { + docker rm -f "$CURRENT" >/dev/null 2>&1 || true + sleep 2 + N=$((N + 1)) + CURRENT="$PREFIX-qemu-$N" + CONSOLE="$RUNDIR/console-$N.log" + boot_enclave "$CURRENT" "$CONSOLE" + BOOT="" + for _ in $(seq "$TIMEOUT"); do + if grep -qE "serving +addr=" <(plain); then BOOT=served; break; fi + if grep -qE "failed to start the guest|Kernel panic" <(plain); then BOOT=refused; break; fi + sleep 1 + done + [[ -n "$BOOT" ]] || fail "boot $N neither served nor refused within ${TIMEOUT}s" + [[ "$BOOT" == served ]] && enclave_trust_root + plain | grep -E "zfs pool anchored|zfs genesis|refusing the pool|importing the pool" | tail -2 || true +} + +# Stopped, so the image is not being written while it is copied. +stop() { docker rm -f "$CURRENT" >/dev/null 2>&1 || true; sleep 2; } +snapshot() { stop; cp --sparse=always "$IMG" "$STORE_DIR/$1.img"; } +# Without stopping: only while the enclave is held at a hook, writing nothing. +snapshot_live() { cp --sparse=always "$IMG" "$STORE_DIR/$1.img"; } +restore() { stop; cp --sparse=always "$STORE_DIR/$1.img" "$IMG"; } + +say "1/7 two tenants write, each in its own pool, and every write is anchored" +rm -f "$RUNDIR/alice.json" "$RUNDIR/bob.json" +signed enrol >/dev/null || fail "alice could not enrol" +signed2 enrol >/dev/null || fail "bob could not enrol" +signed post --path /files/a.txt --body "alice-a" >/dev/null || fail "alice could not write" +signed2 post --path /files/mine.txt --body "bob" >/dev/null || fail "bob could not write" +[[ "$(signed get --path /files/a.txt)" == "alice-a" ]] || fail "alice did not read her write back" +[[ "$(signed2 get --path /files/mine.txt)" == "bob" ]] || fail "bob did not read his write back" +mapfile -t tenants < <(plain | grep -oE 'tenant=[0-9a-f]{32}' | cut -d= -f2 | awk '!seen[$0]++') +ALICE="${tenants[0]:-}" +[[ -n "$ALICE" && -n "${tenants[1]:-}" ]] || fail "could not find both tenants' ids on the console" +grep -q "zfs anchored" <(plain) || fail "no request was anchored" +echo "alice=$ALICE; $(plain | grep -c 'zfs anchored') anchors so far" + +say "2/7 tenants cannot reach each other across their pools" +for route in escape stat; do + for target in "../$ALICE/http-example/a.txt" \ + "/tenants/$ALICE/http-example/a.txt" \ + "../../tenants/$ALICE/http-example/a.txt" \ + ".zfs/../../$ALICE/http-example/a.txt" \ + "../../../../proc/self/status"; do + out="$(signed2 get --path "/$route/$target" 2>&1 || true)" + if grep -qE "^(read|stat) " <<<"$out"; then + fail "bob reached $target through /$route/: $out" + fi + # The guest's own refusal, naming the target: proof the path reached + # the handler unnormalised rather than missing the route altogether. + grep -qF "refused $target" <<<"$out" \ + || fail "/$route/$target never reached the guest as written: $out" + done +done +signed2 get --path "/stat/http-example/mine.txt" | grep -q "^stat " || fail "bob cannot stat his own file" +echo "every cross-tenant path refused; bob's own file reachable" + +say "3/7 what an anchor costs" +BENCH="${BENCH:-100}" +bench() { + local verb="$1" i start end + : > "$RUNDIR/bench-$verb.txt" + for i in $(seq "$BENCH"); do + start=$(date +%s%N) + if [[ "$verb" == post ]]; then + signed post --path "/files/bench-$i" --body x >/dev/null + else + signed get --path "/files/bench-$i" >/dev/null + fi + end=$(date +%s%N) + echo $(( (end - start) / 1000000 )) >> "$RUNDIR/bench-$verb.txt" + done + sort -n "$RUNDIR/bench-$verb.txt" | awk -v v="$verb" '{a[NR]=$1} END { + printf "%s (client round trip, signing included): n=%d p50=%dms p95=%dms max=%dms\n", + v, NR, a[int(NR*0.5)+1], a[int(NR*0.95)], a[NR] }' +} +bench post +bench get +plain | grep "zfs anchored" | grep -oE "(sync_ms|total_ms)=[0-9]+" | paste - - \ + | awk -F'[\t=]' '{s+=$2; t+=$4; n++} END { if (n) printf "anchors: n=%d mean sync=%.1fms mean sync+publish=%.1fms\n", n, s/n, t/n }' + +say "4/7 a restart imports the pool at its anchor" +reboot +[[ "$BOOT" == served ]] || fail "the enclave refused its own pool" +[[ "$(signed get --path /files/a.txt)" == "alice-a" ]] || fail "a.txt did not survive the restart" +echo "a.txt survived: alice-a" + +say "5/7 a rolled-back disk is refused" +snapshot old # holds a.txt +reboot +signed post --path /files/b.txt --body "alice-b" >/dev/null || fail "alice could not write b.txt" +snapshot good # holds b.txt, anchored +restore old +reboot +[[ "$BOOT" == refused ]] || fail "the enclave served a disk older than its newest anchor" +plain | grep -oE "refusing the pool[^\"]*" | head -1 || true +restore good +reboot +[[ "$BOOT" == served ]] || fail "the enclave refused the good disk" +[[ "$(signed get --path /files/b.txt)" == "alice-b" ]] || fail "b.txt is gone from the good disk" +echo "old disk refused; good disk accepted, b.txt present" + +# Nothing is rewound: a crash leaves what reached the disk, and the boot goes +# on from it. Two histories can then grow from one anchor. Once one of them is +# anchored again, the other is an abandoned fork. +say "6/7 a crash between a sync and its anchor resumes; the abandoned fork is refused" +snapshot base # at the newest anchor +reboot +hooks_start +hook_rule after-sync abort +# The enclave aborts mid-anchor (after-sync), before it can publish or reply, +# so this request never returns. Background it, wait for the crash on the +# console, then stop waiting on the dead connection — a 2xx was impossible +# once it died there, and the client has no timeout of its own. +signed post --path /files/lost.txt --body "never acknowledged" >/dev/null 2>&1 & +lost=$! +for _ in $(seq 30); do grep -q "test hook: dying here" <(plain) && break; sleep 1; done +kill "$lost" 2>/dev/null || true +wait "$lost" 2>/dev/null || true +grep -q "test hook: dying here" <(plain) || fail "the enclave did not die between sync and publish" +hooks_stop +snapshot fork # past the anchor: lost.txt, and the next anchor's unpublished marker +reboot +[[ "$BOOT" == served ]] || fail "the enclave refused the disk it crashed on" +plain | grep -E "zfs pool admitted" | tail -1 +stop # nothing asked of it, so nothing anchored on this history +restore base # the other history from the same anchor +reboot +[[ "$BOOT" == served ]] || fail "the enclave refused the disk at its anchor" +[[ "$(signed get --path /files/b.txt)" == "alice-b" ]] || fail "b.txt is gone" +if signed get --path /files/lost.txt >/dev/null 2>&1; then + fail "lost.txt is on a disk that never held it" +fi +signed post --path /files/c.txt --body "alice-c" >/dev/null || fail "alice could not write c.txt" +snapshot current # this history is anchored now +echo "the crashed disk was admitted; from the anchor's own disk, c.txt anchored" +restore fork # the host serves the other history +reboot +[[ "$BOOT" == refused ]] || fail "the enclave accepted an abandoned fork" +plain | grep -oE "refusing the pool[^\"]*" | head -1 || true +restore current +reboot +[[ "$BOOT" == served ]] || fail "the enclave refused its current disk" +[[ "$(signed get --path /files/c.txt)" == "alice-c" ]] || fail "c.txt is gone" +echo "abandoned fork refused; current disk accepted, c.txt present" + +# The same-nonce steal that sank the single pool — one tenant's write landing +# between another's marker and its sync, acknowledged by an anchor that does +# not hold it — cannot happen here: each tenant has its own pool, its own +# marker, its own last-anchor lock. This shows the independence that makes it +# so: one tenant held mid-anchor does not stop another from anchoring. +say "7/7 two tenants anchor independently" +hooks_start +hook_rule after-marker hold # the first anchor to reach a marker holds there +signed post --path /files/held.txt --body "alice-held" > "$RUNDIR/alice-held.out" 2>&1 & +alice=$! +hook_wait after-marker # alice holds her pool's lock, mid-anchor +# Bob's pool is a different pool with a different lock, so his write must +# complete while alice is held — not wait the whole hold out. +signed2 post --path /files/free.txt --body "bob-free" > "$RUNDIR/bob-free.out" 2>&1 & +bob=$! +for _ in $(seq 60); do kill -0 "$bob" 2>/dev/null || break; sleep 1; done +if kill -0 "$bob" 2>/dev/null; then + hook_release after-marker; wait "$alice" "$bob" 2>/dev/null || true; hooks_stop + fail "bob's write did not finish while alice was held: the pools are not independent" +fi +wait "$bob" || fail "bob's independent write failed: $(cat "$RUNDIR/bob-free.out")" +hook_release after-marker +wait "$alice" || fail "alice's held write failed: $(cat "$RUNDIR/alice-held.out")" +hooks_stop +[[ "$(signed2 get --path /files/free.txt)" == "bob-free" ]] || fail "bob's write is gone" +[[ "$(signed get --path /files/held.txt)" == "alice-held" ]] || fail "alice's write is gone" +echo "bob anchored while alice was held: tenants anchor independently" + +echo +echo "PASS: ZFS spike: per-tenant pools, isolation, resume, rollback refused, crash resumed, fork refused, independent anchoring" diff --git a/deploy/qemu-nitro/test-hooks.py b/deploy/qemu-nitro/test-hooks.py new file mode 100644 index 0000000..4b8a461 --- /dev/null +++ b/deploy/qemu-nitro/test-hooks.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Stop the enclave at a named point in an anchor, for tests that play the host. + +The runtime's testing build dials this port at a few points in an anchor +(`hook` in runtime/src/zfs.rs), sends ` `, and waits for one line +back: `continue` or `abort`. Rules come from files in , so a test can copy +the disk at an exact moment, or kill the enclave between two steps: + + /hooks.rules ` hold|abort`, one per line; each fires once + /hooks.release a held point goes on once its name is a line here, once + per hold + stdout ` -> ` as each arrives + +Anything without a rule continues. Listening on the host's AF_VSOCK, as +heartbeat.py does and for the same reason. + +Usage: test-hooks.py +""" +import os +import socket +import sys +import threading +import time + +port, rundir = int(sys.argv[1]), sys.argv[2] +lock = threading.Lock() +fired = set() # indexes of rule lines already used +held = {} # point -> holds so far + + +def lines(name): + try: + with open(os.path.join(rundir, name)) as f: + return [line.split() for line in f if line.strip()] + except FileNotFoundError: + return [] + + +def answer(conn): + with conn: + line = conn.makefile("r").readline().strip() + point = line.split(" ", 1)[0] + with lock: + action = "continue" + for i, rule in enumerate(lines("hooks.rules")): + if i not in fired and rule[:1] == [point]: + fired.add(i) + action = rule[1] + break + if action == "hold": + held[point] = held.get(point, 0) + 1 + ordinal = held[point] + print(f"{line} -> {action}", flush=True) + if action == "hold": + while sum(r[:1] == [point] for r in lines("hooks.release")) < ordinal: + time.sleep(0.1) + print(f"{point} released", flush=True) + action = "continue" + conn.sendall(f"{action}\n".encode()) + + +srv = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) +srv.bind((socket.VMADDR_CID_ANY, port)) +srv.listen(8) +print(f"test-hooks: listening on vsock port {port}", flush=True) +while True: + conn, _ = srv.accept() + threading.Thread(target=answer, args=(conn,), daemon=True).start() diff --git a/deploy/tofu/main.tf b/deploy/tofu/main.tf index 6a29ba5..08a289c 100644 --- a/deploy/tofu/main.tf +++ b/deploy/tofu/main.tf @@ -167,8 +167,8 @@ data "aws_iam_policy_document" "buckets" { "s3:PutObjectRetention", ] resources = concat( - [for b in var.buckets : "arn:aws:s3:::${b}"], - [for b in var.buckets : "arn:aws:s3:::${b}/*"], + ["arn:aws:s3:::${var.roots_bucket}"], + ["arn:aws:s3:::${var.roots_bucket}/*"], ) } @@ -341,18 +341,48 @@ resource "aws_instance" "parent" { http_put_response_hop_limit = 1 } - # Configuration the image cannot know: which buckets, and which domain the - # certificate is for. Not secrets — the master key is not passed here, and - # once M8 lands it comes from KMS gated on PCR0 rather than from anywhere on - # this machine. + # Which device is the pool's disk: the one thing the parent needs that the + # image cannot know. The enclave's settings are in the image, measured. user_data = templatefile("${path.module}/user-data.sh.tftpl", { - data_bucket = var.buckets[0] - roots_bucket = length(var.buckets) > 1 ? var.buckets[1] : var.buckets[0] - region = var.region - tls_domains = join(",", var.tls_domains) + # How the volume appears to the parent: NVMe, named by its id. + pool_disk = "/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${replace(aws_ebs_volume.pool.id, "-", "")}" }) user_data_replace_on_change = true tags = local.tags } + +# --------------------------------------------------------------------------- +# The pool's disk +# --------------------------------------------------------------------------- + +# Where everything the enclave keeps lives: a ZFS pool on dm-crypt, served to +# the enclave by nbdkit on the parent (deploy/ami/units/nbdkit.service). The +# parent sees only ciphertext; the anchor chain in the roots bucket is what +# makes a rolled-back or substituted volume refused at boot — restoring an +# older snapshot of this volume is exactly that, and the enclave will not +# serve it. +# +# Never destroyed by a plan: this volume *is* the state. Losing it leaves +# anchors no disk can satisfy, and the enclave refuses to start rather than +# begin again. +resource "aws_ebs_volume" "pool" { + availability_zone = aws_subnet.public.availability_zone + size = var.pool_size_gib + type = "gp3" + # Amazon's at-rest encryption, beneath the enclave's own dm-crypt. + encrypted = true + + lifecycle { + prevent_destroy = true + } + + tags = merge(local.tags, { Name = "${local.name}-pool" }) +} + +resource "aws_volume_attachment" "pool" { + device_name = "/dev/sdf" + volume_id = aws_ebs_volume.pool.id + instance_id = aws_instance.parent.id +} diff --git a/deploy/tofu/outputs.tf b/deploy/tofu/outputs.tf index 7ae69ba..f9a78d9 100644 --- a/deploy/tofu/outputs.tf +++ b/deploy/tofu/outputs.tf @@ -36,13 +36,13 @@ output "guest_log_group" { output "verify" { value = <<-EOT # Before the first start, and for every guest change: upload the guest the - # key policy pins. The key must match `guestObject` in - # deploy/nix/deployment.nix. The enclave measures what it fetches into + # key policy pins, at guest/guest.wasm, where every image looks. The + # enclave measures what it fetches into # PCR16 and asks KMS for its key with that measurement, so an object the # policy does not name boots an enclave that can read nothing. nix build .#guest-release --out-link guest-release aws s3 cp guest-release/guest.wasm \ - s3://${length(var.buckets) > 1 ? var.buckets[1] : var.buckets[0]}/guest/guest.wasm + s3://${var.roots_bucket}/guest/guest.wasm # The key policy's condition, on both kms:GenerateDataKey and kms:Decrypt, # in a policy nobody can edit — the enclave refuses any other diff --git a/deploy/tofu/user-data.sh.tftpl b/deploy/tofu/user-data.sh.tftpl index 7cd02da..e36cb36 100644 --- a/deploy/tofu/user-data.sh.tftpl +++ b/deploy/tofu/user-data.sh.tftpl @@ -1,22 +1,19 @@ #!/usr/bin/env bash -# Deployment configuration the image cannot know: which buckets, which region, -# which domain. Written where the enclave.service unit picks it up. +# The one thing the parent needs that the image cannot know: which device is +# the pool's disk, written where nbdkit.service picks it up. The enclave's own +# settings — bucket, region, domains — are in the image and measured into PCR0; +# nothing here reaches it. # # Not a place for secrets. This file is readable from the instance metadata # service by anything on the box, and the parent is the party the enclave -# excludes. The master key is deliberately absent — until M8 it is supplied -# out of band, and after M8 it comes from KMS gated on PCR0, which is a -# statement about the enclave's code rather than about this machine. +# excludes. set -euxo pipefail -mkdir -p /etc/systemd/system/enclave.service.d -cat > /etc/systemd/system/enclave.service.d/deployment.conf < /etc/enclave/disk.env <= 1 && length(var.buckets) <= 2 - error_message = "Give one bucket, or two as [data, roots]." - } -} - -variable "tls_domains" { - type = list(string) - default = [] - description = "Domains for the enclave's certificate. Required for ACME; a self-signed certificate needs none, since attestation rather than a CA is what a client checks." } variable "vpc_cidr" { @@ -102,3 +90,9 @@ variable "push_app_id" { type = string default = "" } + +variable "pool_size_gib" { + type = number + default = 32 + description = "Size of the EBS volume holding the enclave's ZFS pool." +} diff --git a/docs/BACKGROUND_TASKS.md b/docs/BACKGROUND_TASKS.md index 2c0587f..fa595ca 100644 --- a/docs/BACKGROUND_TASKS.md +++ b/docs/BACKGROUND_TASKS.md @@ -11,25 +11,20 @@ The guest must export `run-task` and may import the queue interface defined in [`wit/tasks/tasks.wit`](../wit/tasks/tasks.wit). The HTTP example implements both its usual `wasi:http` interface and this background interface. -Enable `S3FS_BACKGROUND_TASKS=true` (or `--background-tasks true`) alongside -WebAuthn authentication and tenant isolation. In the Nix deployment, set -`backgroundTasks = true` in `deploy/nix/deployment.nix`. This image configuration -change changes PCR0; rebuilding the example guest changes PCR16. Update the -approved measurements through your normal deployment process. - -The emulator image turns it on independently, in `eif-qemu`'s environment in -`flake.nix`, so the end-to-end harness can exercise this path. That override -changes only the emulator's PCR0 — which the harness checks against its own -build rather than a published number — and leaves the production default alone. - -| Setting | Default | Purpose | -|---|---:|---| -| `S3FS_BACKGROUND_CONCURRENCY` | 1 | Maximum active background workers | -| `S3FS_BACKGROUND_MAX_RECORDS` | 1024 | Total durable records, including terminal tasks | -| `S3FS_BACKGROUND_PER_TENANT` | 64 | Records allowed per tenant | - -One guest attempt may run 600 seconds, and that is not a setting: work that drives a round with -an outside service waits on its schedule, which is minutes. +There is no setting: tasks run when the guest exports `run-task`. The guest is +measured into PCR16, so its exports already are the decision, and a setting beside +them could only disagree. A guest that exports it needs WebAuthn authentication, +or the runtime refuses to start. + +| Limit | Value | +|---|---:| +| Active background workers | 1 | +| Durable records, including terminal tasks | 1024 | +| Records per tenant | 64 | +| One guest attempt | 600 s | + +None of these is a setting. An attempt gets minutes because work that drives a round +with an outside service waits on that service's schedule. All limits must be positive. Background workers have separate admission from interactive requests and yield on epoch ticks. They use the same tenant lock, @@ -78,8 +73,8 @@ run-task(task-id, payload) -> result This export is not an HTTP endpoint. It receives the correct tenant's directory as `/` and an execution deadline. It uses a fresh guest -instance under the same pool lock as HTTP calls; any warm HTTP instance is -dropped first so it cannot retain stale database handles across the mutation. +instance under the same pool lock as HTTP calls, which also get a fresh +instance each, so nothing in memory outlives the call that made it. A missing tenant directory causes failure, never recreation. The background callback can inspect task status, but cannot enqueue, cancel or diff --git a/docs/CLIENT_INTEGRATION.md b/docs/CLIENT_INTEGRATION.md index e7397c1..1595c29 100644 --- a/docs/CLIENT_INTEGRATION.md +++ b/docs/CLIENT_INTEGRATION.md @@ -37,7 +37,7 @@ sudo modprobe vsock_loopback Once ever: ```bash -docker build -t s3fs-qemu-nitro:latest deploy/qemu-nitro +docker build -t enclave-qemu-nitro:latest deploy/qemu-nitro cargo install vhost-device-vsock --root target/qemu-nitro/tools ``` @@ -48,7 +48,7 @@ cargo build --release --target wasm32-wasip2 # in your guest's crate deploy/qemu-nitro/dev-enclave.sh --guest target/wasm32-wasip2/release/cosigner.wasm ``` -It builds the enclave image, starts a block store and an ACME CA, boots the +It builds the enclave image, starts a store, a disk and an ACME CA, boots the enclave under QEMU's `nitro-enclave` machine, waits for it to fetch and measure your component and obtain a certificate, then prints the three values a client pins and stays up. Ctrl-C stops everything it started. @@ -175,6 +175,20 @@ tenant's own directory. Path traversal out of it is refused by the runtime, not by the guest. You do not have to implement tenant separation; you have to not work around it. +### A response counts when it ends + +The runtime makes a request's writes durable — synced and anchored, see +[STORAGE.md](STORAGE.md) — before the response *ends*: the last chunk of a +chunked body, HTTP/2's end of stream, or gRPC's status trailers. A streamed +body streams as the guest writes it; only its end waits. A body with a +`Content-Length` is held whole. + +So treat a response as an acknowledgement only once it is complete. One that +stops short — the connection closed before the terminating chunk, or with +fewer bytes than its `Content-Length` — may describe writes that the next boot +rolls back. Standard HTTP clients already fail such a response; +[`passkey-client`](../runtime/src/bin/passkey-client.rs) does too. + ## What changes in an existing verifier A crate shaped like a pure-verification library — bytes in, no HTTP, no async — diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index d25e13c..429374e 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,107 +1,55 @@ -# Compatibility Matrix +# Compatibility -What a Wasm guest gets from `wasi:filesystem@0.2.6`, and where it differs from a local filesystem. +What a Wasm guest gets from `wasi:filesystem`, and where it differs from a local filesystem. -The storage engine is a ZFS-style copy-on-write block store: content is held in immutable, AEAD-encrypted blocks packed into slab objects, and the whole filesystem hangs off one signed, hash-chained root record. Every mutation is a single transaction group and therefore a single root record, which is where most of the guarantees below come from. +The guest's filesystem is `wasmtime-wasi`'s own implementation over a real directory: its tenant's ZFS dataset, preopened as `/`. Operations behave as they do on a Linux ZFS mount, with the exceptions below. See [STORAGE.md](STORAGE.md) for what is underneath. -## ✅ POSIX-equivalent +## The same as Linux on ZFS -| WASI op | Notes | -|---|---| -| `read`, `write`, `read-via-stream`, `write-via-stream`, `append-via-stream` | Buffered per handle at record granularity. | -| `sync`, `sync-data` | Commits a transaction group and publishes a new signed root. | -| `stat`, `stat-at` | Read from the dnode on every call, so they cannot go stale. `link-count` and all three timestamps are real values, not placeholders. | -| `set-times`, `set-times-at` | A field write in the dnode. No `CopyObject`, and nothing else to disagree with. | -| `set-size` | Truncate or grow, at any size. Growth is a hole and costs nothing. | -| `create-directory-at`, `remove-directory-at`, `unlink-file-at` | One commit each. | -| **`rename-at`** | **Atomic.** One directory-entry move inside one commit. A directory rename costs the same as a file rename. | -| `read-directory` | Name-ordered, straight from the directory's leaves — no sort step. | -| `symlink-at`, `readlink-at` | Targets up to 294 bytes live in the dnode; longer ones spill to data blocks. Cycle detection at depth 40. | -| `open-at` with `CREATE` / `EXCLUSIVE` / `TRUNCATE` | Create is atomic inside its transaction. | -| **`link-at`** | **Hard links.** A directory entry is an object id, so a link is one more entry and an increment of `nlink`. Files and symlinks only. | -| **`unlink-file-at` while an fd is open** | **POSIX behaviour.** The object stays readable and writable through existing handles until the last one closes. | -| `is-same-object`, `metadata-hash`, `metadata-hash-at` | Exact. Identity is `(object id, generation)`, which is stable across mounts because object ids are never reused. | - -**Crash consistency.** The visible state is always a Merkle-verified snapshot, never a torn one. Slabs written by a commit that died before publishing its root are orphans: unreferenced, unreachable, harmless. There is no journal and no fsck. - -**Integrity.** Every block read verifies a BLAKE3 checksum against its parent's block pointer and then opens an AEAD whose additional data binds the block to its exact position in the tree. A block cannot be corrupted, forged, substituted, or relocated without the read failing. - -**Rollback.** Root records are written with `If-None-Match: *` into a bucket under Object Lock COMPLIANCE retention, and each carries the hash of its predecessor. An adversary with full write access to the buckets can make the filesystem unreadable, but cannot make it read *wrong* and cannot make it read *old*. +Reads, writes, streams, `stat`, `set-times`, `set-size`, directories, `rename-at` (atomic), `link-at`, `symlink-at` and `readlink-at`, unlinking an open file, `is-same-object` and `metadata-hash` behave as ZFS does. There is no translation layer of our own in between. -## ⚠️ Weakened semantics +## Different -| WASI op | Gap | Contract | +| What | How | Why | |---|---|---| -| Crash while a file is unlinked-but-open | The dnode leaks: allocated, nameless, unreachable. | Garbage, not corruption — the same trade a real filesystem makes with its orphan inode list. Reclaimed by the garbage collector when that lands. | -| Two writers to the same file | Each handle buffers independently; whichever syncs last wins. | Within one mount, use one handle per file. | -| Two mounts of the same buckets | Exactly one can win a given root sequence. The loser is **poisoned**: every subsequent operation fails, including reads. | Deliberate. Retrying would reuse a transaction group and repeat every AEAD nonce in it. Single writer per filesystem. | -| Unsynced writes on a crash | Lost. | The transaction-group model: durability is at `sync` / `close`, and what survives is always consistent. | -| Cold-mount freshness | A store that *hides* roots newer than the one it serves is not cryptographically excluded. Object Lock means those roots cannot be deleted, so this requires S3 itself to lie. | Pass `--min-root-seq` to set a floor from outside the store. Within a session the gap does not exist: the accepted sequence only ever rises. | -| `atime` | Only updated when a handle is synced. | No read-time metadata writes; a commit per read would be absurd. | - -A hard link to a *directory* returns `not-permitted`, as on Linux: it would make the namespace a graph rather than a tree, and the dnode's parent link has room for exactly one answer. - -## ❌ Not implemented - -Nothing in `wasi:filesystem@0.2.6` is unsupported. +| `sync`, `sync-data` | Return without making anything durable (`sync=disabled`). | Durability is the anchor, taken before a response ends, a task's outcome is recorded, or a message reply is sent. A guest cannot be told its write is durable earlier than that, and is never told later. | +| A crash | Goes on from whatever reached the disk: writes no anchor covered may survive in part or not at all — as a crash on a local disk may keep part of an unsynced write. Anchors cover the whole pool, so another request's anchor may already have kept part of an unfinished request's writes. | Nothing unanchored was acknowledged. | +| Paths out of the preopen | `..` past it, absolute paths and symlinks out of it are refused by `cap-std`. | The tenant's directory is a capability. | +| `atime` | Not updated. | Reads must not write. | +| `.zfs` | ZFS's control directory (`snapdir=hidden`) may be reachable by name inside a tenant's own dataset. It holds nothing: no snapshots are taken. | A ZFS default, not exercised by the tests. | -## Snapshots +## Not available -Every root record is a complete, self-verifying snapshot. Copy-on-write means the blocks an older root names were never overwritten, so taking one costs nothing and keeping one costs only the storage its blocks already occupy. +Shared memory, `mmap` and advisory locks are not in WASI, so SQLite needs `locking_mode=EXCLUSIVE` and `temp_store=MEMORY`, and cannot use WAL. See [STORAGE.md](STORAGE.md#running-sqlite). -```rust -let snaps = fs.snapshots(10).await?; // newest first -let snap = fs.open_snapshot(snaps[3].seq).await?; -let ino = snap.lookup(&snap.root(), "deleted-file").await?; -let bytes = snap.read(&ino, 0, 4096).await?; -``` +## Storage format versions -Reads through a snapshot verify exactly as the live mount does — same checksums, same position binding — because a snapshot is not a copy of anything. It is the same blocks, reached through an older root. +The state-origin receipt names a layout version (`zfs/state-origin/vN`), so a store cannot be opened under a runtime that means something different by it. -Opening one does not lower the live mount's rollback floor. Reading history must never become a way to make the store rewind. - -## Operational notes - -- **The bucket is not browsable.** Contents are opaque, encrypted, content-independent blocks. `aws s3 ls` shows slabs and roots, nothing resembling a path. -- **No garbage collection yet.** Copy-on-write means superseded blocks accumulate. Roots are ~700 bytes each and locked for the full retention term; slabs are the cost driver and live in an unlocked bucket precisely so they can be reclaimed later. -- **Keys are supplied, not discovered.** The master secret and the filesystem id are inputs. The keys that verify a root record derive from them, so reading either out of the store would mean trusting the store to say which key checks its own signature. - -## Running SQLite - -SQLite works, including `VACUUM`, triggers, foreign-key cascades, recursive -CTEs, window functions, and `PRAGMA integrity_check`. Two settings are needed, -and neither is a limitation of this filesystem: +| Version | Layout | +|---|---| +| v1 | One ZFS pool for the whole enclave, its anchors under `{prefix}zfs/`. | +| v2 | A control pool and a pool per tenant, anchors under `{prefix}zfs/control/` and `{prefix}zfs/pools//`. Current. | -```rust -conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?; // no fcntl under WASI -conn.pragma_update(None, "temp_store", "MEMORY")?; // no access(2) under WASI -``` +A v2 runtime **refuses a v1 store rather than touching it**: it finds the v1 anchors, sees no v2 control pool, and stops with a message pointing here — before it claims a generation, imports, or writes anything. A v1 store is never resumed or reformatted in place. (The safety does not rest on the version string alone: a v1 store still holds its sealed key, so the boot takes the resume path, not genesis, and so can never mint a second history over it.) -`temp_store=MEMORY` is the non-obvious one. SQLite locates a directory for -temporary databases by probing candidates with `access(2)`, which WASI does not -provide — so every candidate is rejected, and `VACUUM` fails with a bare -`disk I/O error` that says nothing about a missing syscall. Setting -`temp_store_directory` does not help: that pragma validates the path the same -way and reports `not a writable directory`. +### Migrating a v1 store to v2 -`journal_mode=DELETE` (the default) is fine and worth keeping: the rollback -journal is a real sidecar file, created, extended, truncated and unlinked on -every transaction. WAL is not usable — it needs shared memory that WASI has no -way to provide. +There is no in-place migration; the layouts put the bytes in different places. The procedure is offline and keeps the original until the new store is proven: -WAL is not usable — it coordinates through a shared-memory index that WASI has -no way to provide — and concurrent connections are out for the same reason -`locking_mode=EXCLUSIVE` is needed. Neither restricts anything real here: the -store is single-writer by design. +1. Leave the v1 EBS volume and its roots-bucket prefix **untouched** — do not boot a v2 runtime against them. +2. Bring up v2 on a **new** volume and a **new** bucket prefix (a fresh genesis). +3. Re-establish each tenant from the application's own source of truth, or copy guest data out of the v1 store with a one-off v1-capable reader and back in through v2. Tenant pools are created on first use. +4. Retire the v1 volume and prefix only once the v2 store is verified serving. -JSON, FTS5 and R-Tree are all present in the bundled build and all verified -working. [`examples/guest-sqlite`](../examples/guest-sqlite/) covers DDL, -transactions, savepoints, every constraint kind, joins, CTEs, window functions, -blobs including incremental `sqlite3_blob_open` I/O, `ATTACH`, `WITHOUT ROWID`, -generated columns, partial and expression indexes, `DROP`, `VACUUM`, and -`integrity_check` before and after a reopen. The README has the full notes and -the benchmark table. +Guest data does not carry across automatically: a tenant's v1 files lived in one shared pool, and v2 puts each tenant's in its own. ## Test coverage -333 unit tests plus a MinIO integration suite covering the real S3 wire protocol, Object Lock retention, end-to-end write/read/remount, slab packing economics, tamper detection, and mount-floor enforcement. Design lineage: ZFS for the storage model, [GeeseFS](https://github.com/yandex-cloud/geesefs) for the original path-mapping engine this replaced. +The runtime's in-process suites run guests over a plain directory standing in for the pool (`zfs::Disk::Directory`): +- tenant isolation, including a hostile guest's escape attempts; +- restarts; +- tasks, streams and devices; +- streaming responses over HTTP/1.1, HTTP/2 and gRPC. + +ZFS itself (the anchor, crash recovery, rollback and fork refusal) is exercised in the QEMU emulator by [`run-zfs-spike.sh`](../deploy/qemu-nitro/run-zfs-spike.sh) and the end-to-end suite. diff --git a/docs/DEV_ENCLAVE.md b/docs/DEV_ENCLAVE.md index 1460319..0c5d6fa 100644 --- a/docs/DEV_ENCLAVE.md +++ b/docs/DEV_ENCLAVE.md @@ -4,7 +4,7 @@ A real enclave on your own machine, to develop a client against. ```bash sudo modprobe vsock_loopback # once per boot -docker build -t s3fs-qemu-nitro:latest deploy/qemu-nitro # QEMU with the nitro-enclave machine; ~680 MB +docker build -t enclave-qemu-nitro:latest deploy/qemu-nitro # QEMU with the nitro-enclave machine; ~680 MB cargo install vhost-device-vsock --root target/qemu-nitro/tools deploy/qemu-nitro/dev-enclave.sh --guest path/to/your-component.wasm @@ -23,8 +23,8 @@ after the enclave starts serving: the e2e asserts and exits, this waits. The enclave boots as an EIF under QEMU's `nitro-enclave` machine — the same image format and the same boot path as production, measured into PCR0 by the -hypervisor. It takes its address by DHCP over emulated vsock, mounts the -encrypted block store over that link, fetches your component from the store and +hypervisor. It takes its address by DHCP over emulated vsock, brings up its ZFS +pool on a disk image served over vsock, fetches your component from the store and measures it into PCR16 before it can obtain a key, orders a certificate from a CA over RFC 8555 TLS-ALPN-01, and gates every request on a WebAuthn assertion bound to that exact request. @@ -184,7 +184,7 @@ image with the image options given and puts into `DIR` everything a run needs: ``` DIR/ ├── image.env every image option, the runtime revision, and the image tags below -├── eif/ s3fs-qemu.eif, pcr.json +├── eif/ enclave-qemu.eif, pcr.json ├── bin/ gvproxy (static); nitro-attest, passkey-client, vhost-device-vsock (libc only) ├── images/ qemu.tar.gz, minio.tar.gz — the container images, loaded on first run ├── deploy/qemu-nitro/ this harness, as it was when the image was built @@ -248,7 +248,7 @@ so does deleting it by hand while the enclave is down. |---|---| | `no /dev/vsock` | `sudo modprobe vsock_loopback` | | `no /dev/kvm` | the `nitro-enclave` machine needs KVM; it will not run in a VM without nested virtualisation | -| `missing QEMU image` | `docker build -t s3fs-qemu-nitro:latest deploy/qemu-nitro` | +| `missing QEMU image` | `docker build -t enclave-qemu-nitro:latest deploy/qemu-nitro` | | `... is not tracked by git` | Nix flakes copy only tracked files; `git add deploy/qemu-nitro/` | -| the enclave never reported a trust root | `S3FS_COSIGN_ATTESTATIONS` is unset in the image — check `eif-qemu`'s environment in `flake.nix` | +| the enclave never reported a trust root | `ENCLAVE_COSIGN_ATTESTATIONS` is unset in the image — check `eif-qemu`'s environment in `flake.nix` | | a port is in use | `--port`, and `--name` if you want two at once | diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index e63bd17..1112e92 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -53,8 +53,8 @@ One setting. Empty means off. | Setting | Purpose | |---|---| -| `S3FS_PUSH_APP_ID` | The push application. Baked into the image, so PCR0 covers it. Not a secret | -| `S3FS_PUSH_ENDPOINT` | Send to a stub instead, signed with a placeholder. Only a `testing` build has it | +| `ENCLAVE_PUSH_APP_ID` | The push application. Baked into the image, so PCR0 covers it. Not a secret | +| `ENCLAVE_PUSH_ENDPOINT` | Send to a stub instead, signed with a placeholder. Only a `testing` build has it | Requests go to `pinpoint..amazonaws.com` in the runtime's region, signed (SigV4, service `mobiletargeting`) at the trusted clock's time as the instance's diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3163439..a993729 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,12 @@ # Roadmap +> **Since this was written:** the state moved off the block store below. Tenant +> data and the runtime's own records are on a ZFS pool on a disk the parent +> serves over vsock, anchored in the roots bucket after every change — see +> [STORAGE.md](STORAGE.md). M0–M7 and M9 describe the retired s3fs engine and +> are kept as history; M8 (key release) shipped. On Nitro the parent serves the +> pool's EBS volume with stock nbdkit; that path is not yet proven on hardware. + The storage engine is complete (M0–M7, `b11f95b`) and so is the runtime that loads a guest and gives it the filesystem (M10). What remains is the enclave's own plumbing — proving to KMS which code is running — and reclaiming space. @@ -66,12 +73,12 @@ register. KMS and S3 go through a proxy on the parent. This needs a seam that does not exist: -- [`AwsS3BackendConfig`](../crates/s3fs-core/src/backend/aws.rs) has no +- [`AwsS3BackendConfig`](../runtime/src/store/backend/aws.rs) has no `http_client` field, so the SDK cannot be pointed at a vsock connector. Add `http_client: Option` and thread it into `connect_unchecked`. - Static credentials there are built with `None` expiry - ([aws.rs](../crates/s3fs-core/src/backend/aws.rs)), so a KMS-attested + ([aws.rs](../runtime/src/store/backend/aws.rs)), so a KMS-attested session token expires mid-run with no recovery. Needs a refresh path. **3c. Prove the enclave can reach IMDS.** The runtime reaches the metadata @@ -88,7 +95,7 @@ though the network were broken. **3b. Cover the clock.** The guest's wall clock now comes from `/dev/ptp0` (see the README), but two things remain. The Object Lock retention deadline in -[`RootStore::root_retention`](../crates/s3fs-core/src/store/root.rs) still uses +`RootStore::root_retention` (in the retired engine) still uses `SystemTime::now()` — and a COMPLIANCE deadline computed from a wrong clock cannot be corrected afterwards by anyone, which makes it the sharpest version of this problem in the codebase. And attestation should eventually cover *which* @@ -100,7 +107,7 @@ the name it lives under: a `DeleteObject` with no version id writes a delete marker, and `HEAD`/`GetObject` then report an Object-Locked record missing while it sits underneath, undeletable. That made "hide the tip" a legal S3 call rather than a lie from S3, and made a hidden state-origin receipt indistinguishable from -none — which authorises genesis. [`Backend::get_retained_blob`](../crates/s3fs-core/src/backend/mod.rs) +none — which authorises genesis. [`Backend::get_retained_blob`](../runtime/src/store/backend/mod.rs) reads the version through `ListObjectVersions`; the boot machine and `RootStore::exists`/`load` use it. Needs `s3:ListBucketVersions` on the enclave's role, and errors rather than answering "absent" without it. @@ -120,7 +127,7 @@ to be absent and Object Lock keeps it present. The *rollback* half remains — `--min-root-seq` is still supplied by hand. Carrying it in the KMS encryption context would make the floor something the enclave receives from a service the storage operator does not control — the one thing that closes the residual risk documented in -[`store/root.rs`](../crates/s3fs-core/src/store/root.rs). +`store/root.rs` (in the retired engine). ### Testing @@ -209,7 +216,7 @@ crates/ Configuration is environment-first with matching flags, because inside an enclave the image's `ENV` lines are the only configuration there is. The guest -inherits that environment minus anything under `AWS_` or `S3FS_` — credentials +inherits that environment minus anything under `AWS_` or `ENCLAVE_` — credentials and the runtime's own settings — which is the one rule that keeps the master key away from the code the enclave exists to contain. @@ -223,10 +230,10 @@ code that reads the data rather than only to the runtime that loads it. Almost nothing structural, by design: - `StaticKey` becomes `KmsAttestedKey` — one more implementation of - `enclave_runtime::MasterKeySource`, and `S3FS_MASTER_KEY` stops being read at all. + `enclave_runtime::MasterKeySource`, and `ENCLAVE_MASTER_KEY` stops being read at all. - `AwsS3BackendConfig` gains the `http_client` field so the SDK can be pointed at the parent's vsock proxy, plus a credential refresh path. - `RootRecord::attestation` starts carrying the PCR digest, turning the anchor chain into a record of *which code* wrote each state. -- `S3FS_MIN_ROOT_SEQ` moves into the KMS encryption context, so the freshness +- `ENCLAVE_MIN_ROOT_SEQ` moves into the KMS encryption context, so the freshness floor comes from a service the storage operator does not control. diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 096f992..7298c61 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -1,171 +1,214 @@ # Storage -The encrypted filesystem behind `wasi:filesystem`. A guest uses ordinary file APIs; this document describes what happens underneath, how the store's identity is checked at boot, how SQLite runs on it, and how to embed the engine without the runtime. +Each tenant's files live on a ZFS pool of their own, and the runtime's own records on a **control pool**. All the pools share one disk the untrusted parent serves over vsock, cut into equal regions, each region its own encrypted device with a key the parent never sees. After each change a pool's state is pinned by a signed **anchor** in an Object-Locked S3 bucket, one chain per pool. A guest uses ordinary file APIs: `wasi:filesystem` is `wasmtime-wasi`'s, over its tenant pool's `data` dataset and nothing else. -The engine began as **s3-wasi-fs** and remains independently usable through `s3fs-core`, without Wasmtime or enclave hardware. +This replaced the runtime's original storage engine, s3fs: a copy-on-write filesystem of its own on S3. The design goal is the same: the host cannot read the state, alter it, or roll it back unnoticed. The machinery underneath is ZFS, which has decades of production use behind it. ## How it works -### From a pathname to a root record +### The stack ```text -wasi:filesystem descriptors and streams - │ - ▼ -Fs: paths, links, directories, handles, buffered records - │ - ▼ -Directory B+tree ──► object IDs ──► dnode array - │ - indirect block trees - │ - encrypted blocks in slabs - │ - signed root record + chain + /pools//data a tenant's pool, mounted here: the guest's only preopen + /runtime/{credentials,tasks,streams,devices,catalog} on the control pool + pool "enclave" (region 0) the control pool: records and the tenant catalog + pool "p" (region N) a tenant's pool: one data dataset + /dev/mapper/zcrypt- per-region plain dm-crypt, aes-xts-plain64, a key per pool + /dev/nbd0 the parent's disk, cut into equal regions: NBD over vsock 10809 ``` -Files and directories are addressed by object ID. Directory entries live in a separator-indexed B+tree. Dnodes describe objects and point into variable-width indirect block trees. Modified paths are rebuilt copy-on-write, so a commit publishes a new tree while older roots continue to describe their original state. +### A pool per tenant -Blocks carry AES-256-GCM authentication and BLAKE3 checksums. Their authentication binds them to their expected position, preventing a valid block from simply being moved elsewhere in the tree. Keys derive from a 32-byte master secret and filesystem identity through HKDF. The data bucket holds slab objects; the roots bucket holds the signed history and runtime bootstrap objects. +The disk is divided into fixed, 4 KiB-aligned regions (`region_bounds` in [`zfs.rs`](../runtime/src/zfs.rs)). Region 0 is the control pool; each tenant is allocated one of the rest on first use. A region is mapped as its own `dm-crypt` device, `zcrypt-`, with a key derived `HKDF(master, fs_uuid, "zfs/dmcrypt/v2" ‖ kind ‖ pool_id)` — domain-separated, so one region's key never decrypts another's. On it sits one pool, `p`, with a single `data` dataset the guest sees as `/`. -Verifying a root authenticates the pointers beneath it; each block is verified when read. Mounting does **not** eagerly read or scrub every file. +The **control pool** holds the runtime's records and the **catalog**: one entry per tenant giving its pool id, its region, and the hash of its pool's anchor 0. The catalog is on the control pool, out of every guest's reach, and validated at boot (regions distinct, in range, none the control pool's; pool ids distinct), so the host cannot point a tenant at another's pool. A tenant's pool id is random and minted once, so a deleted tenant's history can never be restarted under a guessed id. -### Commit and recovery +An anchor names the pool it speaks for — a kind (control or tenant), the pool id, and the tenant id, all signed (`ZFSANCH2`) — and a boot refuses an anchor whose pool id is not the one it expected. Each pool anchors on its own chain, under `zfs/control/anchors/` or `zfs/pools//anchors/`, with its own last-anchor lock, so two tenants anchor independently: one held mid-anchor does not stop another. A tenant pool is imported the first time that tenant is used and kept for the life of the process; the number of pools is bounded by how many regions the disk holds, and registration is refused once none is free. -A transaction stages changed blocks and dnodes, uploads its slabs, waits for those uploads, then publishes a signed root with a conditional PUT. Publishing that root is the visibility boundary. A failed upload must not publish a tree pointing to missing data. Failed file sync retains dirty buffers for a retry. +Because a tenant's data is its own pool, the same-nonce cross-tenant steal the single pool was prone to — one tenant's write landing between another's anchor marker and its sync — cannot happen: an anchor only ever covers its own pool, and a tenant is serialised against itself. Each pool is still verified on import exactly as a boot verifies the control pool (below), so a rolled-back or forked tenant disk is refused when that tenant is next used. -The current transaction protocol also publishes a claim before a newly opened session encrypts blocks, and reclaims after an abandoned or failed transaction. Its purpose is to avoid reusing `(transaction group, block sequence)` as an AEAD nonce after a crash or competing mount. S3 accepts a conditional PUT over a delete marker, so publication then reads back the retained version: a mount whose record was not written first loses, even when its PUT succeeded. +**Capacity.** A region is 200 MiB (`DEFAULT_REGION_MIB`); a disk of _D_ holds ⌊_D_ / region⌋ pools, control included. A tenant that fills its region gets `ENOSPC`; the disk filling up refuses new registrations, not existing tenants. Growing capacity means a larger disk (more regions); a region is never grown in place, and there is one disk, not one volume per tenant. -This is a **single-writer filesystem**. Independent writable mounts and multiple active schedulers are not a supported deployment model. A conflict poisons the losing mount rather than silently merging histories. +The region size is **fixed for the life of a store**, not a runtime flag. A region's offset on the disk is its index times its size, so changing the size would move every tenant's region out from under its dm-crypt mapping and read it as corruption. To make it a deployment setting it would have to be chosen at genesis, committed in the control pool's anchored state, and checked to match on every resume — otherwise a changed flag silently relocates every pool. Until then it is a constant, changed only by rebuilding the image, and a store carries the size it was created with. -### Filesystem behavior +- **The pool.** The enclave loads `spl.ko` and `zfs.ko` at boot. They are built against a kernel compiled from AWS's own enclave recipe; see [the parent side](#the-parent-side). +- **The disk.** The parent's NBD server is reached over vsock. The kernel refuses a vsock socket for NBD, so it is handed one end of a Unix socket pair, and two threads copy between that and the vsock connection. +- **The roots bucket** holds what must outlive any disk and that no host may rewrite. That is the anchor chain, the state-origin receipt and pair records, the sealed master key's pointer, the guest, and the sealed ACME cache. -| Operation or property | Behavior | +### What the host can do to the disk + +Without the dm-crypt key, the host can only replay sectors it once saw or scramble them. To ZFS both look like ordinary corruption, which its checksums exist to catch. Every block pointer carries its child's checksum (SHA-256 for datasets, fletcher4 for the pool's own metadata), so a stale or scrambled block fails its parent's check on read. Plain dm-crypt has no header, so nothing is parsed from the disk before it is decrypted. + +That leaves three things the host can do: +- make the disk unreadable, which is denial of service and fails closed; +- see which sectors are touched, and when; +- serve an older disk entirely, or one mixing sectors from several. That is the rollback the anchor exists for. + +#### What this rests on + +- **dm-crypt is confidentiality, not integrity.** Plain AES-XTS authenticates nothing. What it buys is that the host cannot choose plaintext: it can put back a sector's old ciphertext, which decrypts to that sector's old contents, or change it, which decrypts to noise. +- **ZFS checksums are not MACs.** They are unkeyed. They catch a replayed or scrambled block because the host cannot make a block whose checksum matches what its parent expects. SHA-256 makes that collision-resistant for dataset blocks. The pool's own metadata uses fletcher4, which is not collision-resistant: there the claim rests on the host being limited to old versions of a sector or noise, never content it chose. +- **The uberblock is the root.** Its own checksum is SHA-256, but any uberblock this pool ever wrote is valid, so which state it names is settled by the anchor: the txg the kernel reports loading, and the nonce inside that state. + +None of this has been checked against a host deliberately mixing old and new sectors of one pool; that is still to be tested. + +### The anchor + +ZFS pins a pool state by its uberblock, but a txg number does not name one: forks reuse numbers, and the uberblocks of every state the disk held may still be on it for the host to serve. So each anchor writes a fresh random value into the pool itself: + +1. `zpool sync`. If no txg has written anything since the last anchor, stop: there is nothing new to anchor. +2. `zfs set enclave:anchor=--`, the marker. This is a sync task: it returns once everything written before it is on the disk. +3. `zpool sync` again. The property change's frees are deferred two txgs; without this sync every later call would see a write and anchor again. +4. Read the newest txg that wrote anything, from `/proc/spl/kstat/zfs/enclave/txgs`. This is the anchored txg. +5. Sign `seq | prev hash | fs id | pool guid | txg | nonce` with the anchor key, and publish it to `zfs/anchors/` with a conditional PUT under Object Lock COMPLIANCE. Read the retained version back, since a delete marker could otherwise let a second writer's PUT through. + +Anchors are serialised across the whole enclave, and each runs in a task of its own, so a caller that gives up cannot cut one short. A failure before the marker changes nothing. Any failure from the marker on stops anchoring for good, an uncertain publish included: retrying could publish over a lost race. + +The marker's txg is older than the anchored one, and other tenants write meanwhile. A write landing between the two is covered by the anchor, and its own anchor call finds nothing newer and is acknowledged. So the disk as of the marker holds the anchor's nonce without that write, and the nonce alone does not pin the state. + +On boot, the runtime imports the newest state on the disk, unmounted, and reads which txg it loaded from the kernel's own notes on that import (`spa_load(enclave, …): using uberblock with txg=N`, in `/proc/spl/kstat/zfs/dbgmsg`, from the last load that ended `LOADED`). It reads the marker from the loaded pool, and admits the pool only if: +- its guid is the anchored pool's; +- the loaded txg is no older than the anchored txg; +- its marker is the newest anchor's, or that of the next anchor (one written but never published) naming the newest as its predecessor. + +| Disk | What happens | |---|---| -| Read/write/stat/list/sync | Implemented through the core `Fs` API and WASI adapter. File writes are buffered; sync/close drives durability. | -| Rename | Directory-entry changes commit atomically; moving a directory does not copy every descendant. Moves into the directory's own subtree are rejected. | -| Sparse files | Growth creates holes; reads return zeroes without materializing all intervening blocks. | -| Hard links | Multiple directory entries reference one object, with a maintained link count. | -| Symlinks | Supported, with a default traversal limit of 40 and resolution confined to the guest's filesystem scope. | -| Unlink while open | The open handle retains access until its final close. | -| Identity and metadata | Object identity, link counts, and timestamps come from the filesystem rather than inferred S3 filenames. | -| Snapshots | Historical root records can be opened read-only through `Fs::open_snapshot` or the store API; they share unchanged blocks. They are not an automatic guest-visible snapshot directory. | -| Freshness | The session floor only rises. `--min-root-seq` supplies an external floor for a cold mount. | -| Space reclamation | Garbage collection is not implemented. Historical and orphaned slabs consume storage. | - -The detailed [compatibility matrix](COMPATIBILITY.md) describes WASI/POSIX differences. This is a WASI filesystem implementation and embeddable Rust engine, not a host FUSE mount or a general replacement for a Linux filesystem. - -### Defaults and cost model - -| Store setting | Default | -|---|---:| -| Record size | 128 KiB | -| Maximum slab size | 64 MiB | -| Decrypted block-cache budget | 64 MiB | -| Concurrent slab PUTs | 8 | -| Root-chain links checked at mount | 1 | -| Per-root COMPLIANCE retention | 10 × 365 days | - -[`StoreConfig`](../crates/s3fs-core/src/store/config.rs) validates these settings. Record sizes must be powers of two from 4 KiB through 1 MiB. Retention is finite: an operational retention policy must account for the history a deployment still relies on. - -S3 round trips dominate durable mutations. Batching application work reduces commit overhead; smaller writes may still rewrite a whole record and its tree path. Tenant handlers can execute concurrently, but their commits share one transaction lock. Snapshot sharing avoids copying the whole filesystem, while retaining old state still costs space for its changed blocks. +| At the anchor | Admitted. | +| Ahead of the anchor (the enclave synced, then died before publishing) | Admitted as it is. Nothing past the anchor was acknowledged, and nothing is rewound. | +| As of the anchor's marker, before its sync | Holds the right nonce, but loaded an older txg. **Refused.** | +| Behind the anchor (rolled back) | An older txg and an older marker. **Refused.** | +| An abandoned fork | Its marker is not the newest anchor's, nor one naming it. **Refused.** | -## State identity at boot +There is no rewind. Before this, boot imported with `zpool import -T `, which takes the newest uberblock *at or below* that txg and, if that one fails to load, quietly tries older ones. Three txgs after an anchor, its blocks can already have been reused. -A missing store is not automatically an invitation to create an empty replacement. The boot machine checks the retained state-origin receipt, the sealed-key object, the genesis root, and the record for the running runtime/guest pair. +Anchors are found by their retained versions, by galloping then bisecting, because they are contiguous and none can be deleted. A host cannot hide the newest one behind a delete marker. A cold boot cannot know on its own whether the store is still adding them; `--min-root-seq` sets a floor from outside. -The origin commitment includes the filesystem ID, data and roots buckets, prefix, genesis root hash, and the SHA-256 of the sealed-key representation. It is encoded with a domain tag and CBOR, then hashed with BLAKE3. The receipt attests to that commitment. +### When a write is durable -| Observed state | Outcome | +`sync=disabled`: `fsync` returns without making anything durable, because durability is the anchor. Nothing is acknowledged until the anchor covering it exists: + +| Write made by | Anchored before | |---|---| -| No origin receipt and no existing state | Genesis: provision key material, create the store, publish origin and pair records. | -| State exists without its receipt, or receipt exists without required state | Refuse to boot. | -| Origin and this runtime/guest pair verify | Resume. | -| Origin verifies but this pair has no record | Upgrade path: record the new pair after key release and state verification. | -| Existing origin/pair record is inconsistent or invalid | Refuse to boot. | +| A request | The response ends. A streamed body streams as written, and only its end (with any trailers, such as gRPC's status) waits. A body with `Content-Length` is held whole, because a client counting bytes would otherwise act on it first. So is a response that cannot have a body (to a HEAD, or a 1xx, 204 or 304), which would otherwise be complete when its head went out. | +| A held connection's message run | Its reply is sent. | +| A background task | Its outcome is recorded. Its attempt is also anchored before it runs, so a task that crashes the enclave still spends the attempt rather than looping on it for ever. | +| A wake | The invocation that raised it is anchored. A `wake` the guest raises during a request, a message or a task is held and delivered only once that invocation's writes are durable — never before, since a wake tells an owner to come and look. If the anchor fails, the wake is dropped. | +| A passkey registration | The response. The tenant's pool is created and its catalog entry written and anchored on the control pool first, then the credential, so both are durable before the user is told they are registered. | +| A passkey revocation | The response; the credential is on the control pool, anchored before the response. | -Bucket identity and policy live in the measured image. Otherwise, a host could point an approved runtime at a different empty bucket and make a new store look legitimate. +One effect still precedes its anchor: a **stream dial or send** (`enclave:streams`). When a guest opens a stream or sends on one, the runtime dials or `POST`s the far side during the invocation, before that invocation's record is anchored. It is left this way deliberately — the dial carries no committed state (it is an attested request, and the far side deduplicates, so a crash before the anchor simply rewinds the stream record and the supervisor re-establishes or drops it on the next boot), and deferring it would mean reworking the reconnect supervisor. Moving it behind the anchor, like wakes, is the remaining piece of the one-operation boundary. -[`Backend::get_retained_blob`](../crates/s3fs-core/src/backend/mod.rs) reads object versions beneath delete markers. The S3 backend follows version-list pagination and fetches a selected version by ID. Missing version-list permissions cause a failure rather than a false report that no record exists. The retained version is the oldest one listed, and root publication uses the same read to confirm it wrote first. +After a crash, the boot goes on from whatever reached the disk. Writes no anchor covered may be there or not; none was acknowledged. -## Running SQLite +### Layout -SQLite is the main application-level filesystem workload: real C code performs page-granular random I/O, rollback-journal updates, and integrity checks. [`guest-sqlite`](../examples/guest-sqlite/) covers DDL, transactions, savepoints, constraints, joins, CTEs, window functions, blobs, triggers, schema changes, `VACUUM`, JSON, FTS5, and R-Tree where available. +| Path | What | +|---|---| +| `/pools//data` | A tenant's pool, mounted here, created on first use. A tenant id is the first 16 bytes of a SHA-256 of the passkey's credential id; a guest without a gate (development only) gets the all-zero id. The pool id is random, in the catalog. | +| `/runtime/catalog/` | One JSON entry per tenant: pool id, region, genesis hash. On the control pool. | +| `/runtime/credentials/` | Passkeys, CBOR, one file each. On the control pool. | +| `/runtime/tasks/`, `/runtime/streams/` | JSON records, each written to a temporary file and renamed over the record. On the control pool. | +| `/runtime/devices//` | Push registrations. On the control pool. | -Build it using wasi-sdk: +No guest can reach `/runtime` (it is on the control pool) or another tenant (it is a different pool, a different encrypted device). A guest's only preopen is its own pool's `data` dataset, and `wasmtime-wasi` resolves paths through `cap-std`, which refuses `..` past the preopen, absolute paths and symlinks out of it. The runtime's records stay on the control pool for now; moving each tenant's records into its own pool is a later step. -```bash -scripts/wasi-sdk.sh -scripts/build-guest.sh sqlite +### Defaults -deploy/qemu-nitro/dev-enclave.sh \ - --guest examples/guest-sqlite/target/wasm32-wasip2/release/guest-sqlite.wasm \ - --guest-env S3FS_BACKGROUND_TASKS=false -``` +| Setting | Value | Why | +|---|---|---| +| `checksum` | `sha256` | Collision-resistant checksums for dataset blocks | +| `sync` | `disabled` | Durability is the anchor, not the ZIL | +| `atime` | `off` | Reads must not write | +| `failmode` | `panic` | A pool that lost its disk stops the enclave; the next boot decides | +| `zfs_txg_timeout` | 3600 s | No txg on a timer; syncs come from anchors | +| `zfs_arc_max`, `zfs_dirty_data_max` | 256 MiB | Enclave memory is fixed | +| Anchor retention | 10 years (`--root-retention-secs`) | COMPLIANCE: nobody can shorten it | -SQLite exports the HTTP handler, not `run-task`, so the override disables the emulator's default background-task startup requirement. Using the printed pins, request `/` with `passkey-client` to run the workload. The response reports `OK` or a failure; timings go to guest output. Each run removes and recreates its benchmark database, so use a dedicated development tenant and do not treat `/` as a health check. `SQLITE_SCALE` controls the workload size; the current example defaults to 2,000 rows. +### Cost -The two required pragmas are: +Measured on the QEMU emulator, with the disk served by `nbd-stub.py` over userspace vsock forwarding, so these numbers are pessimistic: -```rust -conn.pragma_update(None, "temp_store", "MEMORY")?; -conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?; -``` +| What | Measured | +|---|---| +| Anchor, mean: the first `zpool sync` | 59 ms | +| Anchor, mean: both syncs, `zfs set` and the publish | 105 ms | +| Signed POST that writes a file, p50 (client round trip, assertion included) | 260 ms | +| Signed GET that writes nothing, p50 | 183 ms; no anchor is published | + +100 POSTs and 100 GETs produced 100 anchors: a request that writes nothing pays one empty `zpool sync` and no S3 round trip. + +## State identity at boot + +A missing store is not an invitation to create an empty one. The boot checks the retained state-origin receipt, the sealed-key object, and the pair record for the running runtime and guest. + +The receipt commits to BLAKE3 over CBOR of `("zfs/state-origin/v1", fs id, roots bucket, prefix, hash of anchor 0, sha256 of the sealed key)`. -SQLite's temporary-directory probing and advisory locking assume syscalls that this WASI environment does not supply. Use the default rollback-journal mode (`DELETE`). WAL requires the shared-memory/mapping facilities this configuration lacks. Runtime serialization protects one tenant from concurrent callbacks; the guest must still avoid opening competing SQLite connections inside its own invocation. +| Observed state | Outcome | +|---|---| +| No receipt and no sealed key | **Genesis.** Mint the key, take the lease (a conditional PUT of the sealed key), create the control pool in region 0 and publish its anchor 0, attest the receipt, and record the pair. Tenant pools come later, as tenants arrive. | +| Receipt and key verify; this pair has a record | **Resume:** import the control pool at its newest anchor, as above, and validate the catalog. Tenant pools are imported on demand, each verified the same way. | +| Receipt and key verify; this pair has no record | **Upgrade:** record the new pair. | +| Only one of receipt and key | Refuse. | +| A pool on the disk but no anchor, or anchors but genesis | Refuse. | + +The bucket's identity lives in the measured image: `ENCLAVE_ROOTS_BUCKET` is part of PCR0. Otherwise a host could point an approved runtime at an empty bucket and make a new store look legitimate. -### Recorded workload results +## The parent side -The following are the repository's earlier measurements against local MinIO with 20,000 accounts and 40,000 entries. They were **not rerun for this documentation update**, are not current release benchmarks, and should not be extrapolated to remote S3 latency. +| | Emulator | Nitro | +|---|---|---| +| Disk | A sparse file served by [`deploy/qemu-nitro/nbd-stub.py`](../deploy/qemu-nitro/nbd-stub.py) | A gp3 EBS volume ([`deploy/tofu`](../deploy/tofu/main.tf), `pool_size_gib`), served by stock nbdkit from the Amazon Linux 2023 repositories ([`nbdkit.service`](../deploy/ami/units/nbdkit.service)) | +| Kernel | [`nix/kernel-zfs.nix`](../nix/kernel-zfs.nix) | Same image | -| Phase | Elapsed | Rate | -|---|---:|---:| -| Bulk insert, 20,000 rows in one transaction | 142 ms | 140,884 rows/s | -| Point select by row ID | 2.5 ms | 405,948 queries/s | -| Indexed select | 1.3 ms | 785,850 queries/s | -| Update 500 rows in one transaction | 63 ms | 7,963 rows/s | -| Blob write | 128 ms | 18.5 MB/s | -| Blob read and verify | 41 ms | 57.8 MB/s | -| Incremental blob I/O, 512 KiB scattered | 242 ms | 2.2 MB/s | -| `VACUUM` | 471 ms | — | -| `PRAGMA integrity_check` | 136 ms | — | -| 25 inserts, autocommit | 1,796 ms | 14 rows/s | -| 25 inserts, one SQL transaction | 69 ms | 364 rows/s | +[`nix/kernel-zfs.nix`](../nix/kernel-zfs.nix) is AWS's `aws-nitro-enclaves-sdk-bootstrap` recipe: Linux 6.6, their config, and their two patches, including the vsock fix that stops parent and enclave deadlocking when both send queues fill. It adds NBD and dm-crypt, and builds OpenZFS against the result. The build user and host are pinned, so the kernel's version string, and with it PCR0, is the same on every machine. -The practical lesson is to batch writes. A SQL transaction is not necessarily one block-store commit—journal and filesystem sync operations matter—but it can greatly reduce durable round trips compared with repeated autocommit statements. +[`deploy/qemu-nitro/run-zfs-spike.sh`](../deploy/qemu-nitro/run-zfs-spike.sh) plays the hostile host: +- two tenants write, and cross-tenant paths are refused; +- a restart resumes; +- a rolled-back disk is refused; +- a crash between sync and publish resumes from the disk it left; +- the abandoned fork is refused; +- the disk as of an anchor's marker, before its sync, is refused. -## Use the storage engine directly +The testing build stops at named points in an anchor and asks the host, over vsock port 9101, whether to go on (`hook` in [`zfs.rs`](../runtime/src/zfs.rs), answered by [`test-hooks.py`](../deploy/qemu-nitro/test-hooks.py)). A leg can hold an anchor there, copy the disk at that exact moment, or kill the enclave between two steps. -`s3fs-core` can be embedded independently. This complete example creates an in-memory filesystem, commits a file, remounts it, and reads it back. It needs `s3fs-core` and Tokio with macros and a runtime; no NSM, Wasmtime, Docker, or AWS credentials are involved. +### The flaw the last leg is for + +The boot once admitted the disk as of an anchor's marker. Leg 7 found it on the emulator: +1. Alice's anchor was held after its marker, and the disk was copied. +2. Bob's write was acknowledged behind it; one anchor (111, txg 846) covered both. +3. Served the copy, the kernel logged `spa_load(enclave, config untrusted): using uberblock with txg=845`. +4. The boot logged `zfs pool resumed at its anchor seq=111 txg=846`, and bob's acknowledged file was gone. + +Leg 6 showed the same flaw in an honest crash. Three txgs past anchor 107, `-T 810` failed on txg 810 (`couldn't get 'config' value in MOS directory [error=5]`, its blocks already reused) and ZFS fell back to 809, the anchor's own marker. A boot requiring the exact txg would have refused that disk forever, which is why the boot now admits the newest state rather than rewinding. + +## Running SQLite + +[`guest-sqlite`](../examples/guest-sqlite/) covers DDL, transactions, savepoints, constraints, joins, CTEs, window functions, blobs, triggers, schema changes, `VACUUM`, JSON, FTS5 and R-Tree. Build it with wasi-sdk: + +```bash +scripts/wasi-sdk.sh +scripts/build-guest.sh sqlite + +deploy/qemu-nitro/dev-enclave.sh \ + --guest examples/guest-sqlite/target/wasm32-wasip2/release/guest-sqlite.wasm +``` + +The two pragmas WASI requires are unchanged: ```rust -use std::sync::Arc; -use s3fs_core::{backend::memory::MemoryBackend, Config, Fs, MasterSecret, OpenFlags}; - -#[tokio::main(flavor = "current_thread")] -async fn main() -> Result<(), Box> { - let data = Arc::new(MemoryBackend::new()); - let roots = Arc::new(MemoryBackend::new()); - let secret = MasterSecret::from_bytes([7; 32]); // Example only. - let fs_id = [3; 16]; - let config = Arc::new(Config::default()); - - let fs = Fs::create(data.clone(), roots.clone(), &secret, fs_id, config.clone()).await?; - let file = fs.open("/hello.txt", OpenFlags::create_new()).await?; - fs.pwrite(&file, 0, b"hello from encrypted storage").await?; - fs.sync(&file).await?; - fs.close(&file).await?; - drop(fs); - - let fs = Fs::mount(data, roots, &secret, fs_id, config, None).await?; - let file = fs.open("/hello.txt", OpenFlags::read_only()).await?; - let bytes = fs.pread(&file, 0, 64).await?; - assert_eq!(bytes.as_ref(), b"hello from encrypted storage"); - fs.close(&file).await?; - Ok(()) -} +conn.pragma_update(None, "temp_store", "MEMORY")?; // no access(2) under WASI +conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?; // no fcntl locks under WASI ``` -For S3, enable **`s3fs-core`'s** `aws` feature and supply separate `AwsS3Backend` instances for the data and roots buckets. Create the roots bucket with Object Lock support and appropriate retention. Keep the master secret and filesystem ID outside untrusted storage; they select the keys used to verify the store. Use `Fs::create` only for an intentional new filesystem and `Fs::mount` for an existing one. +Use the default rollback journal (`DELETE`). WAL needs shared memory that WASI does not provide. SQLite has not yet been measured on the pool; the earlier figures were for s3fs and no longer apply. + +## Limits -An embedded consumer owns its provisioning, boot policy, freshness floor, and handle lifecycle. The runtime's origin receipts, tenant gate, and KMS orchestration are higher layers, not automatic side effects of calling the core API. +- **One writer per pool.** One enclave imports each pool; there is no second mount to race. A per-boot generation fence (`zfs/generations/`) stops a second enclave the host starts from letting the first go on to acknowledge a write it has forked away from. +- **Anchors are per pool**, so two tenants anchor independently; each anchor is a pool sync plus an S3 round trip. A request also anchors the control pool when it leaves a record there (that cost drops to nothing for a pure-data request once the operation boundary tracks it). +- **A tenant's records are still on the control pool.** Until they move into the tenant's own pool, a request that writes one anchors the control pool too, which serialises those writes across tenants. +- **No tenant-pool eviction yet.** A tenant pool is kept imported for the life of the process; the cap is how many regions the disk holds. +- **The image carries ZFS userspace.** The nixpkgs build references systemd, Python and nfs-utils, about 370 MB of the image. Repackaging only `zpool`, `zfs` and their libraries would recover most of it. +- **EBS is single-AZ.** Restoring a snapshot is a rollback, and the boot refuses it. Disaster recovery means recovering the newest disk, not an older one. +- **The host sees access patterns:** which sectors, when, and how much. diff --git a/docs/STREAMING.md b/docs/STREAMING.md index f7abf36..3366e18 100644 --- a/docs/STREAMING.md +++ b/docs/STREAMING.md @@ -82,7 +82,7 @@ thing between a compromised client and whatever the guest will do. ### What a stolen token buys -It is single-use, expires in `--interaction-token-ttl-secs` (60s by default), and +It is single-use, expires in 60 seconds, and is bound to one route. Spending one gets: one interaction with that tenant's guest, and whatever the guest will do without a further check. It gets nothing about another tenant, and no replay — @@ -111,7 +111,7 @@ no gate is configured.** | **client cancels or disconnects** | the guest's next write fails, the call ends, the instance is dropped and never reused, the tenant lock frees | | **head timeout** (`request_timeout`) | bounds only the *response head*. It does not apply once the head is out, so it never ends a healthy stream | | **no traffic either way** | the epoch watchdog asks whether bytes moved, not how long the call ran. Sustained silence while executing wasm ends the call | -| **interaction deadline** (`--max-interaction-secs`, 300s) | a wall clock, checked whether or not wasm is executing — so it reaches a guest blocked in a host call, which the epoch never can. Ends the interaction and frees the tenant | +| **interaction deadline** (300s) | a wall clock, checked whether or not wasm is executing — so it reaches a guest blocked in a host call, which the epoch never can. Ends the interaction and frees the tenant | | **guest spins** | trapped by the epoch, on the same schedule as before streams existed | | **guest traps** | the instance is discarded, never reused — a partially-executed call leaves a store that cannot be re-entered | | **`grpc-timeout` header** | **not enforced by the runtime.** Forwarded to the guest, which owns it. The runtime's deadlines are its own, so a client cannot lengthen them | diff --git a/examples/guest-http/src/main.rs b/examples/guest-http/src/main.rs index 87b83cf..798b373 100644 --- a/examples/guest-http/src/main.rs +++ b/examples/guest-http/src/main.rs @@ -235,6 +235,16 @@ async fn main(mut req: Request) -> Result, Error> { )), } } + // `/escape/` by metadata rather than contents: a stat that resolved + // outside this guest's directory would leak what is there even where + // a read is refused. + ("GET", p) if p.starts_with("/stat/") => { + let target = &p["/stat/".len()..]; + match fs::metadata(target) { + Ok(m) => Ok(text(StatusCode::OK, format!("stat {target}: {} bytes\n", m.len()))), + Err(e) => Ok(text(StatusCode::NOT_FOUND, format!("refused {target}: {e}\n"))), + } + } // Known values on both streams, for the runtime's guest-logging // tests. Every case the line framer has to get right is here, written // the way a guest would actually write it: a line built from two diff --git a/flake.nix b/flake.nix index f97ccd1..4e0371f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Reproducible AWS Nitro Enclave image (EIF) for the s3fs enclave runtime"; + description = "Reproducible AWS Nitro Enclave image (EIF) for the enclave runtime"; # Why this exists at all: PCR0 is a digest of the enclave image, and a KMS key # policy pins it while a client checks it. That number is only worth anything @@ -44,21 +44,16 @@ }; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; - # `cleanCargoSource` drops everything that is not Rust. Two things here - # are not Rust and are not optional: - # - # .pem the AWS Nitro root, embedded by `include_str!` - # .wit the vendored WASI interfaces, read from disk by `bindgen!` - # - # Losing either fails deep in the build naming something else entirely — - # the WIT one surfaces as `could not find 'wasi' in 'bindings'`. + # `cleanCargoSource` drops everything that is not Rust. One thing here + # is not Rust and is not optional: `.pem`, the AWS Nitro root, embedded + # by `include_str!`. Losing it fails deep in the build naming something + # else entirely. srcFilter = path: type: # Guests are separate workspaces. Including their sources here changes # the runtime's store path (and its image's PCR0) on guest-only edits. !(lib.hasPrefix "${toString ./.}/examples/" (toString path) || toString path == "${toString ./.}/examples") && ((lib.hasSuffix ".pem" path) - || (lib.hasSuffix ".wit" path) || (craneLib.filterCargoSources path type)); workspaceSrc = lib.cleanSourceWith { @@ -154,7 +149,8 @@ # What an operator uploads to the roots bucket, and the PCR16 a key policy # pins for it. The guest is not in the enclave image: the runtime fetches - # `deployment.guestObject` at boot and measures it into PCR16. + # `guest/guest.wasm` from the roots bucket at boot and measures it into + # PCR16. # # `guest-pcr16.json` comes from `nitro-attest --measure`, the function a # client verifies with, so the number in the policy and the number a @@ -241,6 +237,13 @@ inherit blobs eif-build; }; + # Tenant data is on ZFS: AWS's enclave kernel built from source, plus NBD + # and dm-crypt, and the OpenZFS module built against it. + kernelZfs = pkgs.callPackage ./nix/kernel-zfs.nix { }; + zfsModules = "${kernelZfs.zfsKmod}/lib/modules/${kernelZfs.kernel.modDirVersion}/extra"; + # What the runtime runs to bring the pool up: zpool and zfs, and dmsetup. + zfsTools = [ kernelZfs.zfsUser pkgs.lvm2.bin ]; + # gvforwarder brings the tap device up and then shells out to a DHCP # client for its address — `udhcpc` if present, otherwise `dhclient`. # Neither is in the runtime's closure, and the symptom is a forwarder @@ -287,15 +290,21 @@ }); runtimeImageFor = deployment: { - name = "s3fs"; - # No guest here. It is fetched from the store at boot and measured - # into PCR16 — see `guest-release` and S3FS_GUEST_OBJECT below. + name = "enclave"; + kernel = kernelZfs.dir; + # No guest here. It is fetched from `guest/guest.wasm` in the roots + # bucket at boot and measured into PCR16 — see `guest-release`. payload = { "enclave-runtime" = "${enclave-runtime}/bin/enclave-runtime"; "usr/local/bin/gvforwarder" = "${gvproxy-static}/bin/gvforwarder"; + "lib/zfs/spl.ko" = "${zfsModules}/spl.ko"; + "lib/zfs/zfs.ko" = "${zfsModules}/zfs.ko"; }; - closureRoots = [ enclave-runtime busybox pkgs.cacert ]; + closureRoots = [ enclave-runtime busybox pkgs.cacert ] ++ zfsTools; command = "/enclave-runtime"; + # Only what differs between deployments. Everything else — KMS, PTP, the + # NSM, gvproxy, ACME on :443 — is a constant in the runtime, and the + # emulator's alternatives exist only in its `testing` build. env = { # Without a trust store the AWS SDK panics with "no CA certificates # found" before it makes a single request — including against a @@ -311,74 +320,48 @@ # gvforwarder finds its DHCP client with exec.LookPath, so busybox's # own bin directory goes on PATH — no symlinks, and the lease script # it execs resolves through the same closure. - PATH = "${busybox}/bin:/usr/local/bin"; - # Where the guest comes from — not the guest, which is not in the - # image. The runtime fetches this key from the roots bucket, extends - # PCR16 with the object's hash and locks the register before it asks - # KMS for a key. The location is measured here, by PCR0; what arrives - # is measured there, by PCR16, so the object need not be trusted. - S3FS_GUEST_OBJECT = deployment.guestObject; - S3FS_BACKGROUND_TASKS = lib.boolToString deployment.backgroundTasks; - S3FS_BACKGROUND_CONCURRENCY = toString deployment.backgroundConcurrency; + PATH = "${busybox}/bin:/usr/local/bin:${kernelZfs.zfsUser}/bin:${pkgs.lvm2.bin}/bin"; # Push notifications, through an AWS End User Messaging Push # application. Empty means off. Only the application is named: its FCM # channel holds the Firebase credential, and the runtime signs as the # instance's role, so no image carries a secret. - S3FS_PUSH_APP_ID = deployment.pushAppId; + ENCLAVE_PUSH_APP_ID = deployment.pushAppId; # The instance's role, for the runtime alone: gvproxy maps this one # address to the metadata service, which a guest cannot reach at any # address (`serve::egress` refuses the whole proxy network). `AWS_*` # never reaches a guest either. AWS_EC2_METADATA_SERVICE_ENDPOINT = "http://192.168.127.253"; - S3FS_HTTP_LISTEN = "0.0.0.0:443"; - # ACME, not self-signed: a platform authenticator will not attest - # against a certificate a browser does not trust, so a self-signed one - # would mean no passkey could ever register. - S3FS_TLS = "acme"; - S3FS_NETWORK = "gvproxy"; - S3FS_GVFORWARDER = "/usr/local/bin/gvforwarder"; - S3FS_RANDOM_SOURCE = "nsm"; - S3FS_CLOCK_SOURCE = "ptp"; # The store this image is for. Attested, because a receipt only - # means "no filesystem here" if the host cannot choose "here". - S3FS_BUCKET = deployment.dataBucket; - S3FS_ROOTS_BUCKET = deployment.rootsBucket; - S3FS_BUCKET_PREFIX = deployment.bucketPrefix; - S3FS_ID = deployment.fsId; + # means "no pool here" if the host cannot choose "here". + ENCLAVE_ROOTS_BUCKET = deployment.rootsBucket; + ENCLAVE_BUCKET_PREFIX = deployment.bucketPrefix; + ENCLAVE_ID = deployment.fsId; AWS_REGION = deployment.region; - S3FS_TLS_DOMAINS = lib.concatStringsSep "," deployment.tlsDomains; - - # A production image demands a receipt signed by AWS. The emulator - # image overrides this, and because the environment is measured, - # PCR0 tells a client which kind it is talking to. - S3FS_RECEIPT_TRUST = "required"; + # ACME, which a platform authenticator needs: it will not attest + # against a certificate a browser does not trust. + ENCLAVE_TLS_DOMAINS = lib.concatStringsSep "," deployment.tlsDomains; # The master secret is minted by KMS inside the enclave and released # only against an attestation whose PCR0 and PCR16 match the key # policy. A wrong image or a wrong guest does not get a refused - # mount — it gets no key at all. - # - # S3FS_MASTER_KEY is deliberately absent, and the runtime *refuses* - # to start if it is set alongside this: a key from configuration is a - # key the parent instance holds, which is the whole thing this - # prevents. The key and the parameter come from the deployment, since - # they name resources this repository does not own. - S3FS_MASTER_KEY_SOURCE = "kms"; - S3FS_KMS_KEY_ID = deployment.kmsKeyId; - S3FS_MASTER_KEY_PARAMETER = deployment.masterKeyParameter; + # mount — it gets no key at all. The key and the parameter come from + # the deployment, since they name resources this repository does not + # own. + ENCLAVE_KMS_KEY_ID = deployment.kmsKeyId; + ENCLAVE_MASTER_KEY_PARAMETER = deployment.masterKeyParameter; # How long root records are locked — the rollback guarantee's horizon, # and how long the roots bucket outlives the deployment. - S3FS_ROOT_RETENTION_SECS = toString deployment.rootRetentionSecs; + ENCLAVE_ROOT_RETENTION_SECS = toString deployment.rootRetentionSecs; # Guest stdout and stderr go to CloudWatch as well as the console. # The enclave calls PutLogEvents itself, over the same path it uses # for S3 and KMS, so TLS terminates inside and the parent carries - # ciphertext. The group and stream are created by Terraform; this - # runtime holds `logs:PutLogEvents` and cannot make them. - S3FS_GUEST_LOG_GROUP = deployment.guestLogGroup; - S3FS_GUEST_LOG_STREAM = deployment.guestLogStream; + # ciphertext. The group and its `guest` stream are created by + # Terraform; this runtime holds `logs:PutLogEvents` and cannot make + # them. + ENCLAVE_GUEST_LOG_GROUP = deployment.guestLogGroup; # Every request that could reach the guest needs a fresh WebAuthn # assertion bound to exactly that request. Without an RP id the @@ -386,9 +369,8 @@ # says so at startup — which is a development arrangement, not this # one. The domain must be the one the app's passkeys are scoped to, # and it must be browser-trusted, hence ACME rather than self-signed. - S3FS_WEBAUTHN_RP_ID = deployment.rpId; - S3FS_WEBAUTHN_ORIGIN = "https://" + deployment.rpId; - S3FS_WEBAUTHN_ALLOWED_ORIGINS = lib.concatStringsSep "," deployment.webauthnAllowedOrigins; + ENCLAVE_WEBAUTHN_RP_ID = deployment.rpId; + ENCLAVE_WEBAUTHN_ALLOWED_ORIGINS = lib.concatStringsSep "," deployment.webauthnAllowedOrigins; # # Registration is open: anyone who can reach the port may create a # tenant of their own. There is nothing to provision, and nothing an @@ -403,7 +385,7 @@ # # packages.x86_64-linux.eif = enclave-runtime.lib.x86_64-linux.mkEif (import ./deployment.nix); # - # The result is `s3fs.eif` and `pcr.json`, as `packages.eif`. + # The result is `enclave.eif` and `pcr.json`, as `packages.eif`. mkEif = deployment: callEif (runtimeImageFor deployment); # The runtime configured for the emulator, as a function of the relying @@ -438,15 +420,15 @@ # production binary has no flag that would read one. "pebble-ca.pem" = ./deploy/qemu-nitro/pebble/ca.pem; }; - closureRoots = [ enclave-runtime-testing busybox pkgs.cacert ]; - name = "s3fs-qemu"; + closureRoots = [ enclave-runtime-testing busybox pkgs.cacert ] ++ zfsTools; + name = "enclave-qemu"; # The key settings go: this image keeps the static key below, and the # runtime refuses one alongside KMS settings rather than pick between them. env = builtins.removeAttrs runtimeImage.env [ - "S3FS_KMS_KEY_ID" - "S3FS_MASTER_KEY_PARAMETER" + "ENCLAVE_KMS_KEY_ID" + "ENCLAVE_MASTER_KEY_PARAMETER" ] // { - S3FS_CLOCK_SOURCE = "host"; + ENCLAVE_CLOCK_SOURCE = "host"; # QEMU's NSM does not sign at all: its source says "we don't # actually sign the data, so we use -1 as the 'alg' value", and -1 @@ -463,9 +445,9 @@ # relaxed variant of it. The root is reported on the console at # startup; `deploy/qemu-nitro/lib.sh` captures it for the clients. # - # Testing-only, like `S3FS_ACME_CA`: the production binary has no + # Testing-only, like `ENCLAVE_ACME_CA`: the production binary has no # such flag, so no deployment can sign its own attestations. - S3FS_COSIGN_ATTESTATIONS = "1"; + ENCLAVE_COSIGN_ATTESTATIONS = "1"; # And receipts stay content-checked, because that chain is minted # fresh at every boot. A receipt signed at genesis names a root the @@ -473,17 +455,13 @@ # every restart into a refusal to resume. Contents are still # verified — PCR0, PCR16 and the state_root — which is the whole # boot machine minus the one part that needs real hardware. - S3FS_RECEIPT_TRUST = "unsigned-emulator"; - # A directory per client, which the e2e exercises with two client - # certificates. Concurrency has to rise with it or every client - # still queues behind every other and the per-client locks buy - # nothing. + ENCLAVE_RECEIPT_TRUST = "unsigned-emulator"; # And it cannot use KMS at all, for the same reason: KMS verifies # the attestation document carrying the enclave's recipient public # key, and will not accept one that is unsigned. So the emulator # keeps the development key source. PCR0 differs between the two # images, so a client can tell which it is talking to. - S3FS_MASTER_KEY_SOURCE = "static"; + ENCLAVE_MASTER_KEY_SOURCE = "static"; # Real ACME, against a Pebble running on the host — the same code # path production takes, which is the whole reason to prefer it. # @@ -498,30 +476,21 @@ # loopback where gvproxy forwards :443 into this enclave — so the # TLS-ALPN-01 challenge arrives on the same port the service uses, # which is exactly the arrangement production runs. - S3FS_TLS = "acme"; - S3FS_TLS_DOMAINS = nixpkgs.lib.concatStringsSep "," tlsDomains; - S3FS_ACME_DIRECTORY = acmeDirectory; - # The e2e schedules work and waits for it to run. Production leaves - # this off in deployment.nix; turning it on here changes only the - # emulator's PCR0, and the harness checks PCR0 against its own - # `nix build` rather than against a published number, so nothing - # downstream moves. The guest is guest-http, which exports the - # `run-task` the runtime refuses to start without when this is set. - S3FS_BACKGROUND_TASKS = "true"; + ENCLAVE_TLS_DOMAINS = nixpkgs.lib.concatStringsSep "," tlsDomains; + ENCLAVE_ACME_DIRECTORY = acmeDirectory; # Notifications, against a stub on the host rather than AWS — which # would refuse an invented registration token anyway. What the e2e # checks is what the *runtime* sends, and the stub records exactly # that. The `http://` endpoint is the same downgrade # `--guest-log-endpoint` already is, and PCR0 records it. A real # application replaces both below. - S3FS_PUSH_APP_ID = "e2e"; + ENCLAVE_PUSH_APP_ID = "e2e"; # Off. Inherited from the production image, and there is no AWS # here to send to: the harness's credentials are MinIO's, which - # CloudWatch would reject. Empty means off — the same shape as the - # TLS override above, and the reason `guest_log_config` treats an - # empty setting as unset rather than as a typo. - S3FS_GUEST_LOG_GROUP = ""; - S3FS_GUEST_LOG_STREAM = ""; + # CloudWatch would reject. Empty means off, which is why + # `guest_log_config` treats an empty setting as unset rather than as + # a typo. + ENCLAVE_GUEST_LOG_GROUP = ""; # The gate, with a relying party the harness can drive. The e2e # exercises it with the software passkey the `testing` feature # provides, so the default is a name nothing else answers to. @@ -531,27 +500,25 @@ # domain publishes assetlinks.json naming the app, and the app claims # `android:apk-key-hash:` rather than the web origin. The rp id # is independent of the certificate's name, which stays enclave.test. - S3FS_WEBAUTHN_RP_ID = rpId; - S3FS_WEBAUTHN_ORIGIN = "https://${rpId}"; - S3FS_ENDPOINT = "http://192.168.127.254:9000"; - S3FS_FORCE_PATH_STYLE = "1"; - S3FS_BUCKET = "e2e-data"; - S3FS_ROOTS_BUCKET = "e2e-roots"; - S3FS_MASTER_KEY = "00000000000000000000000000000000000000000000000000000000000000ab"; + ENCLAVE_WEBAUTHN_RP_ID = rpId; + ENCLAVE_ENDPOINT = "http://192.168.127.254:9000"; + ENCLAVE_FORCE_PATH_STYLE = "1"; + ENCLAVE_ROOTS_BUCKET = "e2e-roots"; + ENCLAVE_MASTER_KEY = "00000000000000000000000000000000000000000000000000000000000000ab"; AWS_ACCESS_KEY_ID = "minioadmin"; AWS_SECRET_ACCESS_KEY = "minioadmin"; AWS_REGION = "us-east-1"; - RUST_LOG = "info,s3fs=debug"; + RUST_LOG = "info"; } // nixpkgs.lib.optionalAttrs (acmeCa != null) { - S3FS_ACME_CA = acmeCa; + ENCLAVE_ACME_CA = acmeCa; } // nixpkgs.lib.optionalAttrs (acmeContacts != [ ]) { - S3FS_ACME_CONTACTS = nixpkgs.lib.concatStringsSep "," acmeContacts; + ENCLAVE_ACME_CONTACTS = nixpkgs.lib.concatStringsSep "," acmeContacts; } // nixpkgs.lib.optionalAttrs (pushAppId == null) { - S3FS_PUSH_ENDPOINT = "http://192.168.127.254:9180"; + ENCLAVE_PUSH_ENDPOINT = "http://192.168.127.254:9180"; } // nixpkgs.lib.optionalAttrs (pushAppId != null) { - S3FS_PUSH_APP_ID = pushAppId; + ENCLAVE_PUSH_APP_ID = pushAppId; } // nixpkgs.lib.optionalAttrs (allowedOrigins != [ ]) { - S3FS_WEBAUTHN_ALLOWED_ORIGINS = nixpkgs.lib.concatStringsSep "," allowedOrigins; + ENCLAVE_WEBAUTHN_ALLOWED_ORIGINS = nixpkgs.lib.concatStringsSep "," allowedOrigins; }; }); @@ -577,8 +544,8 @@ eif = callEif runtimeImage; # The same runtime, configured for the emulator. Neither image contains - # the guest: the e2e uploads `guest-release` to MinIO, at the key - # `deployment.guestObject` names, and the enclave measures it there. + # the guest: the e2e uploads `guest-release` to MinIO, at + # `guest/guest.wasm`, and the enclave measures it there. # # A different image, and therefore a *different PCR0* — which is the # honest outcome: an enclave image is its configuration as much as its @@ -602,6 +569,17 @@ withClosure = false; }; + # The same self-test on the kernel built from source, to show it boots, + # reaches init's heartbeat over vsock and loads its own nsm.ko. + eif-selftest-zfskernel = callEif { + name = "selftest"; + payload = { "nsm-selftest" = "${nsm-selftest}/bin/nsm-selftest"; }; + command = "/nsm-selftest"; + env = { }; + withClosure = false; + kernel = kernelZfs.dir; + }; + default = self.packages.${system}.eif; }; diff --git a/nix/eif.nix b/nix/eif.nix index e5b7e1a..b1c1e64 100644 --- a/nix/eif.nix +++ b/nix/eif.nix @@ -46,6 +46,10 @@ # Shell run inside the assembled rootfs/, for anything a plain file copy # cannot express — symlinks, scripts, permissions. , extraSetup ? "" + # bzImage, bzImage.config, cmdline and nsm.ko. AWS's prebuilt ones unless a + # kernel built from source is passed (nix/kernel-zfs.nix). `init` always + # comes from the blobs: it does not depend on the kernel. +, kernel ? "${blobs}/blobs/x86_64" }: let @@ -77,7 +81,7 @@ stdenvNoCC.mkDerivation { # ---- ramdisk 1: what boots ----------------------------------------- mkdir -p rd1 cp ${blobs}/blobs/x86_64/init rd1/init - cp ${blobs}/blobs/x86_64/nsm.ko rd1/nsm.ko + cp ${kernel}/nsm.ko rd1/nsm.ko chmod +x rd1/init # ---- ramdisk 2: what runs ------------------------------------------ @@ -148,9 +152,9 @@ stdenvNoCC.mkDerivation { # exception. faketime -f '@1970-01-01 00:00:01' \ eif_build \ - --kernel ${blobs}/blobs/x86_64/bzImage \ - --kernel_config ${blobs}/blobs/x86_64/bzImage.config \ - --cmdline "$(cat ${blobs}/blobs/x86_64/cmdline)" \ + --kernel ${kernel}/bzImage \ + --kernel_config ${kernel}/bzImage.config \ + --cmdline "$(cat ${kernel}/cmdline)" \ --ramdisk ramdisk1.cpio \ --ramdisk ramdisk2.cpio \ --output ${name}.eif \ diff --git a/nix/kernel-zfs.nix b/nix/kernel-zfs.nix new file mode 100644 index 0000000..b49f909 --- /dev/null +++ b/nix/kernel-zfs.nix @@ -0,0 +1,96 @@ +# The enclave kernel, built from source rather than taken from AWS's prebuilt +# blobs, for the ZFS storage spike. +# +# The blobs (Linux 4.14) have no NBD and no device-mapper, so they cannot +# attach the parent's disk over vsock or put dm-crypt under it, and ZFS's +# module has to be compiled against the exact kernel it loads into. This is +# AWS's own enclave kernel recipe — aws-nitro-enclaves-sdk-bootstrap: the 6.6 +# line, their config, their two patches — with only the block layers added. +# +# The vsock patch is not optional here. It removes a pushback path that +# deadlocks when the parent's and the enclave's TX queues fill together, and +# block I/O over vsock is exactly that load. It is not upstream as of 6.12, +# and does not apply there, which is why this stays on 6.6. +{ lib +, stdenv +, fetchFromGitHub +, runCommand +, flex +, bison +, linux_6_6 +, linuxManualConfig +, linuxPackagesFor +, zfs +}: + +let + bootstrap = fetchFromGitHub { + owner = "aws"; + repo = "aws-nitro-enclaves-sdk-bootstrap"; + rev = "f718dea60a9d9bb8b8682fd852ad793912f3c5db"; + hash = "sha256-DmNnH6lqweRz1u8nza6FvgJFvDGPactpHruyq0SYkp8="; + }; + + kernelPatches = map (name: { inherit name; patch = "${bootstrap}/kernel/${name}"; }) [ + "nsm.patch" + "0001-vsock-virtio-Remove-queued_replies-pushback-logic.patch" + ]; + + # What olddefconfig must keep. Anything it cannot satisfy it drops without + # a word, so each is checked rather than trusted. + required = [ + "MODULES=y" "NSM=m" "VSOCKETS=y" "VIRTIO_VSOCKETS=y" + "BLK_DEV_NBD=y" "BLK_DEV_DM=y" "DM_CRYPT=y" + # OpenZFS's configure refuses a kernel without these. + "ZLIB_INFLATE=y" "ZLIB_DEFLATE=y" + ]; + + configfile = stdenv.mkDerivation { + name = "nitro-enclave-zfs-kernel-config"; + inherit (linux_6_6) src; + patches = map (p: p.patch) kernelPatches; + nativeBuildInputs = [ flex bison ]; + dontConfigure = true; + buildPhase = '' + cat ${bootstrap}/kernel/microvm-kernel-config-x86_64 - > .config <<'EOF' + CONFIG_NSM=m + CONFIG_BLK_DEV_NBD=y + CONFIG_MD=y + CONFIG_BLK_DEV_DM=y + CONFIG_DM_CRYPT=y + CONFIG_CRYPTO_AES_NI_INTEL=y + EOF + make olddefconfig + for o in ${lib.concatStringsSep " " required}; do + grep -qx "CONFIG_$o" .config || { echo "olddefconfig dropped CONFIG_$o" >&2; exit 1; } + done + ''; + installPhase = "cp .config $out"; + }; + + kernel = linuxManualConfig { + inherit (linux_6_6) src version modDirVersion; + inherit configfile kernelPatches; + allowImportFromDerivation = true; + # The version string the kernel embeds is part of PCR0. Unpinned it names + # whoever built it (`joshua@joshua-OptiPlex-7070`), so two machines + # produce two measurements. The same values as AWS's recipe. + extraMakeFlags = [ "KBUILD_BUILD_USER=nixbuild" "KBUILD_BUILD_HOST=nixbuilder" ]; + }; + + zfsKmod = (linuxPackagesFor kernel).${zfs.kernelModuleAttribute}; +in +assert zfsKmod.version == zfs.version; +{ + inherit kernel zfsKmod; + zfsUser = zfs; + + # The layout eif.nix reads in place of blobs/x86_64. + dir = runCommand "nitro-enclave-zfs-kernel" { } '' + mkdir -p $out + cp ${kernel}/bzImage $out/bzImage + cp ${configfile} $out/bzImage.config + cp ${bootstrap}/kernel/cmdline/x86_64.cmdline $out/cmdline + cp "$(find ${kernel.modules}/lib/modules -name nsm.ko)" $out/nsm.ko + ''; +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index dd8e335..fc8d1c4 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -48,10 +48,16 @@ default = [] # `nitro-attestation/testing` costs nothing new: it is `dep:rcgen` plus # `dep:time`, and rcgen is already enabled here by this very feature. It is # what lets `testing::Enclave` mint documents a client can actually verify. -testing = ["dep:rcgen", "nitro-attestation/testing"] +testing = ["dep:rcgen", "dep:tempfile", "nitro-attestation/testing"] [dependencies] -s3fs-core = { path = "../crates/s3fs-core", features = ["aws"] } +# The roots bucket: anchors, boot records, the guest, the ACME cache. +aws-sdk-s3 = { workspace = true, features = ["rt-tokio", "rustls", "behavior-version-latest"] } +aws-smithy-runtime-api = { workspace = true } +aws-smithy-types = { workspace = true } +thiserror = { workspace = true } +parking_lot = { workspace = true } +siphasher = { workspace = true } nitro-nsm = { path = "../crates/nitro-nsm" } nitro-attestation = { path = "../crates/nitro-attestation" } @@ -133,17 +139,19 @@ hex = "0.4" # The PTP clock is read through the POSIX dynamic-clock mechanism. rustix is # already compiled into the tree via wasmtime, so this adds no build cost and # avoids a direct libc dependency. -rustix = { version = "1", features = ["time", "fs"] } +rustix = { version = "1", features = ["time", "fs", "net", "system"] } # Pinned to whatever `wasmtime-wasi` accepts for `secure_random`, which is # cap-rand's re-export of rand 0.8 — not a version to guess at. cap-rand = "3" getrandom = "0.3" +# `zfs::Disk::Directory`: the pool stand-in for tests, under `testing`. +tempfile = { version = "3", optional = true } clap = { version = "4", features = ["derive", "env"] } anyhow = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } -tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "macros", "signal", "net", "io-util", "time"] } +tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "macros", "signal", "net", "io-util", "time", "process", "fs"] } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # CiphertextForRecipient — what KMS returns when the response is encrypted to @@ -185,6 +193,10 @@ serde_json = "1.0.151" serde = { version = "1.0.229", features = ["derive"] } [dev-dependencies] +tempfile = "3" +# `tests/minio_integration.rs`: the S3 backend against a real MinIO. +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["minio"] } # `StaticReplayClient`: canned HTTP responses for the CloudWatch client, so the # tests assert the request that is actually serialised rather than a mock of # one. A dev-dependency, so none of it reaches the image. It is already in the @@ -193,7 +205,6 @@ serde = { version = "1.0.229", features = ["derive"] } aws-smithy-http-client = { version = "1", features = ["test-util"] } # `start_paused`: a timeout test should not spend the timeout. tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "test-util"] } -aws-smithy-types = { workspace = true } # Building canned HTTP requests/responses for the replay client. http = "1" # The HTTP/2 client half, for the streaming suites. Driving h2 from a real @@ -229,8 +240,8 @@ x509-parser = "0.18" nitro-nsm = { path = "../crates/nitro-nsm", features = ["testing"] } # Builds genuinely signed attestation documents for the TLS binding tests. nitro-attestation = { path = "../crates/nitro-attestation", features = ["testing"] } -# The fresh-versus-warm instance question: how much of a request is -# instantiation, and is it enough to justify pooling instances per client. +# What the fresh instance every request gets costs, against the request +# around it. criterion = { version = "0.5", features = ["async_tokio"] } # Building the CMS envelope a test has to unwrap. Both arrive with `cms` # anyway; naming them here keeps the test encoder honest rather than reaching diff --git a/runtime/benches/instance_cost.rs b/runtime/benches/instance_cost.rs index cbe885a..bbc282a 100644 --- a/runtime/benches/instance_cost.rs +++ b/runtime/benches/instance_cost.rs @@ -1,25 +1,24 @@ //! What a fresh guest instance actually costs, and what a request costs around //! it. //! -//! This benchmark exists to answer one question with a number rather than an -//! intuition: **is per-client instance pooling worth its complexity?** Pooling -//! saves exactly one thing — the instantiation in the middle of every request — -//! and buys it at the price of per-client lifecycle, eviction, and a -//! concurrency model where two clients run at once. That trade is only worth -//! making if instantiation is a large fraction of a request. +//! Every request gets a fresh instance, and this benchmark keeps that choice +//! honest with a number: **what fraction of a request is instantiation?** +//! Keeping instances between requests would save exactly that, at the price of +//! state that outlives the call that made it. It is only worth reconsidering +//! if the fraction grows large. //! //! So the interesting output is not any single line, it is the ratio: //! //! ```text -//! instantiate ÷ dispatch/committing → what pooling could remove +//! instantiate ÷ dispatch/committing → what keeping instances could remove //! ``` //! -//! Against [`MemoryBackend`] there is no network, so `dispatch/committing` -//! measures the engine's own cost — encryption, hashing, the copy-on-write -//! rebuild, the commit. Against real S3 the round trips are added to the -//! denominator and never to the numerator, so **the ratio measured here is the -//! most favourable case pooling will ever see.** If it is small here, it is -//! smaller in production. +//! Over a directory standing in for the pool there is no anchor, so +//! `dispatch/committing` measures the guest and its file I/O. In production an +//! anchor — a pool sync and a write to S3 — is added to the denominator and +//! never to the numerator, so **the ratio measured here is the most favourable +//! case keeping instances will ever see.** If it is small here, it is smaller +//! in production. //! //! ```bash //! (cd examples/guest-http && cargo build --release --target wasm32-wasip2) @@ -35,9 +34,6 @@ use tokio::runtime::Runtime; use bytes::Bytes; use enclave_runtime::{GuestEnvironment, HostClock, ServeHandle}; use http_body_util::{BodyExt, Full}; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::backend::Backend; -use s3fs_core::{Config, Fs, MasterSecret}; use wasmtime_wasi_http::p2::bindings::http::types::Scheme; use wasmtime_wasi_http::p2::body::HyperOutgoingBody; @@ -64,22 +60,13 @@ fn component_bytes() -> Vec { }) } -/// A handle over a memory-backed filesystem, compiled once. +/// A handle over a scratch pool, compiled once. /// /// Compilation and `instantiate_pre` are startup costs the runtime pays once /// for the life of the process, so they belong in setup — measuring them here /// would drown the per-request number this benchmark exists to find. async fn handle() -> ServeHandle { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the memory-backed filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; // Detached deliberately: the collector runs for as long as this // environment can send, which is what a test wants. Production drains it @@ -88,7 +75,7 @@ async fn handle() -> ServeHandle { enclave_runtime::guest_io::start(std::sync::Arc::new(enclave_runtime::TracingLogSink)); let guest = GuestEnvironment::new( fs, - Box::new(HostClock), + Arc::new(HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), &[], &[], @@ -121,30 +108,14 @@ async fn get(handle: &ServeHandle, path: &str) { resp.into_body().collect().await.expect("collecting body"); } -/// A filesystem that already exists, so it can be mounted rather than created. -async fn existing() -> (Arc, Arc) { - let data = Arc::new(MemoryBackend::new()) as Arc; - let roots = Arc::new(MemoryBackend::new()) as Arc; - Fs::create( - data.clone(), - roots.clone(), - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the filesystem"); - (data, roots) -} - fn bench(c: &mut Criterion) { let rt = runtime(); let handle = rt.block_on(handle()); let mut group = c.benchmark_group("guest"); - // The numerator. Exactly what a warm pool would skip: a fresh `Store` and - // an `instantiate_async`, with no request dispatched through it. + // The numerator. What every request pays: a fresh `Store` and an + // `instantiate_async`, with no request dispatched through it. group.bench_function("instantiate", |b| { b.to_async(&rt) .iter(|| async { handle.verify_instantiates().await.expect("instantiates") }); @@ -156,44 +127,13 @@ fn bench(c: &mut Criterion) { b.to_async(&rt).iter(|| get(&handle, "/memory")); }); - // A whole request that reads, modifies, writes and commits — one - // transaction group, one root record. This is the denominator that matters, - // because it is the shape of real work, and against real S3 it grows while - // `instantiate` does not. + // A whole request that reads, modifies and writes a file. This is the + // denominator that matters, because it is the shape of real work, and with + // a real pool's anchor it grows while `instantiate` does not. group.bench_function("dispatch/committing", |b| { b.to_async(&rt).iter(|| get(&handle, "/counter")); }); - // What a *per-client* filesystem would cost to bring up: derive its key - // material, read its signed root record, verify the signature, open the - // object set. Against `MemoryBackend` this is the CPU floor and nothing - // else — in production the root record and the object set are reads against - // S3, so the real figure is this plus round trips. - // - // Worth measuring beside `instantiate`, because if instances are per client - // then so is this, and it is the larger of the two by orders of magnitude. - // Whatever a per-client design keeps warm, this is the thing worth keeping. - let rt2 = runtime(); - let (data, roots) = rt2.block_on(existing()); - group.bench_function("mount", |b| { - b.to_async(&rt2).iter(|| { - let data = data.clone(); - let roots = roots.clone(); - async move { - Fs::mount( - data, - roots, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - None, - ) - .await - .expect("mounting") - } - }); - }); - group.finish(); } diff --git a/runtime/src/auth/credential.rs b/runtime/src/auth/credential.rs index ab09d04..607221c 100644 --- a/runtime/src/auth/credential.rs +++ b/runtime/src/auth/credential.rs @@ -1,31 +1,35 @@ //! Registered passkeys, and the tenants they speak for. //! -//! Records live in `/runtime/credentials/` in the shared filesystem — **above -//! every tenant's scope**, so no guest can name them. That is not a convention -//! here: `974dac2` made the resolver refuse every path that leaves a tenant's -//! subtree, so a guest asking for `/runtime/credentials/…` gets its own -//! `runtime/credentials/…`, which does not exist. +//! Records live in `/runtime/credentials/` on the pool — **outside every +//! tenant's directory**, so no guest can name them: a guest's only preopen is +//! its own `/tenants/`, and `wasmtime-wasi` resolves nothing above it. //! -//! Being in the filesystem at all is what makes them trustworthy. The tree is -//! covered by one signed root record and that record is attested by the boot -//! receipt, so a host cannot add a credential, repoint one at another tenant, -//! or un-revoke one without breaking a signature the enclave checks before it -//! serves anything. +//! Being on the pool at all is what makes them trustworthy. It is encrypted +//! under a key the host never sees and pinned by an anchor the boot checks, +//! so a host cannot add a credential, repoint one at another tenant, or +//! un-revoke one — nor roll back to before a revocation — without the boot +//! refusing the pool. //! -//! ## The tenant id is minted, not derived +//! ## The tenant id is derived from the credential id //! -//! Thirty-two random bytes from the NSM at first registration, stored beside -//! the credential. Deriving it from the credential would have been simpler and -//! would have been wrong: a tenant is a *person*, and a person has a phone, a -//! tablet and a hardware key. Derivation would tie the identity to one of -//! them, so adding a backup passkey would silently create a second tenant with -//! an empty filesystem — the worst possible outcome dressed as a feature. +//! [`tenant_of`]: nothing to mint and nothing to store. It was once sixteen +//! random bytes from the NSM, so that a host could not predict a tenant's +//! directory and create it first. On the pool a host cannot create anything, +//! and a guest can name nothing but its own directory, so an unpredictable +//! name protects nothing. +//! +//! A credential id is registered once (`create_new`) and kept when revoked, +//! so it names one tenant forever. The cost is that a passkey *is* its tenant: +//! a replacement passkey starts with an empty directory. A synced passkey is +//! the same credential on every device, so a new phone is not a new passkey. +//! When rotation is built, an optional per-record override names an existing +//! tenant, and a record without one keeps deriving — nothing on disk changes. //! //! ## Why the sign counter is not persisted per request //! //! The counter exists to spot a cloned authenticator. Writing it on every -//! request would put a filesystem transaction — and in production a commit to -//! S3 — in the path of every signature, to record a number that platform +//! request would put an anchor — a pool sync and a write to S3 — in the path +//! of every signature, to record a number that platform //! passkeys synced through iCloud or Google report as zero forever. //! //! So it is held in memory, checked for regression while the process lives, @@ -38,17 +42,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use std::path::PathBuf; + use anyhow::{Context, Result}; -use s3fs_core::{Fs, FsError, Inode, OpenFlags}; use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; use webauthn_rs::prelude::Passkey; use crate::auth::gate::{CredentialRecord, CredentialStore}; -use crate::tenant::ensure_dir; +use crate::zfs::Zfs; -/// Runtime-owned state, above every tenant scope. -pub const RUNTIME_DIR: &str = "runtime"; -/// Registered credentials, one file each. +/// Registered credentials, one file each, under `/runtime`. pub const CREDENTIALS_DIR: &str = "credentials"; /// Ceiling on a record. A `Passkey` is a few hundred bytes; this is loose @@ -63,7 +67,6 @@ const MAX_RECORD_BYTES: usize = 8 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredCredential { pub version: u32, - pub tenant_id: [u8; 16], pub passkey: Passkey, /// Revoked credentials are kept, not deleted. A lost phone's key must be /// refused *by name*; forgetting it would mean the same credential could @@ -73,32 +76,43 @@ pub struct StoredCredential { pub counter: u32, } -const RECORD_VERSION: u32 = 1; +/// 2: the tenant id is derived, not stored. A version-1 record carries a +/// minted one, and is refused rather than quietly given a different directory. +pub(crate) const RECORD_VERSION: u32 = 2; -/// Credentials in the shared filesystem, with the volatile parts in memory. +/// Credentials on the pool, with the volatile parts in memory. pub struct FilesystemCredentials { - fs: Arc, - /// Records read from the filesystem, plus the counters that are not - /// written back. Also the invalidation point: revocation updates both. + zfs: Arc, + /// Records read from the pool, plus the counters that are not written + /// back. Also the invalidation point: revocation updates both. cache: Mutex, CredentialRecord>>, } impl FilesystemCredentials { - pub fn new(fs: Arc) -> Self { + pub fn new(zfs: Arc) -> Self { FilesystemCredentials { - fs, + zfs, cache: Mutex::new(HashMap::new()), } } - async fn dir(&self) -> Result> { - let root = self.fs.root(); - let runtime = ensure_dir(&self.fs, &root, RUNTIME_DIR).await?; - ensure_dir(&self.fs, &runtime, CREDENTIALS_DIR).await + async fn path(&self, credential_id: &[u8]) -> Result { + Ok(self + .zfs + .runtime_dir(CREDENTIALS_DIR) + .await? + .join(hex::encode(credential_id))) } - fn name(credential_id: &[u8]) -> String { - hex::encode(credential_id) + fn encode(record: &StoredCredential) -> Result> { + let mut bytes = Vec::new(); + ciborium::into_writer(record, &mut bytes).context("encoding a credential record")?; + anyhow::ensure!( + bytes.len() <= MAX_RECORD_BYTES, + "credential record is {} bytes, over the {MAX_RECORD_BYTES} limit", + bytes.len() + ); + Ok(bytes) } /// Write a credential, refusing to overwrite one that exists. @@ -107,50 +121,30 @@ impl FilesystemCredentials { /// error rather than a silent repointing — which is what taking over /// somebody else's tenant would look like. pub async fn register(&self, credential_id: &[u8], record: StoredCredential) -> Result<()> { - let dir = self.dir().await?; - let path = format!( - "/{RUNTIME_DIR}/{CREDENTIALS_DIR}/{}", - Self::name(credential_id) - ); - let _ = dir; - - let mut bytes = Vec::new(); - ciborium::into_writer(&record, &mut bytes).context("encoding a credential record")?; - anyhow::ensure!( - bytes.len() <= MAX_RECORD_BYTES, - "credential record is {} bytes, over the {MAX_RECORD_BYTES} limit", - bytes.len() - ); - - let handle = self - .fs - .open(&path, OpenFlags::create_new()) + let path = self.path(credential_id).await?; + let bytes = Self::encode(&record)?; + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) .await - .map_err(|e| anyhow::anyhow!(e)) - .with_context(|| format!("creating {path}"))?; - self.fs - .pwrite(&handle, 0, &bytes) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(&bytes) .await - .map_err(|e| anyhow::anyhow!(e)) .context("writing a credential record")?; - // Committed before the caller is told it is registered: a registration - // that had not reached the store would be a passkey the user believes - // works and the next boot has never heard of. - self.fs - .sync(&handle) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("committing a credential record")?; - self.fs - .close(&handle) + drop(file); + // Anchored before the caller is told it is registered: a registration + // the anchor did not cover would be a passkey the user believes works + // and the next boot has never heard of. + self.zfs + .anchor_control() .await - .map_err(|e| anyhow::anyhow!(e)) - .context("closing a credential record")?; + .context("anchoring a credential record")?; self.cache.lock().expect("credentials poisoned").insert( credential_id.to_vec(), CredentialRecord { - tenant_id: record.tenant_id, + tenant_id: tenant_of(credential_id), passkey: record.passkey, active: record.active, counter: record.counter, @@ -159,14 +153,21 @@ impl FilesystemCredentials { Ok(()) } - /// Mark a credential unusable, in the filesystem and in memory. + /// Mark a credential unusable, on the pool and in memory. pub async fn revoke(&self, credential_id: &[u8]) -> Result<()> { let mut stored = self .read(credential_id) .await? .context("no such credential")?; stored.active = false; - self.overwrite(credential_id, &stored).await?; + tokio::fs::write(self.path(credential_id).await?, Self::encode(&stored)?) + .await + .context("writing a credential record")?; + // A revocation a rewind could undo is not one. + self.zfs + .anchor_control() + .await + .context("anchoring a revocation")?; // The cache is what the gate reads, so it must not outlive the // decision to revoke by even one request. if let Some(cached) = self @@ -180,66 +181,20 @@ impl FilesystemCredentials { Ok(()) } - async fn overwrite(&self, credential_id: &[u8], record: &StoredCredential) -> Result<()> { - let path = format!( - "/{RUNTIME_DIR}/{CREDENTIALS_DIR}/{}", - Self::name(credential_id) - ); - let mut bytes = Vec::new(); - ciborium::into_writer(record, &mut bytes).context("encoding a credential record")?; - - let handle = self - .fs - .open(&path, OpenFlags::write_only()) - .await - .map_err(|e| anyhow::anyhow!(e)) - .with_context(|| format!("opening {path}"))?; - self.fs - .set_size(&handle, 0) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("truncating a credential record")?; - self.fs - .pwrite(&handle, 0, &bytes) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("writing a credential record")?; - self.fs - .sync(&handle) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("committing a credential record")?; - self.fs - .close(&handle) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("closing a credential record")?; - Ok(()) - } - async fn read(&self, credential_id: &[u8]) -> Result> { - let path = format!( - "/{RUNTIME_DIR}/{CREDENTIALS_DIR}/{}", - Self::name(credential_id) - ); - let handle = match self.fs.open(&path, OpenFlags::read_only()).await { - Ok(handle) => handle, - Err(FsError::NotFound) => return Ok(None), - Err(e) => return Err(anyhow::anyhow!(e)).with_context(|| format!("opening {path}")), + let path = self.path(credential_id).await?; + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), }; - let bytes = self.fs.pread(&handle, 0, MAX_RECORD_BYTES).await; - // Before `?`, so a read error does not leak the handle on every attempt. - self.fs - .close(&handle) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("closing a credential record")?; - let bytes = bytes - .map_err(|e| anyhow::anyhow!(e)) - .context("reading a credential record")?; - + anyhow::ensure!( + bytes.len() <= MAX_RECORD_BYTES, + "credential record {} is over the {MAX_RECORD_BYTES} limit", + path.display() + ); let stored: StoredCredential = - ciborium::from_reader(bytes.as_ref()).context("decoding a credential record")?; + ciborium::from_reader(bytes.as_slice()).context("decoding a credential record")?; anyhow::ensure!( stored.version == RECORD_VERSION, "credential record is version {}, this build understands {RECORD_VERSION}", @@ -264,7 +219,7 @@ impl CredentialStore for FilesystemCredentials { return Ok(None); }; let record = CredentialRecord { - tenant_id: stored.tenant_id, + tenant_id: tenant_of(credential_id), passkey: stored.passkey, active: stored.active, counter: stored.counter, @@ -299,16 +254,15 @@ impl CredentialStore for FilesystemCredentials { } } -/// Thirty-two bytes of tenant identity, from the enclave's own entropy. +/// The tenant a credential speaks for: the first sixteen bytes of +/// `sha256("enclave/tenant/v1" || credential_id)`. /// -/// The NSM rather than the host's RNG: an identifier the host could predict -/// would let it create a tenant directory before the tenant did. -pub fn mint_tenant_id(entropy: &Arc) -> Result<[u8; 16]> { - let mut id = [0u8; 16]; - entropy - .get_random(&mut id) - .context("drawing a tenant id from the NSM")?; - Ok(id) +/// Every tenant's dataset is named by this, so changing it strands them all; +/// `tenant_of_is_pinned` is what notices. +pub fn tenant_of(credential_id: &[u8]) -> [u8; 16] { + let digest = + nitro_attestation::sha256(&[b"enclave/tenant/v1".as_slice(), credential_id].concat()); + digest[..16].try_into().expect("16 bytes") } #[cfg(test)] @@ -316,26 +270,14 @@ mod tests { use super::*; use crate::auth::testing::{Relying, RP_ID}; use crate::auth::SoftwareAuthenticator; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, MasterSecret}; - - async fn shared_fs() -> Arc { - let backend = Arc::new(MemoryBackend::new()); - Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("the shared filesystem") + + async fn shared_fs() -> Arc { + Zfs::scratch().await } - fn stored(tenant_id: [u8; 16], passkey: Passkey) -> StoredCredential { + fn stored(passkey: Passkey) -> StoredCredential { StoredCredential { version: RECORD_VERSION, - tenant_id, passkey, active: true, created_ms: 0, @@ -344,7 +286,7 @@ mod tests { } async fn registered() -> ( - Arc, + Arc, FilesystemCredentials, SoftwareAuthenticator, [u8; 16], @@ -354,11 +296,11 @@ mod tests { let rp = Relying::new(); let auth = SoftwareAuthenticator::new(RP_ID); let passkey = rp.register(&auth); - let tenant_id = [0x11; 16]; store - .register(auth.credential_id(), stored(tenant_id, passkey)) + .register(auth.credential_id(), stored(passkey)) .await .expect("registering"); + let tenant_id = tenant_of(auth.credential_id()); (fs, store, auth, tenant_id) } @@ -396,7 +338,7 @@ mod tests { let other = SoftwareAuthenticator::new(RP_ID); let passkey = rp.register(&other); assert!(store - .register(auth.credential_id(), stored([0x22; 16], passkey)) + .register(auth.credential_id(), stored(passkey)) .await .is_err()); } @@ -437,50 +379,35 @@ mod tests { ); } - /// Several passkeys, one tenant. The reason the tenant id is minted rather - /// than derived from a credential. + /// Every tenant's dataset is named by this value. A change here is every + /// tenant waking up to an empty directory, so it has to be deliberate. + #[test] + fn tenant_of_is_pinned() { + assert_eq!( + hex::encode(tenant_of(b"credential")), + "a0e215ab9e6d4fc553fe89c4d78b844f" + ); + } + + /// A version-1 record carries a minted tenant id this build no longer + /// reads. Refused, rather than quietly given a derived one, which would be + /// a different directory. #[tokio::test] - async fn many_credentials_can_share_one_tenant() { + async fn a_version_1_record_is_refused() { let fs = shared_fs().await; let store = FilesystemCredentials::new(fs); - let rp = Relying::new(); - let tenant_id = [0x33; 16]; - - let devices: Vec<_> = (0..3).map(|_| SoftwareAuthenticator::new(RP_ID)).collect(); - for device in &devices { - let passkey = rp.register(device); - store - .register(device.credential_id(), stored(tenant_id, passkey)) - .await - .unwrap(); - } - for device in &devices { - let found = store.lookup(device.credential_id()).await.unwrap().unwrap(); - assert_eq!( - found.tenant_id, tenant_id, - "a device reached another tenant" - ); - } - - // And revoking one leaves the others working, which is the whole point - // of being able to lose a phone. - store.revoke(devices[0].credential_id()).await.unwrap(); - assert!( - !store - .lookup(devices[0].credential_id()) - .await - .unwrap() - .unwrap() - .active - ); - assert!( - store - .lookup(devices[1].credential_id()) - .await - .unwrap() - .unwrap() - .active - ); + let auth = SoftwareAuthenticator::new(RP_ID); + let old = StoredCredential { + version: 1, + ..stored(Relying::new().register(&auth)) + }; + tokio::fs::write( + store.path(auth.credential_id()).await.unwrap(), + FilesystemCredentials::encode(&old).unwrap(), + ) + .await + .unwrap(); + assert!(store.lookup(auth.credential_id()).await.is_err()); } /// A counter that does not advance is recorded, not fatal — a synced @@ -510,25 +437,23 @@ mod tests { ); } - /// Records are runtime-owned and sit above every tenant scope, so a guest - /// cannot name them. This asserts the location, which is what the resolver - /// guarantee rests on. + /// Records are runtime-owned and sit outside every tenant's directory, so + /// no guest preopen reaches them. This asserts the location, which is what + /// that guarantee rests on. #[tokio::test] - async fn credentials_live_above_every_tenant_scope() { - let (fs, _store, auth, _) = registered().await; - let root = fs.root(); - let runtime = fs.lookup_at(&root, RUNTIME_DIR).await.unwrap(); - let creds = fs.lookup_at(&runtime, CREDENTIALS_DIR).await.unwrap(); - assert!(fs - .lookup_at(&creds, &hex::encode(auth.credential_id())) + async fn credentials_live_outside_every_tenant_directory() { + let (zfs, _store, auth, tenant_id) = registered().await; + let record = zfs + .runtime_dir(CREDENTIALS_DIR) .await - .is_ok()); + .unwrap() + .join(hex::encode(auth.credential_id())); + assert!(record.exists()); - // A tenant's scope is a sibling of `/runtime`, never an ancestor. - let tenant = crate::tenant::tenant_root_by_id(&fs, [0x11; 16]) - .await - .unwrap(); - assert_ne!(tenant.scope.objid(), root.objid()); - assert_ne!(tenant.scope.objid(), runtime.objid()); + let (tenant, _) = zfs.tenant_dir(tenant_id).await.unwrap(); + assert!( + !record.starts_with(&tenant), + "a tenant's directory contains the credentials" + ); } } diff --git a/runtime/src/auth/gate.rs b/runtime/src/auth/gate.rs index f77da66..8428d02 100644 --- a/runtime/src/auth/gate.rs +++ b/runtime/src/auth/gate.rs @@ -1,6 +1,6 @@ //! The gate: no verified assertion, no guest. //! -//! Everything a request must survive before a tenant exists, let alone a warm +//! Everything a request must survive before a tenant exists, let alone an //! instance. [`Gate::verify`] is the only way past it and it either returns a //! [`Verified`] tenant or a [`Denied`]; there is no third outcome and no //! caller-supplied way to skip it. @@ -71,11 +71,9 @@ const MAX_TOKEN_CHARS: usize = 128; /// What a client is known as, once it has proved it. #[derive(Debug, Clone)] pub struct Verified { - /// Sixteen bytes, matching [`crate::tenant::TenantRoot`] — the plan said - /// thirty-two, but the directory layout already used sixteen and one - /// number for one thing is worth more than the extra bits. A minted, - /// collision-checked 128-bit identifier is ample; it is not a secret and - /// nothing derives a key from it. + /// Sixteen bytes, derived from the credential id by + /// [`crate::auth::credential::tenant_of`] and naming the tenant's dataset. + /// It is not a secret and nothing derives a key from it. pub tenant_id: [u8; 16], } diff --git a/runtime/src/auth/mod.rs b/runtime/src/auth/mod.rs index f5440f8..1d760c4 100644 --- a/runtime/src/auth/mod.rs +++ b/runtime/src/auth/mod.rs @@ -44,7 +44,7 @@ mod token; #[cfg(any(test, feature = "testing"))] pub use authenticator::SoftwareAuthenticator; pub use challenge::{ChallengeError, ChallengeStore, DEFAULT_CAPACITY, DEFAULT_TTL}; -pub use credential::{mint_tenant_id, FilesystemCredentials, StoredCredential}; +pub use credential::{tenant_of, FilesystemCredentials, StoredCredential}; pub use gate::{ Authenticated, CredentialRecord, CredentialStore, Denied, Gate, Verified, AUTHORIZATION_HEADER, AUTH_HEADERS, @@ -75,7 +75,7 @@ pub fn build_relying_party( ) -> anyhow::Result { use anyhow::Context as _; let url = webauthn_rs::prelude::Url::parse(origin) - .with_context(|| format!("--webauthn-origin {origin:?} is not a URL"))?; + .with_context(|| format!("the origin {origin:?} is not a URL"))?; let mut builder = webauthn_rs::WebauthnBuilder::new(rp_id, &url) .with_context(|| format!("relying party {rp_id:?} at {origin:?}"))?; for allowed in allowed_origins { diff --git a/runtime/src/auth/routes.rs b/runtime/src/auth/routes.rs index 6d43e6a..d6f0b13 100644 --- a/runtime/src/auth/routes.rs +++ b/runtime/src/auth/routes.rs @@ -27,7 +27,7 @@ use bytes::Bytes; use serde::{Deserialize, Serialize}; use webauthn_rs::prelude::*; -use super::credential::{FilesystemCredentials, StoredCredential}; +use super::credential::{tenant_of, FilesystemCredentials, StoredCredential, RECORD_VERSION}; use super::gate::Gate; use super::token::InteractionScope; @@ -119,9 +119,6 @@ struct RequestOptionsResponse { struct PendingRegistration { state: PasskeyRegistration, - /// Set when an existing tenant is adding a passkey rather than a new - /// tenant being created. - join: Option<[u8; 16]>, expires: Instant, } @@ -140,7 +137,7 @@ pub struct AuthEndpoints { gate: Arc, credentials: Arc, entropy: Arc, - fs: Arc, + zfs: Arc, registrations: Mutex>, } @@ -148,14 +145,14 @@ impl AuthEndpoints { pub fn new( gate: Arc, credentials: Arc, - fs: Arc, + zfs: Arc, entropy: Arc, ) -> Self { AuthEndpoints { gate, credentials, entropy, - fs, + zfs, registrations: Mutex::new(HashMap::new()), } } @@ -256,7 +253,6 @@ impl AuthEndpoints { id, PendingRegistration { state, - join: None, expires: now + REGISTRATION_TTL, }, ); @@ -304,23 +300,22 @@ impl AuthEndpoints { } }; - // Joining an existing tenant, or minting one. Minted from the NSM so a - // host cannot predict a tenant id and create its directory first. - let tenant_id = match pending.join { - Some(existing) => existing, - None => match super::credential::mint_tenant_id(&self.entropy) { - Ok(id) => id, - Err(e) => { - tracing::error!(error = format!("{e:#}"), "minting a tenant id"); - return problem(hyper::StatusCode::INTERNAL_SERVER_ERROR, "unavailable"); - } - }, - }; - + // Derived, so a credential id already registered lands on its own + // existing directory and is then refused below, rather than leaving an + // empty one behind. let credential_id = request.credential.raw_id.as_ref().to_vec(); + let tenant_id = tenant_of(&credential_id); + + // The directory first, so the tenant's first request finds one rather + // than paying for it while a user waits — and so the credential's + // anchor below covers both. + if let Err(e) = self.zfs.tenant_dir(tenant_id).await { + tracing::error!(error = format!("{e:#}"), "creating a tenant directory"); + return problem(hyper::StatusCode::INTERNAL_SERVER_ERROR, "unavailable"); + } + let record = StoredCredential { - version: 1, - tenant_id, + version: RECORD_VERSION, passkey, active: true, created_ms: 0, @@ -331,17 +326,9 @@ impl AuthEndpoints { return problem(hyper::StatusCode::CONFLICT, "could not register"); } - // The directory, so the tenant's first request finds one rather than - // paying for it while a user waits. - if let Err(e) = crate::tenant::tenant_root_by_id(&self.fs, tenant_id).await { - tracing::error!(error = format!("{e:#}"), "creating a tenant directory"); - return problem(hyper::StatusCode::INTERNAL_SERVER_ERROR, "unavailable"); - } - tracing::info!( tenant = %hex::encode(tenant_id), credential = %hex::encode(&credential_id[..8.min(credential_id.len())]), - joined = pending.join.is_some(), "registered a passkey" ); json( @@ -532,28 +519,17 @@ mod tests { use crate::auth::challenge::{ChallengeStore, DEFAULT_CAPACITY, DEFAULT_TTL}; use crate::auth::testing::{Relying, ORIGIN, RP_ID}; use crate::auth::SoftwareAuthenticator; + use crate::zfs::Zfs; use http_body_util::BodyExt; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, Fs, MasterSecret}; struct Fixture { endpoints: AuthEndpoints, - fs: Arc, + fs: Arc, credentials: Arc, } async fn fixture() -> Fixture { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("filesystem"); - + let fs = Zfs::scratch().await; let credentials = Arc::new(FilesystemCredentials::new(fs.clone())); let gate = Arc::new(Gate::new( Relying::new().webauthn, @@ -635,7 +611,11 @@ mod tests { assert_eq!(status, 200, "{body}"); let tenant_hex = body["tenant_id"].as_str().expect("a tenant id"); - assert_eq!(tenant_hex.len(), 32, "a 16-byte tenant id in hex"); + assert_eq!( + tenant_hex, + hex::encode(tenant_of(auth.credential_id())), + "the tenant is the one the credential derives" + ); // The credential is stored and points at that tenant. use crate::auth::gate::CredentialStore; @@ -648,10 +628,9 @@ mod tests { assert_eq!(hex::encode(record.tenant_id), tenant_hex); // And the tenant's directory exists, so their first request is cheap. - assert!(crate::tenant::tenants(&f.fs) - .await - .unwrap() - .contains(&tenant_hex.to_string())); + let mut tenant = [0u8; 16]; + hex::decode_to_slice(tenant_hex, &mut tenant).unwrap(); + assert!(f.fs.tenant_path(tenant).is_dir()); } /// Registration is open, and each one stands alone: a second, unrelated diff --git a/runtime/src/bin/passkey-client.rs b/runtime/src/bin/passkey-client.rs index 5138272..acf88ea 100644 --- a/runtime/src/bin/passkey-client.rs +++ b/runtime/src/bin/passkey-client.rs @@ -559,12 +559,26 @@ fn request( } } + // A response counts only once it is complete. The runtime streams a body + // as the guest writes it and holds back only its end until the guest's + // writes are anchored, so a body that stops short — the enclave died, or + // the connection was cut — reports an operation that may be rolled back. let rest = &raw[split + 4..]; - let body = if head - .to_ascii_lowercase() - .contains("transfer-encoding: chunked") + let lower = head.to_ascii_lowercase(); + let cut_off = || { + anyhow::anyhow!( + "{method} {path}: the response was cut off before it ended, so what it reports \ + may not have happened" + ) + }; + let body = if lower.contains("transfer-encoding: chunked") { + dechunk(rest).ok_or_else(cut_off)? + } else if let Some(length) = lower + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse::().ok()) { - dechunk(rest) + rest.get(..length).ok_or_else(cut_off)?.to_vec() } else { rest.to_vec() }; @@ -824,23 +838,22 @@ fn dump_proof( Ok(()) } -fn dechunk(body: &[u8]) -> Vec { +/// The body of a chunked response, or `None` if it never reached its last, +/// zero-length chunk: a body cut off part-way is not a short body. +fn dechunk(body: &[u8]) -> Option> { let mut out = Vec::new(); let mut rest = body; - while let Some(end) = rest.windows(2).position(|w| w == b"\r\n") { + loop { + let end = rest.windows(2).position(|w| w == b"\r\n")?; let header = String::from_utf8_lossy(&rest[..end]); - let Ok(size) = usize::from_str_radix(header.split(';').next().unwrap_or("").trim(), 16) - else { - break; - }; + let size = usize::from_str_radix(header.split(';').next().unwrap_or("").trim(), 16).ok()?; rest = &rest[end + 2..]; - if size == 0 || rest.len() < size { - break; + if size == 0 { + return Some(out); } - out.extend_from_slice(&rest[..size]); - rest = rest.get(size + 2..).unwrap_or(&[]); + out.extend_from_slice(rest.get(..size)?); + rest = rest.get(size + 2..)?; } - out } #[derive(Debug)] @@ -886,6 +899,23 @@ impl rustls::client::danger::ServerCertVerifier for AcceptAny { #[cfg(test)] mod tests { use super::*; + + /// A response is complete or it is not acted on: the runtime holds back + /// only a body's end until its writes are anchored. + #[test] + fn a_chunked_body_must_reach_its_last_chunk() { + assert_eq!( + dechunk(b"5\r\nhello\r\n0\r\n\r\n").as_deref(), + Some(&b"hello"[..]) + ); + assert_eq!( + dechunk(b"5\r\nhello\r\n"), + None, + "cut off before the last chunk" + ); + assert_eq!(dechunk(b"5\r\nhel"), None, "cut off inside a chunk"); + assert_eq!(dechunk(b""), None); + } use nitro_attestation::testing::TestChain; use nitro_attestation::AttestationHashes; diff --git a/runtime/src/boot.rs b/runtime/src/boot.rs index 34c7060..6696182 100644 --- a/runtime/src/boot.rs +++ b/runtime/src/boot.rs @@ -1,16 +1,15 @@ //! Deciding whether this enclave is entitled to the state it is about to load. //! -//! `Store::open` used to end with `None => Store::format(…)`: an enclave -//! pointed at an empty store made a fresh filesystem and served it. It was -//! correctly signed, correctly hash-chained, and completely wrong — every -//! check the design makes passed, because they all attested to the *new* -//! filesystem while the guest saw an empty database where its data should have -//! been. +//! An enclave pointed at an empty store could make a fresh pool and serve it. +//! It would be correctly signed, correctly anchored, and completely wrong — +//! every check the design makes would pass, because they would all attest to +//! the *new* pool while the guest saw an empty database where its data should +//! have been. //! -//! `store/root.rs` already documents the neighbouring risk, rollback: *"a cold -//! mount cannot distinguish 'the tip is N' from 'the tip is N, and the store is -//! hiding N+1'"*. This is the worse one it does not name — a cold mount cannot -//! distinguish "this filesystem is new" from "everything has been hidden". +//! [`crate::zfs`] documents the neighbouring risk, rollback: a cold boot cannot +//! tell "the newest anchor is N" from "the store is hiding N+1". This is the +//! worse one — a cold boot cannot tell "this pool is new" from "everything has +//! been hidden". //! //! ## The receipt //! @@ -39,7 +38,7 @@ //! - **TLS to S3, validated inside the enclave**: the parent proxies the //! bytes but cannot substitute them. It can block, which fails closed. //! -//! What remains out of scope, unchanged from `root.rs`: S3 lying about `HEAD`. +//! What remains out of scope, as for the anchors: S3 lying about `HEAD`. //! That is AWS, whom we already trust for the signature on the receipt itself. //! //! ## Which code may hold the state @@ -66,32 +65,35 @@ //! image environment that selects it is measured. use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime}; +use crate::store::backend::{Backend, ObjectLock, PutBlobInput}; +use crate::store::{MasterSecret, StoreError}; use anyhow::{bail, Context, Result}; use nitro_attestation::{Expectations, Trust, VerifyOptions, AWS_NITRO_ROOT_G1_PEM}; use nitro_nsm::{AttestationRequest, Nsm, PCR_GUEST, PCR_ZERO}; -use s3fs_core::backend::{Backend, ObjectLock, PutBlobInput}; -use s3fs_core::{FsError, MasterSecret}; use crate::keys::{MasterKeySource, SealedKey}; -use crate::mount::{Backends, MountConfig, Mounted}; +use crate::mount::MountConfig; +use crate::store::crypto::KeyMaterial; +use crate::zfs::{Disk, Zfs}; /// `user_data` purpose for the receipt genesis writes. -const PURPOSE_STATE_ORIGIN: &str = "s3fs-state-origin"; +const PURPOSE_STATE_ORIGIN: &str = "state-origin"; /// `user_data` purpose for the record a runtime and guest leave the first time /// they hold this state. -const PURPOSE_PAIR: &str = "s3fs-pair"; +const PURPOSE_PAIR: &str = "state-pair"; /// Schema string inside the `state_root` pre-image. Bump it and every existing /// receipt stops verifying, which is the intended effect of changing what a -/// receipt means. -const STATE_ROOT_SCHEMA: &str = "s3fs/state-origin/v1"; +/// receipt means. `v2` is the per-tenant pool layout: a control pool and a +/// pool per tenant, versus the single pool `v1` named. +const STATE_ROOT_SCHEMA: &str = "zfs/state-origin/v2"; /// Which boot this is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BootMode { - /// No receipt and no filesystem: create both. + /// No receipt and no pool: create both. Genesis, /// This runtime and guest have held this state before. Resume, @@ -179,7 +181,10 @@ fn register(value: &[u8], index: u16) -> Result<[u8; 48]> { #[derive(Debug)] pub struct Booted { pub mode: BootMode, - pub mounted: Mounted, + /// The pool, opened at its newest anchor or just created. + pub zfs: Arc, + /// Derived from the master secret for this filesystem id. + pub keys: Arc, pub state_root: [u8; 32], /// The runtime image and guest this enclave is running. pub pair: Pair, @@ -199,16 +204,15 @@ pub struct Booted { /// The identity a receipt commits to. /// /// Every field is something an attacker could otherwise vary while leaving the -/// rest of the design intact: the filesystem, the store it lives in, the -/// history it descends from, and the key that reads it. +/// rest of the design intact: the filesystem, the bucket its anchors live in, +/// the history it descends from, and the key that reads it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StateIdentity { pub fs_uuid: [u8; 16], - pub data_bucket: String, pub roots_bucket: String, pub bucket_prefix: String, - /// Hash of the seq-0 record: *which* history, not merely which bucket. - pub genesis_root_hash: [u8; 32], + /// Hash of anchor 0: *which* pool history, not merely which bucket. + pub genesis_anchor_hash: [u8; 32], /// SHA-256 of the **sealed** key. Never the key: a receipt is readable by /// anyone who can read the bucket it sits in. pub sealed_key_sha256: [u8; 32], @@ -224,10 +228,9 @@ impl StateIdentity { let value = ciborium::Value::Array(vec![ ciborium::Value::Text(STATE_ROOT_SCHEMA.into()), ciborium::Value::Bytes(self.fs_uuid.to_vec()), - ciborium::Value::Text(self.data_bucket.clone()), ciborium::Value::Text(self.roots_bucket.clone()), ciborium::Value::Text(self.bucket_prefix.clone()), - ciborium::Value::Bytes(self.genesis_root_hash.to_vec()), + ciborium::Value::Bytes(self.genesis_anchor_hash.to_vec()), ciborium::Value::Bytes(self.sealed_key_sha256.to_vec()), ]); let mut encoded = Vec::new(); @@ -271,16 +274,14 @@ fn sealed_key_key(prefix: &str, fs_uuid: &[u8; 16]) -> String { } /// How a receipt is checked. QEMU's NSM does not sign, so the harness needs a -/// concession that production must never have. +/// concession that production must never have — and does not: the concession is +/// compiled into `testing` builds alone. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReceiptTrust { /// Signature and chain to the AWS Nitro root. Required, /// Contents only, because the emulated NSM produces unsigned documents. - /// - /// Set by the emulator image and never by a production one — and because - /// the image's environment is measured, PCR0 itself tells a client which - /// kind it is talking to. + #[cfg(any(test, feature = "testing"))] UnsignedEmulator, } @@ -288,6 +289,7 @@ impl ReceiptTrust { pub fn parse(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "required" | "signed" => Ok(ReceiptTrust::Required), + #[cfg(any(test, feature = "testing"))] "unsigned-emulator" | "unsigned" => Ok(ReceiptTrust::UnsignedEmulator), other => Err(format!( "expected one of required, unsigned-emulator; got {other:?}" @@ -306,6 +308,8 @@ impl ReceiptTrust { /// of unknown origin. pub struct BootConfig { pub trust: ReceiptTrust, + /// Where the pool lives: the parent's disk, or a directory in tests. + pub disk: Disk, } /// Read an origin record, distinguishing "absent" from "could not read" — and @@ -327,7 +331,7 @@ pub struct BootConfig { async fn maybe_get(backend: &Arc, key: &str) -> Result>> { match backend.get_retained_blob(key).await { Ok(out) => Ok(Some(out.body.to_vec())), - Err(FsError::NotFound) => Ok(None), + Err(StoreError::NotFound) => Ok(None), Err(e) => Err(anyhow::Error::msg(e.to_string())).with_context(|| format!("reading {key}")), } } @@ -338,13 +342,13 @@ fn open_receipt( trust: ReceiptTrust, purpose: &str, expected: &Expectations, + now: SystemTime, ) -> Result { let verified = match trust { ReceiptTrust::Required => verify_as_signed(document, AWS_NITRO_ROOT_G1_PEM.as_bytes())?, // The emulator's NSM does not sign, so there is nothing to verify and - // the contents are read as-is. Only an image built for the emulator - // reaches here, and because the image's environment is measured, PCR0 - // says which kind of image a client is talking to. + // the contents are read as-is. Only a `testing` build has this arm. + #[cfg(any(test, feature = "testing"))] ReceiptTrust::UnsignedEmulator => nitro_attestation::Verified { document: nitro_attestation::parse(document).context("parsing the receipt")?, trust: Trust::Unsigned, @@ -355,7 +359,7 @@ fn open_receipt( // and expiring one would make a filesystem unmountable by the passage of // time. verified - .expect(expected, std::time::SystemTime::now()) + .expect(expected, now) .with_context(|| format!("the {purpose} receipt does not say what it must"))?; Ok(verified) } @@ -397,15 +401,15 @@ fn verify_as_signed(document: &[u8], trust_root: &[u8]) -> Result, mount_config: &MountConfig, boot_config: &BootConfig, nsm: &Arc, key_source: &dyn MasterKeySource, + clock: &Arc, ) -> Result { let prefix = &mount_config.bucket_prefix; let fs_uuid = &mount_config.fs_id; - let roots = &backends.roots; // Before anything is read from the store or asked of KMS. let pair = Pair::read(nsm.as_ref())?; @@ -425,19 +429,33 @@ pub async fn boot( .open(&sealed) .await .context("opening this filesystem's master key")?; - let mounted = crate::mount::mount_existing(backends, mount_config, &master).await?; + let keys = derive_keys(&master, mount_config)?; + let zfs = Zfs::open( + &boot_config.disk, + roots.clone(), + keys.clone(), + &master, + &mount_config.bucket_prefix, + mount_config.root_retention, + mount_config.min_root_seq, + clock.clone(), + ) + .await + .context("opening the pool")?; - let identity = identity_of(&mounted, mount_config, &sealed).await?; + let identity = identity_of(&zfs, mount_config, &sealed); let state_root = identity.state_root(); - verify_origin(&receipt, boot_config.trust, &state_root)?; + let now = trusted_now(clock)?; + verify_origin(&receipt, boot_config.trust, &state_root, now)?; let first = record_pair( - backends, + roots, mount_config, boot_config.trust, nsm.as_ref(), &pair, &state_root, + now, ) .await?; @@ -447,7 +465,8 @@ pub async fn boot( } else { BootMode::Resume }, - mounted, + zfs, + keys, state_root, pair, master, @@ -457,12 +476,13 @@ pub async fn boot( // ---- genesis ------------------------------------------------------- (None, None) => { genesis( - backends, + roots, mount_config, - boot_config.trust, + boot_config, nsm, key_source, pair, + clock, ) .await } @@ -484,32 +504,22 @@ pub async fn boot( } } -/// Recompute the identity of a filesystem that is already mounted. -async fn identity_of( - mounted: &Mounted, - config: &MountConfig, - sealed: &SealedKey, -) -> Result { - // The seq-0 record, read past the session floor because it is history - // rather than the live tip. - let genesis_root = mounted - .fs - .store() - .snapshot_root(0) - .await - .map_err(|e| anyhow::anyhow!("reading the genesis root record: {e}"))?; - - Ok(StateIdentity { +/// The identity of the pool that was just opened or created. +fn identity_of(zfs: &Zfs, config: &MountConfig, sealed: &SealedKey) -> StateIdentity { + StateIdentity { fs_uuid: config.fs_id, - data_bucket: config.bucket.clone(), - roots_bucket: config - .roots_bucket - .clone() - .unwrap_or_else(|| config.bucket.clone()), + roots_bucket: config.roots_bucket.clone(), bucket_prefix: config.bucket_prefix.clone(), - genesis_root_hash: *genesis_root.hash().as_bytes(), + genesis_anchor_hash: zfs.genesis_hash(), sealed_key_sha256: sealed.sha256(), - }) + } +} + +fn derive_keys(master: &MasterSecret, config: &MountConfig) -> Result> { + Ok(Arc::new( + KeyMaterial::derive(master, config.fs_id) + .map_err(|e| anyhow::anyhow!("deriving keys: {e}"))?, + )) } /// Check the origin receipt names the state that was just loaded. @@ -518,12 +528,18 @@ async fn identity_of( /// this point KMS has released the key to *this* pair, and that is the decision /// that matters — see the module docs. What KMS cannot say is whether the state /// loaded is the state genesis recorded, and that is the question here. -fn verify_origin(receipt: &[u8], trust: ReceiptTrust, state_root: &[u8; 32]) -> Result<()> { +fn verify_origin( + receipt: &[u8], + trust: ReceiptTrust, + state_root: &[u8; 32], + now: SystemTime, +) -> Result<()> { open_receipt( receipt, trust, "state-origin", &Expectations::default().user_data(receipt_payload(PURPOSE_STATE_ORIGIN, state_root)), + now, )?; Ok(()) } @@ -534,6 +550,7 @@ fn verify_pair_record( trust: ReceiptTrust, pair: &Pair, state_root: &[u8; 32], + now: SystemTime, ) -> Result<()> { open_receipt( document, @@ -543,6 +560,7 @@ fn verify_pair_record( .pcr0(pair.pcr0.to_vec()) .pcr(PCR_GUEST as u32, pair.pcr16.to_vec()) .user_data(receipt_payload(PURPOSE_PAIR, state_root)), + now, )?; Ok(()) } @@ -555,18 +573,20 @@ fn verify_pair_record( /// included — so an object planted there ahead of a pair's first boot would /// otherwise stand in for the real record for good, Object Lock keeping it as /// faithfully as it would the genuine one. +#[allow(clippy::too_many_arguments)] async fn record_pair( - backends: &Backends, + roots: &Arc, config: &MountConfig, trust: ReceiptTrust, nsm: &dyn Nsm, pair: &Pair, state_root: &[u8; 32], + now: SystemTime, ) -> Result { let key = pair.record_key(&config.bucket_prefix, &config.fs_id); - if let Some(existing) = maybe_get(&backends.roots, &key).await? { - verify_pair_record(&existing, trust, pair, state_root).with_context(|| { + if let Some(existing) = maybe_get(roots, &key).await? { + verify_pair_record(&existing, trust, pair, state_root, now).with_context(|| { format!( "the record at {key} does not describe this runtime and guest holding this \ state. Refusing to boot rather than leave it standing as this pair's record." @@ -585,22 +605,21 @@ async fn record_pair( // be a record of nothing, locked in place for the retention period — and on // real hardware, this is where a guest register missing from documents // would first show. - verify_pair_record(&document, trust, pair, state_root) + verify_pair_record(&document, trust, pair, state_root, now) .context("the NSM's own document does not carry this runtime and guest")?; - match backends - .roots - .put_blob_if_not_exists(locked(&key, document, config.root_retention)) + match roots + .put_blob_if_not_exists(locked(&key, document, config.root_retention, now)) .await { Ok(_) => {} // Another first boot of this pair got there first. Its record stands, // provided it says what this one would have. - Err(FsError::AlreadyExists) => { - let theirs = maybe_get(&backends.roots, &key) + Err(StoreError::AlreadyExists) => { + let theirs = maybe_get(roots, &key) .await? .with_context(|| format!("{key} was reported present and then was not"))?; - verify_pair_record(&theirs, trust, pair, state_root).with_context(|| { + verify_pair_record(&theirs, trust, pair, state_root, now).with_context(|| { format!( "another writer's record at {key} does not describe this runtime and \ guest holding this state. Refusing to boot." @@ -633,31 +652,33 @@ async fn record_pair( /// leaves a store the next boot resumes; that boot writes the record and /// reports an upgrade, which for once is not one. async fn genesis( - backends: &Backends, + roots: &Arc, config: &MountConfig, - trust: ReceiptTrust, + boot_config: &BootConfig, nsm: &Arc, key_source: &dyn MasterKeySource, pair: Pair, + clock: &Arc, ) -> Result { tracing::info!( pcr0 = %hex::encode(pair.pcr0), pcr16 = %hex::encode(pair.pcr16), - "no filesystem here: creating one and recording its origin" + "no pool here: creating one and recording its origin" ); + let now = trusted_now(clock)?; let (master, sealed) = key_source.mint().await.context("minting a master key")?; // Written first and *conditionally*: this is the genesis lease. Two cold // boots race here and exactly one wins, so they cannot create divergent - // filesystems under different keys. + // pools under different keys. let key_object = sealed_key_key(&config.bucket_prefix, &config.fs_id); - backends - .roots + roots .put_blob_if_not_exists(locked( &key_object, sealed.as_bytes().to_vec(), config.root_retention, + now, )) .await .map_err(|e| match e { @@ -665,14 +686,25 @@ async fn genesis( // return from a failed conditional PUT (`backend/mod.rs`), and // matching `Conflict` here meant this arm never fired — a genesis // race reported the generic message instead of the specific one. - FsError::AlreadyExists => anyhow::anyhow!( + StoreError::AlreadyExists => anyhow::anyhow!( "another enclave is performing genesis on this filesystem right now" ), other => anyhow::anyhow!("storing the sealed master key: {other}"), })?; - let mounted = crate::mount::create(backends, config, &master).await?; - let identity = identity_of(&mounted, config, &sealed).await?; + let keys = derive_keys(&master, config)?; + let zfs = Zfs::create( + &boot_config.disk, + roots.clone(), + keys.clone(), + &master, + &config.bucket_prefix, + config.root_retention, + clock.clone(), + ) + .await + .context("creating the pool")?; + let identity = identity_of(&zfs, config, &sealed); let state_root = identity.state_root(); let document = nsm @@ -683,22 +715,36 @@ async fn genesis( .context("asking the NSM for a state-origin receipt")?; let receipt_object = receipt_key(&config.bucket_prefix, &config.fs_id); - backends - .roots - .put_blob_if_not_exists(locked(&receipt_object, document, config.root_retention)) + roots + .put_blob_if_not_exists(locked( + &receipt_object, + document, + config.root_retention, + now, + )) .await .map_err(|e| anyhow::anyhow!("writing the state-origin receipt: {e}"))?; - record_pair(backends, config, trust, nsm.as_ref(), &pair, &state_root).await?; + record_pair( + roots, + config, + boot_config.trust, + nsm.as_ref(), + &pair, + &state_root, + now, + ) + .await?; tracing::info!( state_root = %hex::encode(state_root), - "genesis complete; this filesystem now has an attested origin" + "genesis complete; this pool now has an attested origin" ); Ok(Booted { mode: BootMode::Genesis, - mounted, + zfs, + keys, state_root, pair, master, @@ -707,13 +753,21 @@ async fn genesis( /// Everything the boot machine writes goes under the same retention as the /// anchor chain: undeletable is the whole point. -fn locked(key: &str, body: Vec, retention: Duration) -> PutBlobInput { +fn locked(key: &str, body: Vec, retention: Duration, now: SystemTime) -> PutBlobInput { PutBlobInput::new(key, body.into()).with_object_lock(ObjectLock { - mode: s3fs_core::backend::ObjectLockMode::Compliance, - retain_until: std::time::SystemTime::now() + retention, + mode: crate::store::backend::ObjectLockMode::Compliance, + // `now` is the enclave's trusted (PTP) time, not the host clock: a host + // that wound its clock back could otherwise shorten this lock and make + // the record deletable early — the rollback the lock exists to stop. + retain_until: now + retention, }) } +/// The enclave's trusted wall-clock time as a `SystemTime`. +fn trusted_now(clock: &Arc) -> Result { + Ok(SystemTime::UNIX_EPOCH + clock.now()?) +} + #[cfg(test)] mod tests { use super::*; @@ -722,10 +776,9 @@ mod tests { fn identity() -> StateIdentity { StateIdentity { fs_uuid: [1u8; 16], - data_bucket: "data".into(), roots_bucket: "roots".into(), bucket_prefix: String::new(), - genesis_root_hash: [2u8; 32], + genesis_anchor_hash: [2u8; 32], sealed_key_sha256: [3u8; 32], } } @@ -742,10 +795,12 @@ mod tests { #[test] fn the_boot_records_are_locked_for_the_configured_retention() { let day = Duration::from_secs(86_400); - let lock = locked("k", vec![], day).object_lock.expect("locked"); + let lock = locked("k", vec![], day, SystemTime::now()) + .object_lock + .expect("locked"); assert!(matches!( lock.mode, - s3fs_core::backend::ObjectLockMode::Compliance + crate::store::backend::ObjectLockMode::Compliance )); let left = lock .retain_until @@ -769,10 +824,6 @@ mod tests { a.fs_uuid = [9u8; 16]; assert_ne!(a.state_root(), base, "filesystem id"); - let mut b = identity(); - b.data_bucket = "elsewhere".into(); - assert_ne!(b.state_root(), base, "data bucket"); - let mut c = identity(); c.roots_bucket = "elsewhere".into(); assert_ne!(c.state_root(), base, "roots bucket"); @@ -782,7 +833,7 @@ mod tests { assert_ne!(d.state_root(), base, "prefix"); let mut e = identity(); - e.genesis_root_hash = [9u8; 32]; + e.genesis_anchor_hash = [9u8; 32]; assert_ne!(e.state_root(), base, "history"); let mut f = identity(); @@ -796,7 +847,7 @@ mod tests { #[test] fn swapping_fields_is_not_the_same_state() { let mut swapped = identity(); - std::mem::swap(&mut swapped.data_bucket, &mut swapped.roots_bucket); + std::mem::swap(&mut swapped.roots_bucket, &mut swapped.bucket_prefix); assert_ne!(swapped.state_root(), identity().state_root()); } @@ -819,8 +870,8 @@ mod tests { let mut expected = vec![0xa2]; expected.push(0x67); expected.extend_from_slice(b"purpose"); - expected.push(0x71); - expected.extend_from_slice(b"s3fs-state-origin"); + expected.push(0x6c); + expected.extend_from_slice(b"state-origin"); expected.push(0x6a); expected.extend_from_slice(b"state_root"); expected.extend_from_slice(&[0x58, 0x20]); diff --git a/runtime/src/clock.rs b/runtime/src/clock.rs index a5c7269..ca44321 100644 --- a/runtime/src/clock.rs +++ b/runtime/src/clock.rs @@ -148,22 +148,22 @@ impl ClockSource { /// `Auto` falls back at `warn` rather than silently: a misconfigured enclave /// running on host time must not look identical in the logs to a correctly /// configured one. -pub fn open_clock(source: ClockSource, device: &Path) -> Result> { +pub fn open_clock(source: ClockSource, device: &Path) -> Result> { match source { ClockSource::Host => { tracing::warn!(clock = "host", "using the host clock; time is untrusted"); - Ok(Box::new(HostClock)) + Ok(std::sync::Arc::new(HostClock)) } ClockSource::Ptp => { let clock = PtpClock::open(device) .context("clock source 'ptp' was required but the device could not be opened")?; tracing::info!(clock = %clock.describe(), "clock source"); - Ok(Box::new(clock)) + Ok(std::sync::Arc::new(clock)) } ClockSource::Auto => match PtpClock::open(device) { Ok(clock) => { tracing::info!(clock = %clock.describe(), "clock source"); - Ok(Box::new(clock)) + Ok(std::sync::Arc::new(clock)) } Err(e) => { tracing::warn!( @@ -171,7 +171,7 @@ pub fn open_clock(source: ClockSource, device: &Path) -> Result Result, + clock: std::sync::Arc, last_good: Mutex, } impl WallClockAdapter { - pub fn new(clock: Box) -> Result { + pub fn new(clock: std::sync::Arc) -> Result { let initial = clock.now()?; Ok(WallClockAdapter { clock, @@ -286,7 +286,7 @@ mod tests { #[test] fn the_adapter_passes_readings_and_resolution_through() { let fake = FakeClock::new(1_700_000_000_000_000_000); - let adapter = WallClockAdapter::new(Box::new(fake)).unwrap(); + let adapter = WallClockAdapter::new(std::sync::Arc::new(fake)).unwrap(); assert_eq!( adapter.now(), Duration::from_nanos(1_700_000_000_000_000_000) @@ -299,7 +299,7 @@ mod tests { #[test] fn a_failed_read_serves_the_last_good_value() { let fake = FakeClock::new(1_000); - let adapter = WallClockAdapter::new(Box::new(fake.clone())).unwrap(); + let adapter = WallClockAdapter::new(std::sync::Arc::new(fake.clone())).unwrap(); // Advance, read, then break the device. fake.nanos.store(5_000, Ordering::SeqCst); @@ -323,7 +323,7 @@ mod tests { fn a_clock_that_cannot_be_read_at_all_fails_to_construct() { let fake = FakeClock::new(0); fake.fail.store(true, Ordering::SeqCst); - assert!(WallClockAdapter::new(Box::new(fake.clone())).is_err()); + assert!(WallClockAdapter::new(std::sync::Arc::new(fake.clone())).is_err()); } #[test] diff --git a/runtime/src/env.rs b/runtime/src/env.rs index debc0be..d0f2467 100644 --- a/runtime/src/env.rs +++ b/runtime/src/env.rs @@ -1,144 +1,10 @@ -//! What the guest's environment contains, and what it must never contain. +//! What the guest's environment contains: the settings its own file carries, and nothing else. //! -//! This process holds the filesystem master key, and under the attestation -//! milestone it will hold KMS-released credentials. The guest is the code the -//! enclave exists to contain. So the environment it sees is the host's, minus -//! two categories: -//! -//! - **Credentials**, obviously. -//! - **The runtime's own configuration**, which tells the guest where its data -//! lives and is of no use to it — an enclave guest has no network of its own. -//! -//! Both fall under one rule that is easy to state and hard to get wrong: -//! nothing whose name begins with `AWS_` or `S3FS_` is inherited. That covers -//! `S3FS_MASTER_KEY` and the whole AWS credential set without anyone having to -//! enumerate them, and it keeps working when a new one is added. -//! -//! An operator who genuinely needs one of those names can still set it -//! explicitly. Explicit is a decision; inheritance is an accident waiting to -//! happen. - -use std::collections::BTreeMap; - -/// Name prefixes that are never inherited from the host. -pub const DENIED_PREFIXES: &[&str] = &["AWS_", "S3FS_"]; - -/// Names that are credentials wherever they appear. Setting one explicitly is -/// permitted — the operator asked for it — but it is worth saying out loud. -const CREDENTIAL_NAMES: &[&str] = &[ - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "AWS_SECURITY_TOKEN", - "S3FS_MASTER_KEY", -]; - -fn is_denied(name: &str) -> bool { - DENIED_PREFIXES.iter().any(|p| name.starts_with(p)) -} - -fn is_credential(name: &str) -> bool { - CREDENTIAL_NAMES.contains(&name) -} - -/// How the guest's environment is assembled. -#[derive(Debug, Clone)] -pub struct GuestEnvPolicy { - /// Inherit the host environment (minus [`DENIED_PREFIXES`]). - pub inherit: bool, - /// Explicit entries, as `NAME` (inherit that one by name) or `NAME=VALUE`. - /// Applied after inheritance, so they override it. - pub explicit: Vec, -} - -impl Default for GuestEnvPolicy { - fn default() -> Self { - GuestEnvPolicy { - inherit: true, - explicit: Vec::new(), - } - } -} - -impl GuestEnvPolicy { - /// Inherit nothing; the guest sees only what is named explicitly. - pub fn explicit_only(explicit: Vec) -> Self { - GuestEnvPolicy { - inherit: false, - explicit, - } - } - - /// Build the environment, reading the host's via `std::env::vars`. - pub fn build(&self) -> anyhow::Result> { - self.build_from(std::env::vars().collect::>().as_slice()) - } - - /// Build against an explicit host environment. Separated so the policy is - /// testable without mutating the process, which no test should have to do. - pub fn build_from(&self, host: &[(String, String)]) -> anyhow::Result> { - // Sorted so the guest's environment is deterministic regardless of the - // host's iteration order. - let mut out: BTreeMap = BTreeMap::new(); - let lookup = |name: &str| -> Option<&str> { - host.iter() - .find(|(k, _)| k == name) - .map(|(_, v)| v.as_str()) - }; - - if self.inherit { - let mut denied: Vec<&str> = Vec::new(); - for (name, value) in host { - if is_denied(name) { - denied.push(name); - continue; - } - out.insert(name.clone(), value.clone()); - } - if !denied.is_empty() { - // Say what was withheld. A guest missing a variable should be - // diagnosable from the log rather than mysterious. - denied.sort_unstable(); - tracing::info!( - withheld = denied.join(", "), - "guest environment: withheld runtime configuration and credentials" - ); - } - } - - for spec in &self.explicit { - let (name, value) = match spec.split_once('=') { - Some((name, value)) => (name, Some(value.to_string())), - None => (spec.as_str(), None), - }; - if name.is_empty() { - anyhow::bail!("guest-env: empty variable name in {spec:?}"); - } - let value = match value { - Some(v) => v, - None => match lookup(name) { - Some(v) => v.to_string(), - // A bare name that is unset on the host is skipped rather - // than passed as empty, so the guest can tell "unset" from - // "set to nothing". - None => { - tracing::debug!(name, "guest-env: not set on host, skipping"); - continue; - } - }, - }; - if is_credential(name) { - tracing::warn!( - name, - "guest-env: exposing a credential to guest code, because it was named explicitly" - ); - } - out.insert(name.to_string(), value); - } - - Ok(out.into_iter().collect()) - } -} +//! The runtime's environment holds its configuration and, in the emulator, its object store's +//! credentials. None of that is the guest's business, and none of it reaches the guest: a +//! guest's environment is exactly the `NAME=VALUE` lines in its file's [`GUEST_SETTINGS_SECTION`], +//! which the runtime measures into PCR16 with the guest's code. There is no inheritance to get +//! wrong, and so no denylist to keep up to date. /// The custom section a guest file carries its settings in. pub const GUEST_SETTINGS_SECTION: &str = "enclave-guest-env"; @@ -220,26 +86,6 @@ pub fn from_guest(component: &[u8]) -> anyhow::Result> { Ok(settings) } -/// `env`, with the settings `component` carries laid over it: the guest file is what was -/// measured, so it has the last word. -pub fn with_guest_settings( - env: Vec<(String, String)>, - component: &[u8], -) -> anyhow::Result> { - let settings = from_guest(component)?; - if settings.is_empty() { - return Ok(env); - } - // Names only: a value can be a credential. - tracing::info!( - names = %settings.iter().map(|(n, _)| n.as_str()).collect::>().join(", "), - "guest environment: the settings the guest file carries" - ); - let mut out: BTreeMap = env.into_iter().collect(); - out.extend(settings); - Ok(out.into_iter().collect()) -} - /// `component` with `settings` appended as its [`GUEST_SETTINGS_SECTION`] — what /// `deploy/qemu-nitro/guest-env.py` writes, for tests that need a configured guest. #[cfg(any(test, feature = "testing"))] @@ -275,7 +121,7 @@ mod tests { const EMPTY_COMPONENT: &[u8] = b"\0asm\x0d\x00\x01\x00"; #[test] - fn a_guest_file_carries_its_settings_and_they_override_the_image() { + fn a_guest_file_carries_its_settings() { let guest = with_settings_section( EMPTY_COMPONENT, &[("ASP_URL", "https://asp.example"), ("LANG", "C")], @@ -287,15 +133,6 @@ mod tests { ("LANG".to_string(), "C".to_string()) ] ); - let env = with_guest_settings(vec![("LANG".into(), "en_GB.UTF-8".into())], &guest).unwrap(); - assert_eq!( - env, - vec![ - ("ASP_URL".to_string(), "https://asp.example".to_string()), - ("LANG".to_string(), "C".to_string()) - ], - "the measured guest file has the last word" - ); } #[test] @@ -331,143 +168,4 @@ mod tests { ); assert!(from_guest(b"not wasm").is_err()); } - - fn host() -> Vec<(String, String)> { - [ - ("LANG", "en_GB.UTF-8"), - ("RUST_LOG", "info"), - ("MY_APP_ENDPOINT", "https://example.invalid"), - ("AWS_SECRET_ACCESS_KEY", "super-secret"), - ("AWS_ACCESS_KEY_ID", "AKIA..."), - ("AWS_SESSION_TOKEN", "token"), - ("AWS_REGION", "eu-west-2"), - ("S3FS_MASTER_KEY", "0011..."), - ("S3FS_BUCKET", "prod-data"), - ] - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - fn names(env: &[(String, String)]) -> Vec<&str> { - env.iter().map(|(k, _)| k.as_str()).collect() - } - - #[test] - fn inheritance_passes_ordinary_variables() { - let env = GuestEnvPolicy::default().build_from(&host()).unwrap(); - assert_eq!(names(&env), vec!["LANG", "MY_APP_ENDPOINT", "RUST_LOG"]); - } - - /// The property the whole module exists for. - #[test] - fn no_credential_or_runtime_variable_is_ever_inherited() { - let env = GuestEnvPolicy::default().build_from(&host()).unwrap(); - for (name, _) in &env { - assert!( - !name.starts_with("AWS_") && !name.starts_with("S3FS_"), - "{name} reached the guest by inheritance" - ); - } - assert!(!env.iter().any(|(_, v)| v == "super-secret")); - } - - /// Withholding is by prefix, not by an enumerated list, so a variable - /// nobody thought of is still withheld. - #[test] - fn an_unanticipated_denied_variable_is_still_withheld() { - let mut h = host(); - h.push(("AWS_SOMETHING_INVENTED_LATER".into(), "x".into())); - h.push(("S3FS_FUTURE_OPTION".into(), "y".into())); - let env = GuestEnvPolicy::default().build_from(&h).unwrap(); - assert!(!names(&env).iter().any(|n| n.contains("INVENTED"))); - assert!(!names(&env).iter().any(|n| n.contains("FUTURE"))); - } - - #[test] - fn an_explicit_value_overrides_inheritance() { - let policy = GuestEnvPolicy { - inherit: true, - explicit: vec!["RUST_LOG=trace".into()], - }; - let env = policy.build_from(&host()).unwrap(); - assert_eq!( - env.iter().find(|(k, _)| k == "RUST_LOG").unwrap().1, - "trace" - ); - } - - /// A deployment that genuinely needs a denied name can still have it, but - /// only by saying so. - #[test] - fn an_explicitly_named_denied_variable_is_allowed_through() { - let policy = GuestEnvPolicy { - inherit: true, - explicit: vec!["AWS_REGION".into()], - }; - let env = policy.build_from(&host()).unwrap(); - assert_eq!( - env.iter().find(|(k, _)| k == "AWS_REGION").unwrap().1, - "eu-west-2" - ); - // ...and nothing else under the prefix came with it. - assert!(!names(&env).contains(&"AWS_SECRET_ACCESS_KEY")); - } - - #[test] - fn explicit_only_inherits_nothing() { - let policy = GuestEnvPolicy::explicit_only(vec!["LANG".into(), "EXTRA=1".into()]); - let env = policy.build_from(&host()).unwrap(); - assert_eq!(names(&env), vec!["EXTRA", "LANG"]); - } - - #[test] - fn explicit_only_with_nothing_named_yields_an_empty_environment() { - assert!(GuestEnvPolicy::explicit_only(vec![]) - .build_from(&host()) - .unwrap() - .is_empty()); - } - - #[test] - fn a_bare_name_unset_on_the_host_is_skipped_not_blanked() { - let policy = GuestEnvPolicy::explicit_only(vec!["NOT_SET_ANYWHERE".into()]); - assert!(policy.build_from(&host()).unwrap().is_empty()); - } - - #[test] - fn an_explicit_empty_value_is_kept() { - let policy = GuestEnvPolicy::explicit_only(vec!["EMPTY=".into()]); - let env = policy.build_from(&host()).unwrap(); - assert_eq!(env, vec![("EMPTY".to_string(), String::new())]); - } - - #[test] - fn a_value_may_contain_equals_signs() { - let policy = GuestEnvPolicy::explicit_only(vec!["OPTS=a=1,b=2".into()]); - let env = policy.build_from(&host()).unwrap(); - assert_eq!(env[0].1, "a=1,b=2"); - } - - #[test] - fn an_empty_variable_name_is_rejected() { - assert!(GuestEnvPolicy::explicit_only(vec!["=value".into()]) - .build_from(&host()) - .is_err()); - assert!(GuestEnvPolicy::explicit_only(vec![String::new()]) - .build_from(&host()) - .is_err()); - } - - /// Two runs with the same inputs must produce the same environment, even - /// though the host's iteration order is not defined. - #[test] - fn output_is_sorted_and_deterministic() { - let mut shuffled = host(); - shuffled.reverse(); - let a = GuestEnvPolicy::default().build_from(&host()).unwrap(); - let b = GuestEnvPolicy::default().build_from(&shuffled).unwrap(); - assert_eq!(a, b); - assert!(a.windows(2).all(|w| w[0].0 < w[1].0)); - } } diff --git a/runtime/src/flag.rs b/runtime/src/flag.rs index 7d502ec..a896e78 100644 --- a/runtime/src/flag.rs +++ b/runtime/src/flag.rs @@ -3,13 +3,14 @@ //! clap's default `bool` handling insists on exactly `true` or `false` when a //! value arrives through `env`. That is fine on a command line, where the flag //! is usually written bare, and wrong for a deployment configured by -//! environment: `ENV S3FS_FORCE_PATH_STYLE=1` is the obvious thing to put in a -//! Dockerfile, and it fails at startup with a message about possible values. +//! environment: `ENCLAVE_FORCE_PATH_STYLE=1` is the obvious thing to put in an +//! image's environment, and it fails at startup with a message about possible +//! values. //! //! Inside an enclave that failure is expensive to diagnose — there is no shell //! to go and check, and the only symptom is an enclave that will not start. -/// Parse a boolean the way a person writing a Dockerfile would expect. +/// Parse a boolean the way a person writing an environment file would expect. /// /// Accepts `1`/`0`, `true`/`false`, `yes`/`no`, `on`/`off`, in any case. An /// empty value is `false`, matching the shell convention that an unset-looking diff --git a/runtime/src/guest.rs b/runtime/src/guest.rs index 7467db0..0c0e3eb 100644 --- a/runtime/src/guest.rs +++ b/runtime/src/guest.rs @@ -34,10 +34,10 @@ use std::path::PathBuf; use std::sync::Arc; +use crate::store::backend::Backend; +use crate::store::StoreError; use anyhow::{bail, Context, Result}; use nitro_nsm::{Nsm, PCR_GUEST, PCR_ZERO}; -use s3fs_core::backend::Backend; -use s3fs_core::FsError; /// Larger than any guest this runtime has a use for. /// @@ -82,7 +82,7 @@ async fn fetch_bounded( .get_blob(key, Some(0..limit + 1)) .await .map_err(|e| match e { - FsError::NotFound => anyhow::anyhow!( + StoreError::NotFound => anyhow::anyhow!( "no guest component at {key} in the roots bucket; upload the approved \ guest there before starting the enclave" ), @@ -183,10 +183,10 @@ pub fn measure_guest(nsm: &dyn Nsm, component: &[u8]) -> Result<[u8; 48]> { #[cfg(test)] mod tests { use super::*; + use crate::store::backend::memory::MemoryBackend; + use crate::store::backend::PutBlobInput; use nitro_nsm::fake::FakeNsm; use nitro_nsm::Pcr; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::backend::PutBlobInput; use std::sync::atomic::{AtomicBool, Ordering}; const GUEST: &[u8] = b"\0asm pretend component"; diff --git a/runtime/src/guest_io/cloudwatch.rs b/runtime/src/guest_io/cloudwatch.rs index 58cc2f0..4ffd6bb 100644 --- a/runtime/src/guest_io/cloudwatch.rs +++ b/runtime/src/guest_io/cloudwatch.rs @@ -844,7 +844,7 @@ impl LogDestination for CloudWatchDestination { /// Decide whether an SDK error is worth retrying. /// /// Driven by the service's own error code, never by matching on a `Debug` -/// string — the same discipline as `s3fs_core::backend::aws::map_sdk_error`. +/// string — the same discipline as `crate::store::backend::aws::map_sdk_error`. fn classify(error: aws_sdk_cloudwatchlogs::error::SdkError) -> PutError where E: aws_sdk_cloudwatchlogs::error::ProvideErrorMetadata + std::fmt::Debug, diff --git a/runtime/src/keys/kms.rs b/runtime/src/keys/kms.rs index 6876139..8a4d2e9 100644 --- a/runtime/src/keys/kms.rs +++ b/runtime/src/keys/kms.rs @@ -50,19 +50,20 @@ use std::collections::BTreeMap; use std::sync::Arc; +use crate::store::MasterSecret; use anyhow::{bail, Context, Result}; use async_trait::async_trait; use aws_sdk_kms::primitives::Blob; use aws_sdk_kms::types::{KeyEncryptionMechanism, RecipientInfo}; use base64::Engine as _; use nitro_nsm::{AttestationRequest, Nsm}; -use s3fs_core::MasterSecret; use super::recipient::RecipientKey; use super::{policy, MasterKeySource, SealedKey}; use crate::boot::Pair; -/// Bytes of key material to ask KMS for. The block store's master secret. +/// Bytes of key material to ask KMS for: the master secret everything +/// derives from — the pool's dm-crypt key and the anchor-signing key among it. const MASTER_SECRET_LEN: i32 = 32; /// Version tag on the pointer record, so a future format is a clear error @@ -489,7 +490,7 @@ mod tests { /// a production build treating a plaintext key as metadata. #[test] fn a_static_key_blob_is_not_a_pointer() { - let mut blob = b"s3fs-UNSEALED-development-key-v1\n".to_vec(); + let mut blob = b"enclave-UNSEALED-development-key-v1\n".to_vec(); blob.extend_from_slice(&[7u8; 32]); assert!(KeyPointer::decode(&SealedKey::from_bytes(blob)).is_err()); } diff --git a/runtime/src/keys/mod.rs b/runtime/src/keys/mod.rs index d5e4a0f..9633558 100644 --- a/runtime/src/keys/mod.rs +++ b/runtime/src/keys/mod.rs @@ -9,7 +9,7 @@ //! //! ## Why the secret belongs to the stored state //! -//! It used to arrive as `S3FS_MASTER_KEY`, from the parent instance. A parent +//! It used to arrive as `ENCLAVE_MASTER_KEY`, from the parent instance. A parent //! that supplies the key *has* the key, and can decrypt the whole filesystem — //! so the party an enclave exists to exclude held the only thing that mattered. //! No amount of boot verification fixes that: an enclave could prove perfectly @@ -36,17 +36,19 @@ mod ber; mod kms; mod policy; mod recipient; +#[cfg(any(test, feature = "testing"))] mod static_key; pub use kms::{KeyPointer, KmsAttestedKey, KmsKeyConfig}; +#[cfg(any(test, feature = "testing"))] pub use static_key::StaticKey; use std::sync::Arc; +use crate::store::MasterSecret; use anyhow::{bail, Context, Result}; use async_trait::async_trait; use nitro_nsm::Nsm; -use s3fs_core::MasterSecret; use aws_credential_types::provider::SharedCredentialsProvider; use aws_credential_types::Credentials; @@ -130,9 +132,8 @@ impl MasterKeySource for Box { /// Which implementation of [`MasterKeySource`] a deployment runs. /// -/// No default anywhere. An enclave's key handling is the one setting that -/// should never be inherited by omission, and because the value comes from the -/// image environment, PCR0 records which of the two an enclave was built with. +/// A production build has only [`KmsAttestedKey`]: the static key is compiled into `testing` +/// builds alone, so no setting can make a production binary take its key from configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MasterKeySourceKind { /// [`KmsAttestedKey`] — the production path. @@ -140,6 +141,7 @@ pub enum MasterKeySourceKind { /// [`StaticKey`] — development and the QEMU harness, which cannot use KMS /// because an emulated NSM does not sign its attestations and KMS will not /// accept an unsigned one. + #[cfg(any(test, feature = "testing"))] Static, } @@ -147,6 +149,7 @@ impl MasterKeySourceKind { pub fn parse(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "kms" | "kms-attested" => Ok(MasterKeySourceKind::Kms), + #[cfg(any(test, feature = "testing"))] "static" | "unsealed" => Ok(MasterKeySourceKind::Static), other => Err(format!("expected one of kms, static; got {other:?}")), } @@ -180,7 +183,7 @@ pub struct MasterKeyConfig { /// what they appear to. /// /// The refusals matter more than the construction. A production image that -/// still carried `S3FS_MASTER_KEY` would work perfectly — silently using a key +/// still carried `ENCLAVE_MASTER_KEY` would work perfectly — silently using a key /// the parent instance holds, giving up the whole point of KMS release — so /// supplying both is an error rather than a precedence rule. There is no /// ordering of "both were given" that is safe to guess at. @@ -189,6 +192,7 @@ pub fn open_key_source( nsm: Arc, ) -> Result> { match config.kind { + #[cfg(any(test, feature = "testing"))] MasterKeySourceKind::Static => { if config.kms_key_id.is_some() || config.parameter.is_some() { bail!( @@ -200,7 +204,7 @@ pub fn open_key_source( let hex = config .master_key .as_deref() - .context("--master-key-source=static needs --master-key (S3FS_MASTER_KEY)")?; + .context("--master-key-source=static needs --master-key (ENCLAVE_MASTER_KEY)")?; Ok(Box::new(StaticKey::from_hex(hex)?)) } MasterKeySourceKind::Kms => { @@ -214,9 +218,9 @@ pub fn open_key_source( let key_id = config .kms_key_id .as_deref() - .context("--master-key-source=kms needs --kms-key-id (S3FS_KMS_KEY_ID)")?; + .context("KMS release needs --kms-key-id (ENCLAVE_KMS_KEY_ID)")?; let parameter = config.parameter.as_deref().context( - "--master-key-source=kms needs --master-key-parameter (S3FS_MASTER_KEY_PARAMETER)", + "KMS release needs --master-key-parameter (ENCLAVE_MASTER_KEY_PARAMETER)", )?; Ok(Box::new(KmsAttestedKey::new( @@ -256,7 +260,7 @@ fn credentials(config: &MasterKeyConfig) -> SharedCredentialsProvider { sak, config.session_token.clone(), None, - "s3fs-static", + "enclave-static", )), _ => instance_role(), } @@ -314,6 +318,6 @@ mod tests { #[test] fn without_keys_kms_and_ssm_sign_as_the_instance_role() { assert!(format!("{:?}", credentials(&config(None))).contains("ImdsCredentialsProvider")); - assert!(format!("{:?}", credentials(&config(Some("minio")))).contains("s3fs-static")); + assert!(format!("{:?}", credentials(&config(Some("minio")))).contains("enclave-static")); } } diff --git a/runtime/src/keys/static_key.rs b/runtime/src/keys/static_key.rs index 7a8ebaa..123b736 100644 --- a/runtime/src/keys/static_key.rs +++ b/runtime/src/keys/static_key.rs @@ -1,14 +1,14 @@ //! The development key source: a secret from configuration, stored in the clear. +use crate::store::MasterSecret; use anyhow::{bail, Context, Result}; use async_trait::async_trait; -use s3fs_core::MasterSecret; use super::{MasterKeySource, SealedKey}; /// Marks a blob that is not sealed at all, so that nothing can mistake one for /// protection, and so a real source can refuse to open one. -const UNSEALED_MAGIC: &[u8] = b"s3fs-UNSEALED-development-key-v1\n"; +const UNSEALED_MAGIC: &[u8] = b"enclave-UNSEALED-development-key-v1\n"; /// A secret supplied by configuration, stored in the clear. /// @@ -89,7 +89,7 @@ mod tests { } /// Resume recovers from the *blob*, not from configuration. A deployment - /// whose `S3FS_MASTER_KEY` has drifted must still open the filesystem it + /// whose `ENCLAVE_MASTER_KEY` has drifted must still open the filesystem it /// created, or the drift would be discovered as unreadable data. #[tokio::test] async fn opening_uses_the_blob_rather_than_the_configured_secret() { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index b398e81..58abc60 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -5,28 +5,18 @@ //! client checks that the attestation document binds the certificate from its //! own handshake, is the test this whole design exists to pass. //! -//! This was `s3fs-host` until the split stopped describing anything. It began -//! as `wasi:filesystem` over a block store, reusable by any host; it ended up -//! also holding the NSM, the vsock tap device, TLS termination and the -//! attestation endpoints, none of which mean anything outside an enclave. The -//! layer that is genuinely independent is [`s3fs_core`], which still has no -//! wasmtime dependency at all. -//! //! ```text -//! mount config ──▶ Arc two backends, verified root +//! mount config ──▶ roots bucket anchors, boot records, guest +//! boot receipt + key ──▶ Arc genesis, resume or refusal +//! zfs parent's disk ──▶ /tenants, /runtime anchored after each write //! guest object or file ──▶ PCR16, locked before any key is asked for //! net vsock + tap ──▶ a network gvforwarder against gvproxy -//! env GuestEnvPolicy ──▶ Vec<(K, V)> inherit minus a denylist -//! linker wasmtime-wasi ──▶ Linker everything but filesystem +//! env guest file ──▶ Vec<(K, V)> its settings section, measured +//! linker wasmtime-wasi ──▶ Linker all of WASI, plus our own //! run component ──▶ GuestOutcome once, with its exit code //! serve component ──▶ TLS + HTTP until stopped //! ``` //! -//! [`wasi`] holds the `wasi:filesystem@0.2.x` implementation. It is written -//! against `s3fs_core::Fs` and knows nothing about AWS or enclaves, so a host -//! with its own state type can still take [`add_filesystem_to_linker`] alone — -//! it is simply no longer packaged as though anyone does. -//! //! The [`keys`] seam exists so that replacing a configured secret with a KMS //! release gated on an NSM attestation document is a new implementation of one //! trait, not a change to any of the above. @@ -47,10 +37,11 @@ pub mod random; pub mod run; pub mod serve; pub mod state; +pub mod store; pub mod stream; pub mod tasks; pub mod tenant; -pub mod wasi; +pub mod zfs; #[cfg(any(test, feature = "testing"))] pub mod testing; @@ -63,7 +54,6 @@ pub use auth::{ }; pub use boot::{boot, BootConfig, BootMode, Booted, Pair, ReceiptTrust}; pub use clock::{open_clock, ClockSource, HostClock, PtpClock, TrustedClock, DEFAULT_PTP_DEVICE}; -pub use env::GuestEnvPolicy; pub use flag::parse_bool_flag; pub use guest::{fetch_guest, measure_guest, GuestSource, MAX_GUEST_BYTES}; pub use guest_io::cloudwatch::{ @@ -75,12 +65,14 @@ pub use guest_io::{ FanOutSink, GuestLogCollector, GuestLogRecord, GuestLogSink, GuestLogs, GuestStream, TracingLogSink, GUEST_LOG_TARGET, }; +#[cfg(any(test, feature = "testing"))] +pub use keys::StaticKey; pub use keys::{ open_key_source, KeyPointer, KmsAttestedKey, KmsKeyConfig, MasterKeyConfig, MasterKeySource, - MasterKeySourceKind, SealedKey, StaticKey, + MasterKeySourceKind, SealedKey, }; pub use linker::build_linker; -pub use mount::{connect, create, mount_existing, parse_fs_id, Backends, MountConfig, Mounted}; +pub use mount::{connect, parse_fs_id, MountConfig, DEFAULT_ROOT_RETENTION}; pub use net::{bring_up, Network, NetworkConfig, NetworkMode, DEFAULT_GVFORWARDER}; pub use nitro_nsm::{Nsm, NsmDevice, DEFAULT_NSM_DEVICE}; pub use notify::{ @@ -90,10 +82,10 @@ pub use notify::{ pub use random::{open_entropy, GuestRandom, HostEntropy, RandomSource}; pub use run::{read_component, GuestEnvironment, GuestOutcome, EXIT_RUNTIME_FAILURE}; pub use serve::{ - apply_tenant, serve_component, AcmeConfig, CertificateSlot, GuestEgress, GuestInstance, - PoolLimits, SealedAcmeCache, ServeConfig, ServeHandle, Server, Tenancy, TenantPool, - TlsIdentity, TlsMode, X_ENCLAVE_TENANT, + apply_tenant, serve_component, AcmeConfig, CertificateSlot, GuestEgress, PoolLimits, + SealedAcmeCache, ServeConfig, ServeHandle, Server, Tenancy, TenantPool, TlsIdentity, TlsMode, + X_ENCLAVE_TENANT, }; pub use state::State; -pub use tenant::{tenant_root_by_id, tenants, Arrival, TenantRoot}; -pub use wasi::{add_filesystem_to_linker, S3FsCtxView, S3WasiView}; +pub use tenant::Arrival; +pub use zfs::{Disk, Zfs}; diff --git a/runtime/src/linker.rs b/runtime/src/linker.rs index 18c3912..562b272 100644 --- a/runtime/src/linker.rs +++ b/runtime/src/linker.rs @@ -1,101 +1,21 @@ -//! WASI linker wiring: everything `wasmtime-wasi` provides *except* -//! `wasi:filesystem`, which [`crate::wasi`] takes over. +//! The linker a guest sees: all of `wasmtime-wasi`, plus the runtime's own +//! interfaces. //! -//! ## This is a maintenance hazard, deliberately isolated -//! -//! [`add_wasi_minus_filesystem`] is a copy of -//! `wasmtime_wasi::p2::add_to_linker_with_options_async` with the two -//! `filesystem::*` lines removed. Upstream offers no "everything but one -//! interface" entry point, so there is no way to express this except by -//! restating the list. -//! -//! The failure mode is quiet: a `wasmtime-wasi` bump that adds an interface -//! leaves it missing here, and the only symptom is a guest that fails to -//! instantiate with an unresolved import — at run time, in whatever -//! environment happens to try it first. `wasmtime::component::Linker` exposes -//! no way to enumerate what has been registered, so no unit test can prove -//! this list is complete. -//! -//! What mitigates it is that `wasmtime-wasi` is pinned to an exact version, so -//! the list cannot change under us without someone editing a manifest. If you -//! bump it, diff this function against the upstream one. -//! -//! There used to be a second mitigation — CI ran a `wasi:cli/command` guest -//! that imported the full command world, so a missing interface failed that -//! job. It went with the command runner. The runtime serves `wasi:http/proxy` -//! and nothing else now, so the full `wasi:cli` surface is registered because -//! a proxy guest may still reach for parts of it, not because anything -//! requires all of it. +//! `wasi:filesystem` is `wasmtime-wasi`'s, over the one directory a guest is +//! preopened — its tenant's dataset on the pool, see [`crate::zfs`]. There used +//! to be a filesystem of our own here, and with it a hand-copied list of every +//! other WASI interface that had to be diffed against upstream on each bump. +//! Both are gone: the upstream entry point registers the whole set. -use wasmtime::component::{HasData, Linker, ResourceTable}; +use wasmtime::component::Linker; use wasmtime::Result; -use wasmtime_wasi::cli::{WasiCli, WasiCliView}; -use wasmtime_wasi::clocks::{WasiClocks, WasiClocksView}; -use wasmtime_wasi::random::{WasiRandom, WasiRandomView}; -use wasmtime_wasi::sockets::{WasiSockets, WasiSocketsView}; -use wasmtime_wasi::WasiView; use crate::state::State; -/// Marker for `wasi:io` interfaces, which need `&mut ResourceTable`. -struct HasIo; -impl HasData for HasIo { - type Data<'a> = &'a mut ResourceTable; -} - -/// Add every `wasmtime-wasi` interface except `wasi:filesystem`. -pub fn add_wasi_minus_filesystem(linker: &mut Linker) -> Result<()> { - use wasmtime_wasi::p2::bindings::{cli, clocks, random, sockets}; - use wasmtime_wasi_io::bindings::wasi::io; - let l = linker; - let options = wasmtime_wasi::p2::bindings::LinkOptions::default(); - - // wasi:io (async) - io::error::add_to_linker::(l, |t| t.ctx().table)?; - io::poll::add_to_linker::(l, |t| t.ctx().table)?; - io::streams::add_to_linker::(l, |t| t.ctx().table)?; - - // sockets (async) - sockets::tcp::add_to_linker::(l, State::sockets)?; - sockets::udp::add_to_linker::(l, State::sockets)?; - - // clocks - clocks::wall_clock::add_to_linker::(l, State::clocks)?; - clocks::monotonic_clock::add_to_linker::(l, State::clocks)?; - - // random - random::random::add_to_linker::(l, State::random)?; - random::insecure::add_to_linker::(l, State::random)?; - random::insecure_seed::add_to_linker::(l, State::random)?; - - // cli - cli::exit::add_to_linker::(l, &(&options).into(), State::cli)?; - cli::environment::add_to_linker::(l, State::cli)?; - cli::stdin::add_to_linker::(l, State::cli)?; - cli::stdout::add_to_linker::(l, State::cli)?; - cli::stderr::add_to_linker::(l, State::cli)?; - cli::terminal_input::add_to_linker::(l, State::cli)?; - cli::terminal_output::add_to_linker::(l, State::cli)?; - cli::terminal_stdin::add_to_linker::(l, State::cli)?; - cli::terminal_stdout::add_to_linker::(l, State::cli)?; - cli::terminal_stderr::add_to_linker::(l, State::cli)?; - - // sockets (non-async) - sockets::tcp_create_socket::add_to_linker::(l, State::sockets)?; - sockets::udp_create_socket::add_to_linker::(l, State::sockets)?; - sockets::instance_network::add_to_linker::(l, State::sockets)?; - sockets::network::add_to_linker::(l, &(&options).into(), State::sockets)?; - sockets::ip_name_lookup::add_to_linker::(l, State::sockets)?; - - Ok(()) -} - -/// The full linker a guest sees: WASI, plus `wasi:filesystem` backed by the -/// block store. +/// The full linker a guest sees. pub fn build_linker(engine: &wasmtime::Engine) -> Result> { let mut linker: Linker = Linker::new(engine); - add_wasi_minus_filesystem(&mut linker)?; - crate::wasi::add_filesystem_to_linker(&mut linker)?; + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; crate::tasks::add_to_linker(&mut linker)?; crate::stream::add_to_linker(&mut linker)?; crate::notify::add_to_linker(&mut linker)?; @@ -107,8 +27,7 @@ mod tests { use super::*; /// Not a completeness check — nothing can be, from here. It only catches - /// the coarse failure where two interfaces collide or the filesystem - /// override conflicts with a leftover `wasmtime-wasi` registration. + /// the coarse failure where two interfaces collide. #[test] fn the_linker_builds_without_duplicate_registrations() { let engine = wasmtime::Engine::default(); diff --git a/runtime/src/main.rs b/runtime/src/main.rs index 8ec182a..83f85ee 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -1,46 +1,58 @@ -//! `enclave-runtime` — run a Wasm guest against the Merkle-anchored -//! filesystem, inside an AWS Nitro Enclave. +//! `enclave-runtime` — run a Wasm guest over its tenants' ZFS datasets, inside an AWS Nitro +//! Enclave. //! -//! An enclave has no shell, no config file, and no operator to type flags at -//! it. It gets whatever the enclave image baked in, so **every setting reads -//! from an environment variable**, with a matching flag that wins if present. -//! `ENV` lines in the image's Dockerfile are the deployment configuration; the -//! flags exist so the same binary stays drivable by hand while developing. +//! An enclave has no shell, no config file, and no operator to type flags at it. It gets what its +//! image baked in: an env file in the image's ramdisk, measured into PCR0 with everything else. +//! So **every setting reads from an environment variable**, with a matching flag that wins if +//! present — and changing one means building a new image, exactly as changing a constant does. +//! That is why there are so few: a setting exists only where two deployments of the same code +//! need different values. Everything else is a constant here. //! -//! The guest component is **not** in the image. The runtime fetches it at boot -//! from `--guest-object`, a key in the roots bucket, then measures it into PCR16 -//! and locks that register before it asks KMS for anything — see -//! [`enclave_runtime::guest`]. PCR0 covers the runtime and *where* the guest -//! comes from; PCR16 covers *what* arrived. A key policy pinning both releases -//! the filesystem key to exactly this runtime running exactly this guest, so an -//! attestation still says which code will read the data — and changing the -//! guest is a policy edit rather than an image rebuild. +//! The guest component is **not** in the image. The runtime fetches it at boot from +//! [`GUEST_OBJECT`] in the roots bucket, then measures it into PCR16 and locks that register +//! before it asks KMS for anything — see [`enclave_runtime::guest`]. The guest's own settings +//! travel inside its file and are measured with it — see [`enclave_runtime::env`]. //! -//! Argument parsing over [`enclave_runtime`], which is the same crate: the -//! library half is everything below this file, and lives beside it rather than -//! inside it so integration tests can reach it. +//! The master secret comes from KMS, which releases it only against an attestation whose PCR0 +//! and PCR16 match the key policy. The emulator's alternatives — a static key, unsigned receipts, +//! the host's clock, a local object store — are [`Testing`] settings, compiled into `testing` +//! builds alone, so no setting can talk a production binary into any of them. //! -//! The master secret comes from `--master-key-source`, which has no default: -//! `kms` mints it inside the enclave and lets KMS release it only against an -//! attestation whose PCR0 and PCR16 match the key policy, and `static` takes it from -//! configuration for development and for the QEMU harness. Supplying a -//! plaintext key under `kms` is refused rather than ignored — a key in an -//! environment variable is visible to the parent instance, exactly the party -//! an enclave exists to exclude. - -use std::net::SocketAddr; -use std::path::PathBuf; +//! Argument parsing over [`enclave_runtime`], which is the same crate: the library half is +//! everything below this file, and lives beside it rather than inside it so integration tests +//! can reach it. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context as _, Result}; use clap::Parser; use enclave_runtime::{ - open_clock, open_entropy, serve_component, AcmeConfig, ClockSource, GuestEnvPolicy, - GuestEnvironment, GuestSource, MasterKeySource, MountConfig, NetworkConfig, NetworkMode, - RandomSource, ReceiptTrust, ServeConfig, TlsMode, DEFAULT_GVFORWARDER, DEFAULT_NSM_DEVICE, - DEFAULT_PTP_DEVICE, EXIT_RUNTIME_FAILURE, + open_clock, open_entropy, parse_bool_flag, serve_component, AcmeConfig, ClockSource, + GuestEnvironment, GuestSource, MasterKeySourceKind, MountConfig, NetworkConfig, NetworkMode, + RandomSource, ReceiptTrust, ServeConfig, DEFAULT_NSM_DEVICE, DEFAULT_PTP_DEVICE, + EXIT_RUNTIME_FAILURE, }; +/// Where the guest is, in the roots bucket. +const GUEST_OBJECT: &str = "guest/guest.wasm"; + +/// Every interface, on 443. Inside an enclave the only interface is the one the parent's proxy +/// reaches, and 443 is where it forwards. +const HTTP_LISTEN: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443); + +/// The stream within `--guest-log-group`. One per deployment, so the group alone says where. +const GUEST_LOG_STREAM: &str = "guest"; + +/// How long one S3 request may take, before the SDK's retries. +const S3_TIMEOUT: Duration = Duration::from_secs(30); + +/// The deployment name in the KMS encryption context. It was a setting; it stays in the context, +/// with the value every deployment had, so the keys sealed under it still open. The filesystem +/// id is what tells deployments apart. +const KMS_ENVIRONMENT: &str = "production"; + /// Notification settings, as far as they can be decided without I/O. struct NotifySettings { app_id: String, @@ -60,100 +72,19 @@ fn nonempty(value: &Option) -> Option { #[derive(Parser, Debug)] #[command( version, - about = "Run a Wasm guest against a Merkle-anchored S3 filesystem", + about = "Run a Wasm guest inside an AWS Nitro Enclave", long_about = None )] struct Cli { - /// Enable durable background tasks. Requires authentication and a guest - /// exporting run-task from enclave:tasks/background@0.1.0. - #[arg(long, env = "S3FS_BACKGROUND_TASKS", default_value = "false", value_parser = enclave_runtime::parse_bool_flag, action = clap::ArgAction::Set)] - background_tasks: bool, - #[arg(long, env = "S3FS_BACKGROUND_CONCURRENCY", default_value_t = 1)] - background_concurrency: usize, - #[arg(long, env = "S3FS_BACKGROUND_MAX_RECORDS", default_value_t = 1024)] - background_max_records: usize, - #[arg(long, env = "S3FS_BACKGROUND_PER_TENANT", default_value_t = 64)] - background_per_tenant: usize, - - /// Guest component to run, as a key in the roots bucket. What an enclave - /// image uses. - /// - /// Fetched at boot, measured into PCR16 and locked before any key is asked - /// for. The key is baked into the image, so PCR0 covers where the guest - /// comes from; PCR16 covers what arrived. The object itself need not be - /// trusted: a substituted one measures to a different PCR16, which a key - /// policy pinning the approved guest releases nothing to. - #[arg(long, env = "S3FS_GUEST_OBJECT")] - guest_object: Option, - - /// Guest component to run, from a local file. For development and tests, - /// and measured exactly as an object is. Give this or `--guest-object`. - #[arg(long, env = "S3FS_GUEST_PATH")] - guest_path: Option, - - /// Bucket holding the data slabs. - #[arg(long, env = "S3FS_BUCKET")] - bucket: String, - - /// Bucket holding the signed root records. Defaults to `--bucket`. - /// - /// These should differ in production: the roots bucket carries Object Lock - /// COMPLIANCE retention and is the entire rollback guarantee, while the - /// data bucket stays unlocked so dead copy-on-write blocks stay - /// reclaimable. - #[arg(long, env = "S3FS_ROOTS_BUCKET")] - roots_bucket: Option, - - /// Where the master secret comes from. - /// - /// `kms` mints it inside the enclave and lets KMS release it only against - /// an attestation whose PCR0 and PCR16 match the key policy — so a wrong - /// image or a wrong guest gets no key at all, rather than a refused mount. - /// `static` takes it from `--master-key` and stores it unsealed; it exists - /// for development and for the QEMU harness, whose emulated NSM cannot - /// produce a document KMS would accept. - /// - /// No default. This is the one setting that should never be inherited by - /// omission, and because the image environment is measured, PCR0 records - /// which of the two an enclave was built with. - #[arg(long, env = "S3FS_MASTER_KEY_SOURCE", - value_parser = enclave_runtime::MasterKeySourceKind::parse)] - master_key_source: enclave_runtime::MasterKeySourceKind, - - /// 32-byte master secret, hex encoded. **`--master-key-source=static` only.** - /// - /// Supplying it under `kms` is refused rather than ignored: a key from - /// configuration is a key the parent instance holds, which is precisely - /// what KMS release exists to prevent. - #[arg(long, env = "S3FS_MASTER_KEY")] - master_key: Option, - - /// The customer master key that releases this filesystem's secret. Its - /// policy — `kms:RecipientAttestation:PCR0` and `:PCR16`, on both - /// `GenerateDataKey` and `Decrypt` — is the security control. - #[arg(long, env = "S3FS_KMS_KEY_ID")] - kms_key_id: Option, - - /// SSM parameter holding the KMS ciphertext, and nothing else. - #[arg(long, env = "S3FS_MASTER_KEY_PARAMETER")] - master_key_parameter: Option, - - /// Deployment name, mixed into the KMS encryption context alongside the - /// filesystem id — so a staging enclave cannot open a production - /// filesystem even when pointed at the same parameter. - #[arg(long, env = "S3FS_ENVIRONMENT", default_value = "production")] - environment: String, - - /// Endpoint overrides for KMS and SSM, separate from `--endpoint`. - /// - /// S3's override points at MinIO in development; a MinIO endpoint is not a - /// KMS endpoint, and reusing it would fail in a way that reads like a - /// credentials problem. - #[arg(long, env = "S3FS_KMS_ENDPOINT")] - kms_endpoint: Option, - - #[arg(long, env = "S3FS_SSM_ENDPOINT")] - ssm_endpoint: Option, + /// The Object-Locked bucket: the pool's anchor chain, the boot's origin + /// records, the guest, and the sealed ACME cache. COMPLIANCE retention on + /// it is the entire rollback guarantee. The state itself is on the pool. + #[arg(long, env = "ENCLAVE_ROOTS_BUCKET")] + roots_bucket: String, + + /// Key prefix inside the roots bucket, so deployments can share one locked bucket. + #[arg(long, env = "ENCLAVE_BUCKET_PREFIX", default_value = "")] + bucket_prefix: String, /// Filesystem identifier, 32 hex characters — the key-derivation salt, so /// two filesystems under one master secret stay independent. @@ -163,233 +94,68 @@ struct Cli { /// the store to say which key checks its own signature. #[arg( long, - env = "S3FS_ID", + env = "ENCLAVE_ID", default_value = "00000000000000000000000000000000" )] fs_id: String, - /// Refuse to mount a root record older than this sequence number. + /// Refuse a store whose newest anchor is older than this sequence number. /// - /// The only defence against a store that hides newer roots at a cold - /// mount. Everything else about rollback is closed cryptographically; this + /// The only defence against a store that hides newer anchors at a cold + /// boot. Everything else about rollback is closed cryptographically; this /// one needs a number from outside the store. - #[arg(long, env = "S3FS_MIN_ROOT_SEQ")] + #[arg(long, env = "ENCLAVE_MIN_ROOT_SEQ")] min_root_seq: Option, - /// How long each root record is locked against deletion, in seconds. + /// How long each anchor and boot record is locked against deletion, in seconds. /// /// COMPLIANCE retention: nobody can shorten it, so the rollback guarantee /// lasts this long and so does the roots bucket. Ten years unless the image /// says otherwise; a test deployment says a day, so it can be retired. /// Baked into the image, so PCR0 tells a client which kind it is talking /// to. Under a day is refused: it protects nothing worth the name. - #[arg(long, env = "S3FS_ROOT_RETENTION_SECS", - default_value_t = s3fs_core::store::config::DEFAULT_ROOT_RETENTION.as_secs(), + #[arg(long, env = "ENCLAVE_ROOT_RETENTION_SECS", + default_value_t = enclave_runtime::DEFAULT_ROOT_RETENTION.as_secs(), value_parser = clap::value_parser!(u64).range(86_400..))] root_retention_secs: u64, - /// Path the guest sees as its preopen root. - #[arg(long, env = "S3FS_MOUNT_PATH", default_value = "/")] - mount_path: String, - - /// Key prefix inside both buckets. - #[arg(long, env = "S3FS_BUCKET_PREFIX", default_value = "")] - bucket_prefix: String, - #[arg(long, env = "AWS_REGION", default_value = "us-east-1")] region: String, - /// Endpoint override, e.g. a local MinIO or the parent's vsock proxy. - #[arg(long, env = "S3FS_ENDPOINT")] - endpoint: Option, - - #[arg(long, env = "AWS_ACCESS_KEY_ID")] - access_key_id: Option, - - #[arg(long, env = "AWS_SECRET_ACCESS_KEY")] - secret_access_key: Option, - - #[arg(long, env = "AWS_SESSION_TOKEN")] - session_token: Option, - - /// Path-style addressing, required by MinIO and many S3-compatibles. - #[arg(long, env = "S3FS_FORCE_PATH_STYLE", value_parser = enclave_runtime::parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")] - force_path_style: bool, + /// The customer master key that releases this filesystem's secret. Its + /// policy — `kms:RecipientAttestation:PCR0` and `:PCR16`, on both + /// `GenerateDataKey` and `Decrypt` — is the security control. + #[arg(long, env = "ENCLAVE_KMS_KEY_ID")] + kms_key_id: Option, - /// Skip the `HeadBucket` startup probe. - #[arg(long, env = "S3FS_SKIP_BUCKET_PROBE", value_parser = enclave_runtime::parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")] - skip_bucket_probe: bool, + /// SSM parameter holding the KMS ciphertext, and nothing else. + #[arg(long, env = "ENCLAVE_MASTER_KEY_PARAMETER")] + master_key_parameter: Option, - /// Give the guest nothing but what `--guest-env` names. - /// - /// **Use this when running outside an enclave.** Inheritance is right for - /// an enclave image, where the environment is curated deployment - /// configuration; it is wrong on a developer's machine, where it forwards - /// that machine's whole environment to the guest. The denylist below - /// withholds this runtime's own credentials, not a `GITHUB_TOKEN` or an - /// `SSH_AUTH_SOCK`. + /// Domain to put in the certificate. Repeatable; at least one. /// - /// By default it inherits this process's environment minus anything under - /// `AWS_` or `S3FS_`, which is where the credentials and this runtime's - /// own configuration live. - #[arg(long, env = "S3FS_NO_INHERIT_ENV", value_parser = enclave_runtime::parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")] - no_inherit_env: bool, - - /// Extra variable for the guest, as `NAME` (inherit that one by name) or - /// `NAME=VALUE`. Applied after inheritance, so it overrides — including - /// for names that are otherwise withheld. Repeatable, or comma-separated. - /// - /// Paired with `--no-inherit-env` this is the explicit model: the guest - /// gets exactly what is named here and nothing else. + /// Let's Encrypt issues it over TLS-ALPN-01 on the serving port. The key + /// is generated inside the enclave and never leaves it; what the CA signs + /// is what every response's attestation document binds. #[arg( - long = "guest-env", - env = "S3FS_GUEST_ENV", - value_delimiter = ',', - value_name = "NAME[=VALUE]" + long = "tls-domain", + env = "ENCLAVE_TLS_DOMAINS", + value_delimiter = ',' )] - guest_env: Vec, - - /// Where the guest's wall-clock time comes from. - /// - /// `ptp` reads the Nitro PTP hardware clock and refuses to start without - /// it. `host` uses the system clock, which inside an enclave is whatever - /// the hypervisor last set — fine for development, untrusted in - /// production. `auto` prefers PTP and warns when it falls back. - /// - /// An enclave image should set this to `ptp`. - #[arg(long, env = "S3FS_CLOCK_SOURCE", default_value = "auto", - value_parser = ClockSource::parse)] - clock_source: ClockSource, - - /// PTP character device to read. - #[arg(long, env = "S3FS_PTP_DEVICE", default_value = DEFAULT_PTP_DEVICE)] - ptp_device: PathBuf, - - /// Where the guest's random bytes come from. - /// - /// `nsm` reads the Nitro Security Module and refuses to start without it. - /// `host` uses the kernel, which inside an enclave is NSM-seeded but says - /// nothing about it. `auto` prefers NSM and reports a fallback as an error, - /// because every key the guest generates afterwards rests on the answer. - /// - /// An enclave image should set this to `nsm`. - #[arg(long, env = "S3FS_RANDOM_SOURCE", default_value = "auto", - value_parser = RandomSource::parse)] - random_source: RandomSource, - - /// NSM character device to read. - #[arg(long, env = "S3FS_NSM_DEVICE", default_value = DEFAULT_NSM_DEVICE)] - nsm_device: PathBuf, - - /// How a state-origin receipt must be trusted. - /// - /// `required` demands a signature chaining to the AWS Nitro root. - /// `unsigned-emulator` reads the contents without checking anything, - /// because QEMU's emulated NSM does not sign — and because the image's - /// environment is measured, PCR0 tells a client which of the two an - /// enclave was built with. A production image never sets it. - #[arg(long, env = "S3FS_RECEIPT_TRUST", default_value = "required", - value_parser = ReceiptTrust::parse)] - receipt_trust: ReceiptTrust, - - /// Seconds a guest may take to produce a response head before the request - /// is abandoned. - /// - /// A guest that neither returns nor answers otherwise hangs forever, and - /// with one request in flight at a time that is the whole server. Baked - /// into the image like every other setting, so PCR0 covers it. - #[arg(long, env = "S3FS_REQUEST_TIMEOUT_SECS", default_value_t = 30)] - request_timeout_secs: u64, - - /// Seconds one S3 request may take, before the SDK's retries. - /// - /// Its own setting rather than a reuse of `--request-timeout-secs`, which - /// bounds how long a *guest* may take: tuning how long a guest may think - /// should not silently change how long a slab read may take, and the two - /// have no reason to move together. - /// - /// It has to be a setting at all because an enclave has no shell. Thirty - /// seconds is a guess, and a slow endpoint or a large slab is exactly the - /// case where a guess is wrong — with the symptom being a mount that never - /// returns rather than an error that names S3. - #[arg(long, env = "S3FS_S3_TIMEOUT_SECS", default_value_t = 30)] - s3_timeout_secs: u64, - - #[arg(long, env = "S3FS_GUEST_LOG_GROUP")] - guest_log_group: Option, - - /// The stream within `--guest-log-group`. Required alongside it. - #[arg(long, env = "S3FS_GUEST_LOG_STREAM")] - guest_log_stream: Option, - - /// Point the CloudWatch client somewhere else. For tests. - /// - /// A downgrade path, and worth naming as one: aimed at an `http://` - /// endpoint it hands guest output to whatever is listening, in clear. That - /// is tolerable only because this is baked into the image, so PCR0 records - /// which was built. - #[arg(long, env = "S3FS_GUEST_LOG_ENDPOINT")] - guest_log_endpoint: Option, - - /// The push application wake signals go through: an AWS End User Messaging Push - /// application, whose FCM channel holds the Firebase credential. - /// - /// Empty means notifications are off — the same layering rule the guest log settings - /// follow, so an image can carry the setting and a deployment can blank it. The image names - /// the application and nothing else: requests are signed as the instance's own role, so - /// there is no credential here to set, or to leak from a published image. - #[arg(long, env = "S3FS_PUSH_APP_ID")] - push_app_id: Option, - - /// Point the push client at a stub. **Only in a `testing` build**, for tests and the - /// emulator: an `http://` endpoint hands wake signals to whatever is listening, and - /// requests to it are signed with a placeholder rather than the instance's role. - #[cfg(any(test, feature = "testing"))] - #[arg(long, env = "S3FS_PUSH_ENDPOINT")] - push_endpoint: Option, - - /// Clients kept warm at once. - /// - /// A warm client costs a wasm linear memory and nothing else — the - /// filesystem and its block cache are shared — so this bounds instance - /// memory rather than cache memory. Past it, the least recently used idle - /// client is dropped; one serving a request is never evicted. - #[arg(long, env = "S3FS_MAX_TENANTS", default_value_t = 64)] - max_tenants: usize, - - /// Seconds a mounted client may sit idle before it is dropped. - #[arg(long, env = "S3FS_TENANT_IDLE_SECS", default_value_t = 900)] - tenant_idle_secs: u64, - - /// Requests one client's instance serves before it is rebuilt. - /// - /// Wasm linear memory never shrinks, so an instance that lived forever - /// would only grow. Rebuilding costs ~24 µs and keeps the mount, which is - /// the part that costs S3 round trips. - #[arg(long, env = "S3FS_MAX_REQUESTS_PER_INSTANCE", default_value_t = 10_000)] - max_requests_per_instance: u64, + tls_domains: Vec, /// The WebAuthn relying-party id — the domain passkeys are scoped to. /// - /// Setting it, with `--webauthn-origin`, is what turns authentication on: - /// **every request that could reach the guest then needs a fresh assertion - /// bound to exactly that request.** Left unset the runtime serves the guest - /// to anyone who can open a connection, which is a development and QEMU - /// arrangement and is warned about at startup. - /// - /// Must match the domain the app is scoped to, and production needs - /// browser-trusted TLS on it — see `--tls acme`. Baked into the image, so - /// PCR0 records which relying party an enclave will accept assertions for. - #[arg(long, env = "S3FS_WEBAUTHN_RP_ID")] + /// Setting it is what turns authentication on: **every request that could + /// reach the guest then needs a fresh assertion bound to exactly that + /// request**, and assertions must claim `https://` or one of + /// `--webauthn-allowed-origin`. Left unset the runtime serves the guest to + /// anyone who can open a connection, which is a development arrangement and + /// is warned about at startup. Baked into the image, so PCR0 records which + /// relying party an enclave will accept assertions for. + #[arg(long, env = "ENCLAVE_WEBAUTHN_RP_ID")] webauthn_rp_id: Option, - /// The exact origin assertions must claim, e.g. `https://cosigner.example.com`. - /// - /// Compared exactly, not by suffix: a page on another origin must not be - /// able to borrow a user's passkey for this one. - #[arg(long, env = "S3FS_WEBAUTHN_ORIGIN")] - webauthn_origin: Option, - /// A further origin assertions may claim. Repeatable. /// /// For native apps, which do not claim `https://`. An Android app @@ -399,161 +165,191 @@ struct Cli { /// Android lets an app claim it only after the app is vouched for by /// `https:///.well-known/assetlinks.json`. /// - /// Compared exactly, like `--webauthn-origin`. Baked into the image, so - /// PCR0 records which apps an enclave accepts assertions from. + /// Compared exactly. Baked into the image, so PCR0 records which apps an + /// enclave accepts assertions from. #[arg( long = "webauthn-allowed-origin", - env = "S3FS_WEBAUTHN_ALLOWED_ORIGINS", + env = "ENCLAVE_WEBAUTHN_ALLOWED_ORIGINS", value_delimiter = ',' )] webauthn_allowed_origins: Vec, - /// Seconds a challenge is good for. + /// The push application wake signals go through: an AWS End User Messaging Push + /// application, whose FCM channel holds the Firebase credential. /// - /// Long enough for a person to look at a prompt and present a finger, - /// short enough that a captured assertion is stale before it can be used. - #[arg(long, env = "S3FS_CHALLENGE_TTL_SECS", default_value_t = 60)] - challenge_ttl_secs: u64, + /// Empty means notifications are off, so an image can carry the setting and a + /// deployment can blank it. The image names the application and nothing else: + /// requests are signed as the instance's own role, so there is no credential + /// here to set, or to leak from a published image. + #[arg(long, env = "ENCLAVE_PUSH_APP_ID")] + push_app_id: Option, - /// Seconds an interaction token is good for before it is spent. - /// - /// This bounds the time to *start* an interaction — a person who approves - /// something and then puts their phone down should not find the approval - /// still live later. It is not how long an interaction may run. - #[arg(long, env = "S3FS_INTERACTION_TOKEN_TTL_SECS", default_value_t = 60)] - interaction_token_ttl_secs: u64, + /// CloudWatch log group for guest output, written to its `guest` stream. + /// Empty or unset means the console only. + #[arg(long, env = "ENCLAVE_GUEST_LOG_GROUP")] + guest_log_group: Option, - /// Seconds an interaction may run once started. - /// - /// The other half of the distinction: a stream holds its tenant's single - /// slot for its whole life, so this is what bounds how long that tenant's - /// next request waits. Enforced by wall clock, because a guest parked in a - /// host call executes no wasm and the epoch cannot see it. - #[arg(long, env = "S3FS_MAX_INTERACTION_SECS", default_value_t = 300)] - max_interaction_secs: u64, - - /// Report on the configured clock and entropy source and exit, without - /// mounting or running anything. For diagnosing a deployment, and the only - /// thing the emulator harness needs — it touches no storage. + /// Report on the clock and entropy source and exit, without mounting or + /// running anything. For diagnosing a deployment. #[arg(long, alias = "clock-check")] self_check: bool, - /// Address to serve on in `serve` mode. - /// - /// Binds every interface by default because inside an enclave the only - /// interface is the one the parent's proxy reaches, and binding loopback - /// there would answer nobody. - #[arg(long, env = "S3FS_HTTP_LISTEN", default_value = "0.0.0.0:8080")] - http_listen: SocketAddr, + #[cfg_attr(any(test, feature = "testing"), command(flatten))] + #[cfg_attr(not(any(test, feature = "testing")), arg(skip = Testing::production()))] + testing: Testing, - /// Where the serving certificate comes from. - /// - /// `acme`, the default, obtains one from Let's Encrypt over TLS-ALPN-01 on - /// the same port. It needs `--tls-domain` and outbound network. The key is - /// generated inside the enclave and never leaves it; what the CA signs is - /// what every response's attestation document binds. - /// - /// `off` serves plaintext. Inside an enclave that hands every request to - /// the parent instance, which is the party this design excludes. + /// Arguments passed to the guest. + #[arg(last = true)] + guest_args: Vec, +} + +/// The settings only a `testing` build has: the emulator's and the tests'. +/// +/// A production binary has none of these flags. It takes [`Testing::production`], so each +/// value a production enclave runs with is written once, there, and no deployment can reach +/// another. +#[derive(clap::Args, Debug)] +struct Testing { + /// Where the master secret comes from. `static` takes it from `--master-key` + /// and stores it unsealed, for the emulator, whose NSM cannot produce a + /// document KMS would accept. + #[arg(long, env = "ENCLAVE_MASTER_KEY_SOURCE", default_value = "kms", + value_parser = MasterKeySourceKind::parse)] + master_key_source: MasterKeySourceKind, + + /// 32-byte master secret, hex encoded. `static` only, and refused under + /// `kms`: a key from configuration is a key the parent instance holds. + #[arg(long, env = "ENCLAVE_MASTER_KEY")] + master_key: Option, + + /// `unsigned-emulator` reads a state-origin receipt without checking a + /// signature, because the emulator's NSM does not sign. + #[arg(long, env = "ENCLAVE_RECEIPT_TRUST", default_value = "required", + value_parser = ReceiptTrust::parse)] + receipt_trust: ReceiptTrust, + + /// Where the guest's wall-clock time comes from. The emulator has no PTP + /// clock; `host` uses the system's. + #[arg(long, env = "ENCLAVE_CLOCK_SOURCE", default_value = "ptp", + value_parser = ClockSource::parse)] + clock_source: ClockSource, + + /// PTP character device to read. + #[arg(long, env = "ENCLAVE_PTP_DEVICE", default_value = DEFAULT_PTP_DEVICE)] + ptp_device: PathBuf, + + /// Where random bytes come from. `host` is for a machine with no NSM. + #[arg(long, env = "ENCLAVE_RANDOM_SOURCE", default_value = "nsm", + value_parser = RandomSource::parse)] + random_source: RandomSource, + + /// An S3-compatible endpoint in place of AWS's: the emulator's MinIO. + #[arg(long, env = "ENCLAVE_ENDPOINT")] + endpoint: Option, + + /// Path-style addressing, which MinIO needs. + #[arg(long, env = "ENCLAVE_FORCE_PATH_STYLE", value_parser = parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")] + force_path_style: bool, + + /// Static credentials for that endpoint. Production signs as the instance's role. + #[arg(long, env = "AWS_ACCESS_KEY_ID")] + access_key_id: Option, + + #[arg(long, env = "AWS_SECRET_ACCESS_KEY")] + secret_access_key: Option, + + #[arg(long, env = "AWS_SESSION_TOKEN")] + session_token: Option, + + /// An ACME directory in place of Let's Encrypt production: Pebble, or staging. + #[arg(long, env = "ENCLAVE_ACME_DIRECTORY")] + acme_directory: Option, + + /// PEM trust root for the ACME directory's own HTTPS certificate. /// - /// There is deliberately no self-signed mode at all: a certificate an - /// operator can mint is one they can mint for an impostor too, and it buys - /// nothing the attestation binding does not already give. A deployment with - /// no public CA points `--acme-directory` at a private one instead. - #[arg(long, env = "S3FS_TLS", default_value = "acme", - value_parser = TlsMode::parse)] - tls: TlsMode, - - /// Domain to put in the certificate. Repeatable; required for `--tls acme`. - #[arg(long = "tls-domain", env = "S3FS_TLS_DOMAINS", value_delimiter = ',')] - tls_domains: Vec, + /// A real CA serves its API under a publicly-trusted certificate and needs + /// nothing here; a test CA like Pebble does not, so the emulator supplies + /// its root. An enclave that took issuance orders from a CA of the + /// operator's choosing would be a different trust model than the one a + /// production image claims. + #[arg(long, env = "ENCLAVE_ACME_CA")] + acme_ca: Option, - /// Contact address registered with the ACME provider, for expiry notices. + /// Contact address registered with the ACME directory. #[arg( long = "acme-contact", - env = "S3FS_ACME_CONTACTS", + env = "ENCLAVE_ACME_CONTACTS", value_delimiter = ',' )] acme_contacts: Vec, - /// ACME directory URL. Defaults to Let's Encrypt production. - /// - /// Point it at staging while setting a deployment up. Production allows - /// five duplicate certificates per week per domain, and an enclave that - /// keeps re-issuing will exhaust that and be unable to serve. - #[arg(long, env = "S3FS_ACME_DIRECTORY")] - acme_directory: Option, + /// Point the CloudWatch client somewhere else. An `http://` endpoint hands + /// guest output to whatever is listening, in clear. + #[arg(long, env = "ENCLAVE_GUEST_LOG_ENDPOINT")] + guest_log_endpoint: Option, - /// PEM trust root for the ACME directory's own HTTPS certificate. - /// - /// **Only in a `testing` build.** A real CA serves its API under a - /// publicly-trusted certificate and needs nothing here; a test CA like - /// Pebble does not, so the QEMU harness supplies its root. A production - /// enclave has no such flag, because one that took issuance orders from a - /// CA of the operator's choosing would be a different trust model than the - /// one this image claims. - #[cfg(any(test, feature = "testing"))] - #[arg(long, env = "S3FS_ACME_CA")] - acme_ca: Option, + /// Point the push client at a stub: an `http://` endpoint hands wake signals to + /// whatever is listening, and requests to it are signed with a placeholder + /// rather than the instance's role. + #[arg(long, env = "ENCLAVE_PUSH_ENDPOINT")] + push_endpoint: Option, /// Re-sign attestation documents with a certificate chain minted at boot. /// - /// **Only in a `testing` build, and only useful under an emulator.** QEMU's - /// NSM produces documents with genuine contents — the real PCR0 of the - /// image, the PCR16 the runtime measured — inside an envelope it does not - /// sign: its source says *"we don't actually sign the data, so we use -1 as - /// the 'alg' value"*, and -1 is not a COSE algorithm. A client meeting one - /// has to pass `--unsigned-emulator`, which skips the signature, the chain - /// and the validity windows entirely. + /// Only useful under an emulator. QEMU's NSM produces documents with + /// genuine contents — the real PCR0 of the image, the PCR16 the runtime + /// measured — inside an envelope it does not sign: its source says *"we + /// don't actually sign the data, so we use -1 as the 'alg' value"*, and -1 + /// is not a COSE algorithm. A client meeting one has to pass + /// `--unsigned-emulator`, which skips the signature, the chain and the + /// validity windows entirely. /// /// With this set, the runtime asks the device for a document and re-signs /// that same payload, so a client pins the reported root and runs every /// check it would run against hardware. It proves nothing about *who* /// produced a document — the key is minted inside an image its operator - /// controls — which is why a production binary has no such flag. - #[cfg(any(test, feature = "testing"))] - #[arg(long, env = "S3FS_COSIGN_ATTESTATIONS", value_parser = enclave_runtime::parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")] + /// controls. + #[arg(long, env = "ENCLAVE_COSIGN_ATTESTATIONS", value_parser = parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")] cosign_attestations: bool, +} - /// How the enclave reaches the network. - /// - /// `gvproxy` runs the tap forwarder against the parent's gvproxy, which is - /// the only way an enclave gets an interface at all — it has no NIC, only - /// vsock. Without it nothing that speaks TCP works: not S3, not ACME, not - /// even DNS. - /// - /// `none` assumes the network is already there, which is true everywhere - /// except inside an enclave. - /// - /// An enclave image should set this to `gvproxy`. - #[arg(long, env = "S3FS_NETWORK", default_value = "none", - value_parser = NetworkMode::parse)] - network: NetworkMode, - - /// The tap forwarder binary, shipped inside the enclave image. - #[arg(long, env = "S3FS_GVFORWARDER", default_value = DEFAULT_GVFORWARDER)] - gvforwarder: PathBuf, - - /// Arguments passed to the guest. - #[arg(last = true)] - guest_args: Vec, +impl Testing { + /// What a production enclave runs with. + #[cfg_attr(any(test, feature = "testing"), allow(dead_code))] + fn production() -> Self { + Testing { + master_key_source: MasterKeySourceKind::Kms, + master_key: None, + receipt_trust: ReceiptTrust::Required, + clock_source: ClockSource::Ptp, + ptp_device: PathBuf::from(DEFAULT_PTP_DEVICE), + random_source: RandomSource::Nsm, + endpoint: None, + force_path_style: false, + access_key_id: None, + secret_access_key: None, + session_token: None, + acme_directory: None, + acme_ca: None, + acme_contacts: Vec::new(), + guest_log_endpoint: None, + push_endpoint: None, + cosign_attestations: false, + } + } } impl Cli { - /// Where guest output goes, beyond the console. - /// - /// A group without a stream is refused here rather than at the first write: - /// it needs no I/O to know it is wrong, and the same discipline as - /// `--webauthn-rp-id`/`--webauthn-origin`. Everything that *does* need the - /// network is decided at startup instead, where a transient failure can be - /// told apart from a mistake. /// Everything about notifications that can be decided without a network. /// /// A malformed application id fails at boot rather than the first time somebody is waiting /// to be woken; whether the application can deliver is the probe's to find out, once there /// is a network. fn notify_settings(&self) -> Result> { - match (nonempty(&self.push_app_id), self.push_endpoint()) { + match ( + nonempty(&self.push_app_id), + nonempty(&self.testing.push_endpoint), + ) { (None, None) => Ok(None), (None, Some(_)) => anyhow::bail!( "--push-endpoint was given without --push-app-id, so there is no application to \ @@ -569,124 +365,54 @@ impl Cli { } } - #[cfg(any(test, feature = "testing"))] - fn push_endpoint(&self) -> Option { - nonempty(&self.push_endpoint) - } - - #[cfg(not(any(test, feature = "testing")))] - fn push_endpoint(&self) -> Option { - None - } - - fn guest_log_config(&self) -> Result> { - // Empty means off, not malformed. The image environment is layered — - // the QEMU image inherits production's and overrides what it cannot - // use — and setting a variable to "" is how that layer says "not this - // one". Treating it as a typo would make the override impossible. - let group = self - .guest_log_group - .as_deref() - .map(str::trim) - .filter(|g| !g.is_empty()); - let stream = self - .guest_log_stream - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - match (group, stream) { - (None, None) => Ok(None), - (Some(group), Some(stream)) => Ok(Some(enclave_runtime::CloudWatchConfig { - log_group: group.to_string(), - log_stream: stream.to_string(), - region: self.region.clone(), - endpoint: self.guest_log_endpoint.clone(), - })), - _ => anyhow::bail!( - "--guest-log-group and --guest-log-stream must be given together. \ - This runtime holds `logs:PutLogEvents` and nothing more, so it cannot \ - create a stream it was not told the name of." - ), - } - } - - /// Where the guest comes from: exactly one of the two settings. + /// Where guest output goes, beyond the console: the `guest` stream of the configured group. /// - /// An empty setting counts as unset, for the layering reason - /// `guest_log_config` gives. - fn guest_source(&self) -> Result { - let object = self - .guest_object - .as_deref() - .map(str::trim) - .filter(|key| !key.is_empty()); - let path = self - .guest_path - .as_ref() - .filter(|path| !path.as_os_str().is_empty()); - match (object, path) { - (Some(key), None) => Ok(GuestSource::Object { - key: key.to_string(), - }), - (None, Some(path)) => Ok(GuestSource::Path(path.clone())), - (None, None) => anyhow::bail!( - "no guest to run: set --guest-object, a key in the roots bucket, or outside \ - an enclave --guest-path" - ), - (Some(_), Some(_)) => anyhow::bail!( - "--guest-object and --guest-path are both set. They name two guests, and \ - there is no safe way to guess which was meant." - ), - } + /// Empty means off, not malformed. The image environment is layered — the QEMU image + /// inherits production's and blanks what it cannot use — and "" is how that layer says + /// "not this one". + fn guest_log_config(&self) -> Option { + nonempty(&self.guest_log_group).map(|log_group| enclave_runtime::CloudWatchConfig { + log_group, + log_stream: GUEST_LOG_STREAM.to_string(), + region: self.region.clone(), + endpoint: self.testing.guest_log_endpoint.clone(), + }) } fn mount_config(&self) -> Result { Ok(MountConfig { - bucket: self.bucket.clone(), roots_bucket: self.roots_bucket.clone(), region: self.region.clone(), - endpoint: self.endpoint.clone(), - access_key_id: self.access_key_id.clone(), - secret_access_key: self.secret_access_key.clone(), - session_token: self.session_token.clone(), - force_path_style: self.force_path_style, + endpoint: self.testing.endpoint.clone(), + access_key_id: self.testing.access_key_id.clone(), + secret_access_key: self.testing.secret_access_key.clone(), + session_token: self.testing.session_token.clone(), + force_path_style: self.testing.force_path_style, bucket_prefix: self.bucket_prefix.clone(), - mount_path: self.mount_path.clone(), fs_id: enclave_runtime::parse_fs_id(&self.fs_id)?, min_root_seq: self.min_root_seq, - skip_bucket_probe: self.skip_bucket_probe, - request_timeout: Duration::from_secs(self.s3_timeout_secs), + skip_bucket_probe: false, + request_timeout: S3_TIMEOUT, root_retention: Duration::from_secs(self.root_retention_secs), }) } fn master_key_config(&self) -> Result { Ok(enclave_runtime::MasterKeyConfig { - kind: self.master_key_source, - master_key: self.master_key.clone(), + kind: self.testing.master_key_source, + master_key: self.testing.master_key.clone(), kms_key_id: self.kms_key_id.clone(), parameter: self.master_key_parameter.clone(), - environment: self.environment.clone(), + environment: KMS_ENVIRONMENT.to_string(), fs_id: enclave_runtime::parse_fs_id(&self.fs_id)?, region: self.region.clone(), - kms_endpoint: self.kms_endpoint.clone(), - ssm_endpoint: self.ssm_endpoint.clone(), - access_key_id: self.access_key_id.clone(), - secret_access_key: self.secret_access_key.clone(), - session_token: self.session_token.clone(), + kms_endpoint: None, + ssm_endpoint: None, + access_key_id: self.testing.access_key_id.clone(), + secret_access_key: self.testing.secret_access_key.clone(), + session_token: self.testing.session_token.clone(), }) } - - fn env_policy(&self) -> GuestEnvPolicy { - if self.no_inherit_env { - GuestEnvPolicy::explicit_only(self.guest_env.clone()) - } else { - GuestEnvPolicy { - inherit: true, - explicit: self.guest_env.clone(), - } - } - } } #[tokio::main] @@ -715,14 +441,14 @@ async fn main() -> std::process::ExitCode { async fn run() -> Result { let cli = Cli::parse(); - let clock = open_clock(cli.clock_source, &cli.ptp_device)?; - let entropy = open_entropy(cli.random_source, &cli.nsm_device)?; + let clock = open_clock(cli.testing.clock_source, &cli.testing.ptp_device)?; + let entropy = open_entropy(cli.testing.random_source, Path::new(DEFAULT_NSM_DEVICE))?; /// Wrap the device so its documents carry a signature, if this build can. /// - /// Two definitions rather than a runtime branch, for the reason `--acme-ca` - /// has two: a production binary has no flag to read, so no deployment can - /// talk it into signing attestations with a key it minted itself. + /// Two definitions rather than a runtime branch: a production binary has no + /// cosigning code at all, so no deployment can talk it into signing + /// attestations with a key it minted itself. #[cfg(any(test, feature = "testing"))] fn cosign( cli: &Cli, @@ -730,7 +456,7 @@ async fn run() -> Result { ) -> Result> { use base64::Engine as _; - if !cli.cosign_attestations { + if !cli.testing.cosign_attestations { return Ok(entropy); } let cosigning = enclave_runtime::testing::CosigningNsm::wrap(entropy)?; @@ -768,15 +494,14 @@ async fn run() -> Result { // that mounted first would fail with an S3 error that says nothing about // the real cause. let _network = enclave_runtime::bring_up(&NetworkConfig { - mode: cli.network, - gvforwarder: cli.gvforwarder.clone(), + mode: NetworkMode::Gvproxy, ..Default::default() })?; let keys = enclave_runtime::open_key_source(&cli.master_key_config()?, entropy.clone())?; - // Resolved before anything touches the network, so a runtime given no - // guest, or two, says so rather than failing somewhere later. - let guest_source = cli.guest_source()?; + let guest_source = GuestSource::Object { + key: GUEST_OBJECT.to_string(), + }; tracing::info!( key_source = keys.describe(), guest = %guest_source, @@ -784,13 +509,13 @@ async fn run() -> Result { ); let mount_config = cli.mount_config()?; - let backends = enclave_runtime::connect(&mount_config).await?; + let roots = enclave_runtime::connect(&mount_config).await?; // The guest, before anything asks for a key. KMS releases the key against // an attestation carrying PCR16, so PCR16 has to be final by then: measured, // and locked so nothing can extend it afterwards. These same bytes are what // get compiled and served below, so what was measured is what runs. - let component = enclave_runtime::fetch_guest(&guest_source, &backends.roots).await?; + let component = enclave_runtime::fetch_guest(&guest_source, &roots).await?; let guest_pcr = enclave_runtime::measure_guest(entropy.as_ref(), &component)?; tracing::info!( guest = %guest_source, @@ -802,13 +527,15 @@ async fn run() -> Result { // Decide whether this enclave is entitled to the state it is about to // load, before it loads any of it. let booted = enclave_runtime::boot( - &backends, + &roots, &mount_config, &enclave_runtime::BootConfig { - trust: cli.receipt_trust, + trust: cli.testing.receipt_trust, + disk: enclave_runtime::Disk::Parent, }, &entropy, &keys, + &clock, ) .await?; @@ -820,33 +547,15 @@ async fn run() -> Result { "state origin established" ); - let mounted = booted.mounted; - let fs = mounted.fs.clone(); - - // What the image says, then what the guest file says on top: the settings a guest was - // deployed with travel inside it, measured into PCR16 with its code — see `env::from_guest`. - let env = enclave_runtime::env::with_guest_settings(cli.env_policy().build()?, &component)?; - - /// The ACME directory's trust root, if this build can have one. - /// - /// Two definitions rather than a runtime branch: a production binary has no - /// `--acme-ca` field to read, so the public roots are not something a - /// deployment can talk it out of. - #[cfg(any(test, feature = "testing"))] - fn acme_directory_ca(cli: &Cli) -> anyhow::Result>> { - match &cli.acme_ca { - Some(path) => Ok(Some(anyhow::Context::with_context( - std::fs::read(path), - || format!("reading the ACME directory trust root {}", path.display()), - )?)), - None => Ok(None), - } - } + let zfs = booted.zfs.clone(); - #[cfg(not(any(test, feature = "testing")))] - fn acme_directory_ca(_cli: &Cli) -> anyhow::Result>> { - Ok(None) - } + // The guest's environment is the settings its file carries, measured into PCR16 with its + // code, and nothing else — see `env::from_guest`. Names only: a value can be a credential. + let env = enclave_runtime::env::from_guest(&component)?; + tracing::info!( + names = %env.iter().map(|(n, _)| n.as_str()).collect::>().join(", "), + "guest environment: the settings the guest file carries" + ); /// Open the emulator's host to guests, but not the runtime's own services on it. /// @@ -859,12 +568,10 @@ async fn run() -> Result { fn open_dev_host(cli: &Cli) -> anyhow::Result<()> { let mut own = Vec::new(); for url in [ - &cli.endpoint, - &cli.kms_endpoint, - &cli.ssm_endpoint, - &cli.acme_directory, - &cli.guest_log_endpoint, - &cli.push_endpoint, + &cli.testing.endpoint, + &cli.testing.acme_directory, + &cli.testing.guest_log_endpoint, + &cli.testing.push_endpoint, ] .into_iter() .flatten() @@ -891,28 +598,26 @@ async fn run() -> Result { Ok(()) } - // One certificate source, or none. There is no third: a certificate the - // runtime minted for itself would be one an operator could mint too. - let acme = match cli.tls { - TlsMode::Off => None, - TlsMode::Acme => Some(enclave_runtime::serve::acme::start( - &AcmeConfig { - domains: cli.tls_domains.clone(), - contacts: cli.acme_contacts.clone(), - directory: cli.acme_directory.clone(), - directory_ca: acme_directory_ca(&cli)?, - prefix: mounted.bucket_prefix.clone(), - }, - mounted.data.clone(), - mounted.keys.clone(), - )?), + // One certificate source. There is no second: a certificate the runtime + // minted for itself would be one an operator could mint too. + let directory_ca = match &cli.testing.acme_ca { + Some(path) => Some(std::fs::read(path).with_context(|| { + format!("reading the ACME directory trust root {}", path.display()) + })?), + None => None, }; - tracing::info!( - variables = env.len(), - listen = %cli.http_listen, - tls = ?cli.tls, - "serving guest" - ); + let acme = Some(enclave_runtime::serve::acme::start( + &AcmeConfig { + domains: cli.tls_domains.clone(), + contacts: cli.testing.acme_contacts.clone(), + directory: cli.testing.acme_directory.clone(), + directory_ca, + prefix: mount_config.bucket_prefix.clone(), + }, + roots.clone(), + booted.keys.clone(), + )?); + tracing::info!(listen = %HTTP_LISTEN, "serving guest"); // Not a choice. An enclave nobody can verify is an enclave for nothing, and // a flag that turns verification off is a flag that can be turned off by @@ -920,31 +625,18 @@ async fn run() -> Result { // enclave exists to exclude. let attestation = Some(entropy.clone()); - // Per-client views of the one filesystem. Each client's guest sees - // its own directory as `/`; the mount, the block cache and the - // transaction stream are shared, so a client costs a directory - // rather than a mount. + // Per-client views of the one pool. Each client's guest sees its + // own dataset as `/`, through a fresh instance per request, so a + // client costs a dataset and a lock. // // A client is serialised against itself and nobody else, so two // clients can execute guest code at the same time. A guest is // written for that; it is not a deployment decision. - let tenancy = { - let limits = enclave_runtime::PoolLimits { - max_tenants: cli.max_tenants, - idle_timeout: Duration::from_secs(cli.tenant_idle_secs), - max_requests_per_instance: cli.max_requests_per_instance, - }; - tracing::info!( - max_tenants = limits.max_tenants, - "serving each client its own directory of the filesystem" - ); - Some(std::sync::Arc::new(enclave_runtime::Tenancy::new(limits))) - }; + let tenancy = Some(std::sync::Arc::new(enclave_runtime::Tenancy::new( + enclave_runtime::PoolLimits::default(), + ))); - // WebAuthn, when the deployment named a relying party. Both the - // id and the origin or neither: a gate with no origin to compare - // against would accept assertions from any page that could reach - // it. + // WebAuthn, when the deployment named a relying party. // // Empty entries are dropped, not refused: an image built with no // extra origins still sets the variable, to nothing. @@ -959,26 +651,29 @@ async fn run() -> Result { // `serve::egress`. Only the emulator widens it, to its host. open_dev_host(&cli)?; - let authentication = match (&cli.webauthn_rp_id, &cli.webauthn_origin) { - (Some(rp_id), Some(origin)) => { + let authentication = match nonempty(&cli.webauthn_rp_id) { + Some(rp_id) => { + // What a page on the relying party's own domain claims. Native apps + // claim theirs, from `--webauthn-allowed-origin`. + let origin = format!("https://{rp_id}"); let credentials = - std::sync::Arc::new(enclave_runtime::FilesystemCredentials::new(fs.clone())); + std::sync::Arc::new(enclave_runtime::FilesystemCredentials::new(zfs.clone())); let gate = std::sync::Arc::new(enclave_runtime::Gate::new( - enclave_runtime::build_relying_party(rp_id, origin, &allowed_origins)?, + enclave_runtime::build_relying_party(&rp_id, &origin, &allowed_origins)?, enclave_runtime::ChallengeStore::new( - Duration::from_secs(cli.challenge_ttl_secs), + enclave_runtime::auth::DEFAULT_TTL, enclave_runtime::DEFAULT_CAPACITY, ), credentials.clone(), enclave_runtime::TokenStore::new( - Duration::from_secs(cli.interaction_token_ttl_secs), + enclave_runtime::auth::DEFAULT_TOKEN_TTL, enclave_runtime::DEFAULT_TOKEN_CAPACITY, ), )); let auth = std::sync::Arc::new(enclave_runtime::AuthEndpoints::new( gate.clone(), credentials, - fs.clone(), + zfs.clone(), entropy.clone(), )); tracing::info!( @@ -989,20 +684,15 @@ async fn run() -> Result { ); Some((auth, gate)) } - (None, None) => { + None => { anyhow::ensure!( allowed_origins.is_empty(), - "--webauthn-allowed-origin was given without --webauthn-rp-id and \ - --webauthn-origin. With no relying party, authentication is off and \ - the origin would be silently ignored." + "--webauthn-allowed-origin was given without --webauthn-rp-id. With no \ + relying party, authentication is off and the origin would be silently \ + ignored." ); None } - _ => anyhow::bail!( - "--webauthn-rp-id and --webauthn-origin must be given together. A gate \ - with no origin to compare against would accept an assertion from any \ - page that could reach it." - ), }; // The console always, and CloudWatch alongside it when configured. @@ -1016,7 +706,7 @@ async fn run() -> Result { vec![std::sync::Arc::new(enclave_runtime::TracingLogSink)]; let mut log_forwarder = None; - if let Some(config) = cli.guest_log_config()? { + if let Some(config) = cli.guest_log_config() { // Which image is writing to this stream. A fixed stream name is // shared by every boot and every image, so without this nothing // in it says which enclave produced which line. @@ -1147,28 +837,21 @@ async fn run() -> Result { enclave_runtime::FanOutSink::new(sinks), )); - let guest = GuestEnvironment::new(fs, clock, entropy, &env, &cli.guest_args, guest_logs)?; + let guest = GuestEnvironment::new(zfs, clock, entropy, &env, &cli.guest_args, guest_logs)?; let served = serve_component( &component, guest, ServeConfig { notify, - background_tasks: cli - .background_tasks - .then_some(enclave_runtime::tasks::TaskLimits { - concurrency: cli.background_concurrency, - timeout: enclave_runtime::tasks::TASK_TIMEOUT, - max_records: cli.background_max_records, - per_tenant: cli.background_per_tenant, - }), - addr: cli.http_listen, + // Whether tasks run is the guest's to say, by exporting `run-task`. + background_tasks: Some(Default::default()), + addr: HTTP_LISTEN, certificate: None, acme, attestation, - request_timeout: Duration::from_secs(cli.request_timeout_secs), - max_interaction: Duration::from_secs(cli.max_interaction_secs), tenancy, authentication, + ..ServeConfig::default() }, ) .await; @@ -1339,69 +1022,66 @@ mod tests { use clap::CommandFactory; /// The minimum a parse needs, so a test can say only what it is about. - /// - /// `--master-key-source` is here because it has no default and never - /// should: an enclave's key handling is not something to inherit by - /// omission. Tests that care which source is chosen pass their own. fn cli_from(args: &[&str]) -> Cli { - let mut full = vec!["enclave-runtime", "--master-key-source", "static"]; + let mut full = vec!["enclave-runtime", "--roots-bucket", "b"]; full.extend_from_slice(args); Cli::try_parse_from(full).expect("parse") } - /// The S3 timeout is its own setting, and reaches the mount config. It - /// used to be hardcoded here *and* ignored by the backend, so neither half - /// of the path worked. #[test] - fn the_s3_timeout_is_configurable_and_separate_from_the_guest_one() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--s3-timeout-secs", - "9", - "--request-timeout-secs", - "45", - ]); - assert_eq!( - cli.mount_config().expect("valid").request_timeout, - Duration::from_secs(9) - ); - // Changing the guest deadline must not move the S3 one. - assert_eq!(cli.request_timeout_secs, 45); + fn the_cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn the_roots_bucket_is_required() { + assert!(Cli::try_parse_from(["enclave-runtime"]).is_err()); + assert!(Cli::try_parse_from(["enclave-runtime", "--roots-bucket", "b"]).is_ok()); + } + + /// What a production enclave runs with: every value written once, in + /// `Testing::production`, and nothing a deployment could change. + #[test] + fn a_production_build_takes_its_key_from_kms_and_its_time_from_ptp() { + let production = Testing::production(); + assert_eq!(production.master_key_source, MasterKeySourceKind::Kms); + assert!(production.master_key.is_none()); + assert_eq!(production.receipt_trust, ReceiptTrust::Required); + assert_eq!(production.clock_source, ClockSource::Ptp); + assert_eq!(production.random_source, RandomSource::Nsm); + assert!(production.endpoint.is_none() && production.access_key_id.is_none()); + assert!(production.acme_directory.is_none() && production.acme_ca.is_none()); + assert!(production.push_endpoint.is_none() && production.guest_log_endpoint.is_none()); + assert!(!production.cosign_attestations); + + // And a testing build's defaults are the same, so an emulator departs + // from production only where its image says so. + let defaults = cli_from(&[]).testing; + assert_eq!(defaults.master_key_source, production.master_key_source); + assert_eq!(defaults.receipt_trust, production.receipt_trust); + assert_eq!(defaults.clock_source, production.clock_source); + assert_eq!(defaults.random_source, production.random_source); } /// Ten years unless the image says otherwise, what it says reaches the /// mount, and under a day is refused. #[test] fn root_retention_is_ten_years_unless_the_image_says_otherwise() { - let key = "aa".repeat(32); - let cli = cli_from(&["--bucket", "b", "--master-key", &key]); assert_eq!( - cli.mount_config().expect("valid").root_retention, - s3fs_core::store::config::DEFAULT_ROOT_RETENTION + cli_from(&[]).mount_config().expect("valid").root_retention, + enclave_runtime::DEFAULT_ROOT_RETENTION ); - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &key, - "--root-retention-secs", - "86400", - ]); assert_eq!( - cli.mount_config().expect("valid").root_retention, + cli_from(&["--root-retention-secs", "86400"]) + .mount_config() + .expect("valid") + .root_retention, Duration::from_secs(86_400) ); assert!(Cli::try_parse_from([ "enclave-runtime", - "--master-key-source", - "static", - "--bucket", + "--roots-bucket", "b", - "--master-key", - &key, "--root-retention-secs", "3600", ]) @@ -1411,36 +1091,21 @@ mod tests { /// Console-only is the default, and takes no client and no credentials. #[test] fn notifications_are_off_unless_configured() { - let cli = cli_from(&["--bucket", "b", "--master-key", &"aa".repeat(32)]); - assert!(cli.notify_settings().expect("valid").is_none()); + assert!(cli_from(&[]).notify_settings().expect("valid").is_none()); } /// An image carries the setting; a deployment blanks it. Empty is off, not /// malformed — the same rule the guest log settings follow. #[test] fn an_empty_setting_turns_notifications_off() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--push-app-id", - "", - ]); + let cli = cli_from(&["--push-app-id", ""]); assert!(cli.notify_settings().expect("valid").is_none()); } /// An application id is all production names: no credential, no endpoint. #[test] fn an_application_id_is_all_a_deployment_sets() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--push-app-id", - "0123456789abcdef0123456789abcdef", - ]); + let cli = cli_from(&["--push-app-id", "0123456789abcdef0123456789abcdef"]); let settings = cli.notify_settings().expect("valid").expect("configured"); assert_eq!(settings.app_id, "0123456789abcdef0123456789abcdef"); assert!(settings.endpoint.is_none()); @@ -1449,34 +1114,21 @@ mod tests { /// It goes into a URL path, so a malformed one fails at boot. #[test] fn a_malformed_application_id_is_refused() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--push-app-id", - "../channels", - ]); - assert!(cli.notify_settings().is_err()); + assert!(cli_from(&["--push-app-id", "../channels"]) + .notify_settings() + .is_err()); } #[test] fn a_stub_without_an_application_is_refused() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--push-endpoint", - "http://127.0.0.1:9180", - ]); - assert!(cli.notify_settings().is_err()); + assert!(cli_from(&["--push-endpoint", "http://127.0.0.1:9180"]) + .notify_settings() + .is_err()); } #[test] fn guest_logging_is_console_only_unless_configured() { - let cli = cli_from(&["--bucket", "b", "--master-key", &"aa".repeat(32)]); - assert!(cli.guest_log_config().expect("valid").is_none()); + assert!(cli_from(&[]).guest_log_config().is_none()); } /// An empty setting means off, not malformed. @@ -1486,229 +1138,30 @@ mod tests { /// harness enclave refuse to boot, which is how this rule was learned. #[test] fn an_empty_setting_turns_guest_logging_off() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-log-group", - "", - "--guest-log-stream", - "", - ]); - assert!( - cli.guest_log_config() - .expect("empty is off, not an error") - .is_none(), - "an empty group should mean console-only" - ); + assert!(cli_from(&["--guest-log-group", ""]) + .guest_log_config() + .is_none()); } - /// Half a destination is a typo, and knowable without touching the network. #[test] - fn a_log_group_without_a_stream_is_refused() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-log-group", - "/enclave/guest", - ]); - let error = cli.guest_log_config().unwrap_err(); - let message = format!("{error:#}"); - assert!(message.contains("--guest-log-stream"), "{message}"); - - // And the other way round. + fn a_configured_group_is_written_to_its_guest_stream() { let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-log-stream", - "guest", - ]); - assert!(cli.guest_log_config().is_err()); - } - - #[test] - fn a_configured_destination_carries_the_settings_through() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), "--region", "eu-west-2", "--guest-log-group", "/enclave/guest", - "--guest-log-stream", - "guest", "--guest-log-endpoint", "http://127.0.0.1:4566", ]); - let config = cli.guest_log_config().expect("valid").expect("configured"); + let config = cli.guest_log_config().expect("configured"); assert_eq!(config.log_group, "/enclave/guest"); assert_eq!(config.log_stream, "guest"); assert_eq!(config.region, "eu-west-2"); assert_eq!(config.endpoint.as_deref(), Some("http://127.0.0.1:4566")); } - #[test] - fn the_cli_definition_is_valid() { - Cli::command().debug_assert(); - } - - /// There is no default guest. The image used to carry one at a fixed path; - /// it carries a key in the roots bucket instead, and a runtime given - /// neither says what is missing rather than guessing. - #[test] - fn a_guest_source_is_required() { - let cli = cli_from(&["--bucket", "b", "--master-key", &"aa".repeat(32)]); - let err = cli.guest_source().unwrap_err(); - assert!(format!("{err:#}").contains("--guest-object"), "{err:#}"); - } - - #[test] - fn a_guest_object_is_a_key_in_the_roots_bucket() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-object", - "guest/guest.wasm", - ]); - assert_eq!( - cli.guest_source().unwrap(), - GuestSource::Object { - key: "guest/guest.wasm".into() - } - ); - } - - #[test] - fn a_guest_path_is_for_local_runs() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-path", - "/tmp/other.wasm", - ]); - assert_eq!( - cli.guest_source().unwrap(), - GuestSource::Path(PathBuf::from("/tmp/other.wasm")) - ); - } - - #[test] - fn two_guest_sources_are_refused() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-object", - "guest/guest.wasm", - "--guest-path", - "/tmp/other.wasm", - ]); - assert!(cli.guest_source().is_err()); - } - - /// The image environment is layered, and "" is how a layer says "not this - /// one" — the same rule as the guest log settings. - #[test] - fn an_empty_guest_setting_counts_as_unset() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--guest-object", - "", - "--guest-path", - "/tmp/other.wasm", - ]); - assert_eq!( - cli.guest_source().unwrap(), - GuestSource::Path(PathBuf::from("/tmp/other.wasm")) - ); - } - - /// The explicit model, which is what `--no-inherit-env` leaves you with: - /// the guest gets exactly what is named and nothing else. This was - /// `s3fs-runner`'s whole reason to exist before it was deleted, so it is - /// tested here rather than assumed. - #[test] - fn named_variables_reach_the_guest_when_nothing_is_inherited() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--no-inherit-env", - "--guest-env", - "LOG_LEVEL=debug", - ]); - assert_eq!( - cli.env_policy().build().unwrap(), - vec![("LOG_LEVEL".to_string(), "debug".to_string())] - ); - } - - /// Repeatable *and* comma-separated, because inside an enclave the setting - /// arrives as one `S3FS_GUEST_ENV` string and there is nowhere to repeat a - /// flag from. - #[test] - fn guest_env_accepts_a_comma_separated_list() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--no-inherit-env", - "--guest-env", - "A=1,B=2", - ]); - let env = cli.env_policy().build().unwrap(); - assert_eq!( - env, - vec![ - ("A".to_string(), "1".to_string()), - ("B".to_string(), "2".to_string()) - ] - ); - } - - #[test] - fn the_bucket_and_key_source_are_required() { - assert!(Cli::try_parse_from(["enclave-runtime"]).is_err()); - assert!(Cli::try_parse_from(["enclave-runtime", "--bucket", "b"]).is_err()); - // A bucket without a key source is not enough: there is no default, - // and picking one would mean guessing at how the enclave gets its key. - assert!(Cli::try_parse_from([ - "enclave-runtime", - "--bucket", - "b", - "--master-key", - &"ab".repeat(32), - ]) - .is_err()); - } - - fn key_config(args: &[&str]) -> Result { - let mut full = vec!["--bucket", "b"]; - full.extend_from_slice(args); - Cli::try_parse_from(std::iter::once("enclave-runtime").chain(full.iter().copied())) - .expect("parse") - .master_key_config() - } - fn source_error(args: &[&str]) -> String { - let config = key_config(args).expect("config"); + let config = cli_from(args).master_key_config().expect("config"); let nsm = std::sync::Arc::new(nitro_nsm::fake::FakeNsm::new()); format!( "{:#}", @@ -1717,14 +1170,12 @@ mod tests { ) } - /// The refusal that matters most. A production image that still carried - /// `S3FS_MASTER_KEY` would work perfectly — quietly taking its key from + /// The refusal that matters most. An image that still carried + /// `ENCLAVE_MASTER_KEY` would work perfectly — quietly taking its key from /// the parent instance, which is the whole thing KMS release prevents. #[test] fn a_plaintext_key_is_refused_under_kms() { let err = source_error(&[ - "--master-key-source", - "kms", "--kms-key-id", "arn:aws:kms:eu-west-2:1:key/a", "--master-key-parameter", @@ -1753,103 +1204,40 @@ mod tests { #[test] fn each_source_names_the_setting_it_is_missing() { assert!(source_error(&["--master-key-source", "static"]).contains("--master-key")); - assert!(source_error(&["--master-key-source", "kms"]).contains("--kms-key-id")); - assert!(source_error(&[ - "--master-key-source", - "kms", - "--kms-key-id", - "arn:aws:kms:eu-west-2:1:key/a", - ]) - .contains("--master-key-parameter")); - } - - /// Warm clients are bounded by default. Each costs a wasm linear memory, - /// so an unbounded pool is an enclave that dies of memory exhaustion under - /// the load it was built for. - #[test] - fn the_warm_client_count_is_bounded_by_default() { - let cli = cli_from(&["--bucket", "b"]); - assert_eq!(cli.max_tenants, 64); - assert!(cli.tenant_idle_secs > 0, "idle tenants are never reclaimed"); + assert!(source_error(&[]).contains("--kms-key-id")); + assert!( + source_error(&["--kms-key-id", "arn:aws:kms:eu-west-2:1:key/a"]) + .contains("--master-key-parameter") + ); } /// The encryption context is built from the filesystem id and the - /// environment, so it is the same at mint and at open by construction. + /// environment, so it is the same at mint and at open by construction — + /// and the environment is the one every deployment always had, so keys + /// sealed before it was a constant still open. #[test] fn the_key_config_carries_the_encryption_context_inputs() { - let config = key_config(&[ - "--master-key-source", - "kms", - "--fs-id", - &"cd".repeat(16), - "--environment", - "staging", - ]) - .expect("config"); - assert_eq!(config.environment, "staging"); + let config = cli_from(&["--fs-id", &"cd".repeat(16)]) + .master_key_config() + .expect("config"); + assert_eq!(config.environment, "production"); assert_eq!(config.fs_id, [0xcd; 16]); } #[test] fn guest_arguments_come_after_a_separator() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--", - "arg1", - "--not-our-flag", - ]); + let cli = cli_from(&["--", "arg1", "--not-our-flag"]); assert_eq!(cli.guest_args, vec!["arg1", "--not-our-flag"]); } - /// The default is inheritance, because a guest that needs configuration - /// should get it without every variable being enumerated. - #[test] - fn the_environment_is_inherited_by_default() { - let cli = cli_from(&["--bucket", "b", "--master-key", &"aa".repeat(32)]); - assert!(cli.env_policy().inherit); - } - - #[test] - fn no_inherit_env_switches_to_allowlist_only() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--no-inherit-env", - "--guest-env", - "RUST_LOG=debug", - ]); - let policy = cli.env_policy(); - assert!(!policy.inherit); - assert_eq!(policy.explicit, vec!["RUST_LOG=debug"]); - } - #[test] fn an_invalid_filesystem_id_is_rejected_before_anything_is_opened() { - let cli = cli_from(&[ - "--bucket", - "b", - "--master-key", - &"aa".repeat(32), - "--fs-id", - "not-hex", - ]); - assert!(cli.mount_config().is_err()); + assert!(cli_from(&["--fs-id", "not-hex"]).mount_config().is_err()); } #[test] fn mount_config_carries_the_settings_through() { let cli = cli_from(&[ - "--bucket", - "data", - "--roots-bucket", - "roots", - "--master-key", - &"aa".repeat(32), "--bucket-prefix", "tenant", "--min-root-seq", @@ -1857,11 +1245,11 @@ mod tests { "--force-path-style", ]); let cfg = cli.mount_config().unwrap(); - assert_eq!(cfg.bucket, "data"); - assert_eq!(cfg.roots_bucket.as_deref(), Some("roots")); + assert_eq!(cfg.roots_bucket, "b"); assert_eq!(cfg.bucket_prefix, "tenant"); assert_eq!(cfg.min_root_seq, Some(42)); assert!(cfg.force_path_style); + assert_eq!(cfg.request_timeout, S3_TIMEOUT); } /// `ExitCode` is a byte. A guest exit code outside that range must not diff --git a/runtime/src/mount.rs b/runtime/src/mount.rs index e069a5a..f8f3b72 100644 --- a/runtime/src/mount.rs +++ b/runtime/src/mount.rs @@ -1,20 +1,25 @@ -//! Turning configuration into a mounted filesystem. +//! Turning configuration into a connection to the roots bucket. +//! +//! The bucket holds what must outlive any disk and that no host may rewrite: +//! the pool's anchor chain, the state-origin receipt and pair records, the +//! sealed master key's pointer, the guest, and the sealed ACME cache. The +//! state itself is on the pool — see [`crate::zfs`]. use std::sync::Arc; use std::time::Duration; +use crate::store::backend::{AwsS3Backend, AwsS3BackendConfig, Backend}; use anyhow::{Context, Result}; -use s3fs_core::backend::{AwsS3Backend, AwsS3BackendConfig, Backend}; -use s3fs_core::crypto::KeyMaterial; -use s3fs_core::{Config, Fs, MasterSecret}; -/// Everything needed to open the store. +/// How long each anchor and boot record is locked by default: ten years, per +/// the deployment contract. +pub const DEFAULT_ROOT_RETENTION: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60); + +/// Everything needed to reach the store. #[derive(Debug, Clone)] pub struct MountConfig { - /// Bucket holding the data slabs. - pub bucket: String, - /// Bucket holding the signed root records. `None` uses `bucket`. - pub roots_bucket: Option, + /// The Object-Locked bucket: anchors, boot records, guest, ACME cache. + pub roots_bucket: String, pub region: String, pub endpoint: Option, pub access_key_id: Option, @@ -22,24 +27,23 @@ pub struct MountConfig { pub session_token: Option, pub force_path_style: bool, pub bucket_prefix: String, - pub mount_path: String, /// Filesystem identifier, the key-derivation salt. pub fs_id: [u8; 16], - /// Refuse to mount a root older than this. + /// Refuse a store whose newest anchor is older than this. pub min_root_seq: Option, /// Skip the `HeadBucket` startup probe. pub skip_bucket_probe: bool, pub request_timeout: Duration, - /// How long each root record, and every record the boot writes beside - /// them, is locked against deletion (Object Lock, COMPLIANCE). Rollback - /// protection lasts exactly this long, and so does the roots bucket. + /// How long each anchor, and every record the boot writes beside them, is + /// locked against deletion (Object Lock, COMPLIANCE). Rollback protection + /// lasts exactly this long, and so does the bucket. pub root_retention: Duration, } impl MountConfig { - fn backend_config(&self, bucket: &str) -> AwsS3BackendConfig { + fn backend_config(&self) -> AwsS3BackendConfig { AwsS3BackendConfig { - bucket: bucket.to_string(), + bucket: self.roots_bucket.clone(), region: self.region.clone(), endpoint: self.endpoint.clone(), access_key_id: self.access_key_id.clone(), @@ -55,14 +59,6 @@ impl MountConfig { request_timeout: self.request_timeout, } } - - fn fs_config(&self) -> Config { - Config::builder() - .bucket_prefix(self.bucket_prefix.clone()) - .mount_path(self.mount_path.clone()) - .root_retention(Some(self.root_retention)) - .build() - } } /// Parse the 32-hex-character filesystem identifier. @@ -79,155 +75,16 @@ pub fn parse_fs_id(s: &str) -> Result<[u8; 16]> { Ok(out) } -/// A mounted filesystem, plus the pieces a runtime needs alongside it. -/// -/// `Debug` names the store rather than dumping the filesystem: an `Fs` renders -/// its whole handle table, which is noise in a boot log and unbounded in a -/// panic message. -/// -/// The ACME cache writes sealed objects to the data bucket and seals them -/// under a key derived from the same master secret, so it needs both — but it -/// deliberately does *not* go through the filesystem, because the guest's -/// preopen is the filesystem root and the blob contains a TLS private key. -pub struct Mounted { - pub fs: Arc, - pub data: Arc, - pub keys: Arc, - pub bucket_prefix: String, -} - -impl std::fmt::Debug for Mounted { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Mounted") - .field("bucket_prefix", &self.bucket_prefix) - .finish_non_exhaustive() - } -} - -/// Both backends, connected but not yet mounted. -/// -/// Separate from mounting because the boot machine has to *read* the store — -/// the state-origin receipt lives in the roots bucket — before it can decide -/// whether this is a genesis or a resume, and only after deciding does it know -/// which master secret to use. -pub struct Backends { - pub data: Arc, - pub roots: Arc, -} - -/// Connect to both buckets. -pub async fn connect(config: &MountConfig) -> Result { - let data_cfg = config.backend_config(&config.bucket); - let data: Arc = Arc::new(if config.skip_bucket_probe { - AwsS3Backend::connect_unchecked(data_cfg).await? +/// Connect to the roots bucket. +pub async fn connect(config: &MountConfig) -> Result> { + let backend = if config.skip_bucket_probe { + AwsS3Backend::connect_unchecked(config.backend_config()).await? } else { - AwsS3Backend::connect(data_cfg) + AwsS3Backend::connect(config.backend_config()) .await - .context("connecting to the data bucket")? - }); - - let roots_name = config.roots_bucket.as_deref().unwrap_or(&config.bucket); - let roots: Arc = if roots_name == config.bucket { - // One bucket for both. Simpler to operate, but the anchor and the - // reclaimable data then share a retention policy, which defeats the - // point of splitting them. - tracing::warn!( - bucket = %config.bucket, - "roots and data share a bucket; Object Lock retention cannot then \ - differ between the rollback anchor and reclaimable block storage" - ); - data.clone() - } else { - Arc::new(AwsS3Backend::connect_unchecked(config.backend_config(roots_name)).await?) + .context("connecting to the roots bucket")? }; - - Ok(Backends { data, roots }) -} - -/// Mount an existing filesystem with a secret the caller has already resolved. -pub async fn mount_existing( - backends: &Backends, - config: &MountConfig, - master: &MasterSecret, -) -> Result { - finish(backends, config, master, false).await -} - -/// Create a filesystem. Only genesis calls this. -pub async fn create( - backends: &Backends, - config: &MountConfig, - master: &MasterSecret, -) -> Result { - finish(backends, config, master, true).await -} - -async fn finish( - backends: &Backends, - config: &MountConfig, - master: &MasterSecret, - genesis: bool, -) -> Result { - let data = backends.data.clone(); - let roots = backends.roots.clone(); - - let fs_config = config.fs_config(); - - // Derived twice — once here and once inside `Fs::mount` — rather than - // reaching into the store for them. The derivation is cheap and pure, and - // threading them out would widen the store's API for one caller. - let derived = Arc::new( - KeyMaterial::derive(master, config.fs_id) - .map_err(|e| anyhow::anyhow!("deriving keys: {e}"))?, - ); - - let fs = if genesis { - Fs::create( - data.clone(), - roots, - master, - config.fs_id, - Arc::new(fs_config), - ) - .await - .map_err(|e| anyhow::anyhow!("creating filesystem: {e}"))? - } else { - Fs::mount( - data.clone(), - roots, - master, - config.fs_id, - Arc::new(fs_config), - config.min_root_seq, - ) - .await - .map_err(|e| anyhow::anyhow!("mounting filesystem: {e}"))? - }; - - // The operator's evidence of which committed state this process came up - // on. Under attestation this is what the health endpoint reports, and it - // is the number to compare against an external freshness floor. - let root = fs - .store() - .root() - .await - .map_err(|e| anyhow::anyhow!("reading root record: {e}"))?; - tracing::info!( - mode = if genesis { "genesis" } else { "resume" }, - root_seq = root.seq, - txg = root.txg, - merkle_root = %root.merkle_root(), - data_bucket = %config.bucket, - roots_bucket = %config.roots_bucket.as_deref().unwrap_or(&config.bucket), - "mounted" - ); - - Ok(Mounted { - fs, - data, - keys: derived, - bucket_prefix: config.bucket_prefix.clone(), - }) + Ok(Arc::new(backend)) } #[cfg(test)] @@ -251,8 +108,7 @@ mod tests { fn config() -> MountConfig { MountConfig { - bucket: "data".into(), - roots_bucket: None, + roots_bucket: "roots".into(), region: "us-east-1".into(), endpoint: None, access_key_id: None, @@ -260,7 +116,6 @@ mod tests { session_token: None, force_path_style: false, bucket_prefix: String::new(), - mount_path: "/".into(), fs_id: [0u8; 16], min_root_seq: None, skip_bucket_probe: true, @@ -269,32 +124,15 @@ mod tests { } } - /// The image's retention reaches the root records, not the store's ten-year default. - #[test] - fn root_records_are_locked_for_the_configured_retention() { - let cfg = config(); - assert_eq!( - cfg.fs_config().store.root_retention, - Some(cfg.root_retention) - ); - } - - #[test] - fn the_roots_bucket_defaults_to_the_data_bucket() { - let cfg = config(); - assert_eq!(cfg.roots_bucket.as_deref().unwrap_or(&cfg.bucket), "data"); - assert_eq!(cfg.backend_config("roots").bucket, "roots"); - } - /// Production sets no keys. A store client without a provider signs nothing, and that shows /// only on hardware, at the first request of the boot. #[test] fn without_keys_the_store_signs_as_the_instance_role() { let mut cfg = config(); - assert!(cfg.backend_config("data").credentials_provider.is_some()); + assert!(cfg.backend_config().credentials_provider.is_some()); cfg.access_key_id = Some("minio".into()); cfg.secret_access_key = Some("minio".into()); - assert!(cfg.backend_config("data").credentials_provider.is_none()); + assert!(cfg.backend_config().credentials_provider.is_none()); } } diff --git a/runtime/src/notify/device.rs b/runtime/src/notify/device.rs index 7cdf194..0601d9d 100644 --- a/runtime/src/notify/device.rs +++ b/runtime/src/notify/device.rs @@ -2,8 +2,8 @@ //! //! A registration token is a capability: whoever holds one can wake that device //! from anywhere, through the Firebase project it was issued for. So tokens live at -//! `/runtime/devices//`, above every tenant scope and unreachable from -//! any guest — the same placement, and the same reason, as +//! `/runtime/devices//` on the pool, outside every tenant's directory +//! and unreachable from any guest — the same placement, and the same reason, as //! [`crate::auth::credential`]. A guest enrols one and asks for a count; it //! never reads one back. //! @@ -24,15 +24,14 @@ //! is kept for ever so it can never be silently reinstated. use std::collections::BTreeMap; +use std::path::PathBuf; use std::sync::Arc; use anyhow::{ensure, Context, Result}; -use s3fs_core::{Fs, FsError, Inode, InodeKind, OpenFlags}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; -use crate::auth::credential::RUNTIME_DIR; -use crate::tenant::ensure_dir; +use crate::zfs::Zfs; /// Where every tenant's devices live, one directory each. pub const DEVICES_DIR: &str = "devices"; @@ -82,11 +81,10 @@ impl Default for DeviceLimits { /// Every tenant's devices, held in memory and backed by the filesystem. /// /// Held in memory because the forwarder needs a tenant's tokens on every wake, -/// and a `read_dir` per wake is a filesystem transaction — in production an S3 -/// round trip — in the path of a signal whose only value is being prompt. +/// and a `read_dir` per wake would be disk I/O over vsock in the path of a +/// signal whose only value is being prompt. pub struct DeviceRegistry { - fs: Arc, - dir: Arc, + dir: PathBuf, limits: DeviceLimits, devices: Mutex>>, } @@ -118,19 +116,17 @@ fn is_hex(name: &str, len: usize) -> bool { } impl DeviceRegistry { - pub async fn open(fs: Arc) -> Result> { - Self::open_with_limits(fs, DeviceLimits::default()).await + pub async fn open(zfs: Arc) -> Result> { + Self::open_with_limits(zfs, DeviceLimits::default()).await } - pub async fn open_with_limits(fs: Arc, limits: DeviceLimits) -> Result> { + pub async fn open_with_limits(zfs: Arc, limits: DeviceLimits) -> Result> { anyhow::ensure!( limits.max_devices > 0 && limits.per_tenant > 0, "device limits must be positive" ); - let runtime = ensure_dir(&fs, &fs.root(), RUNTIME_DIR).await?; - let dir = ensure_dir(&fs, &runtime, DEVICES_DIR).await?; + let dir = zfs.runtime_dir(DEVICES_DIR).await?; let registry = Arc::new(DeviceRegistry { - fs, dir, limits, devices: Mutex::new(BTreeMap::new()), @@ -146,40 +142,32 @@ impl DeviceRegistry { let mut devices: BTreeMap<[u8; 16], Vec> = BTreeMap::new(); let mut total = 0usize; - for tenant_entry in self.fs.read_dir(&self.dir).await? { + let mut tenants = tokio::fs::read_dir(&self.dir).await?; + while let Some(tenant_entry) = tenants.next_entry().await? { + let tenant_name = tenant_entry.file_name().into_string().unwrap_or_default(); ensure!( - tenant_entry.kind == InodeKind::Directory && is_hex(&tenant_entry.name, 32), - "unexpected entry {:?} under /{RUNTIME_DIR}/{DEVICES_DIR}", - tenant_entry.name + tenant_entry.file_type().await?.is_dir() && is_hex(&tenant_name, 32), + "unexpected entry {:?} under {}", + tenant_entry.file_name(), + self.dir.display() ); let mut tenant = [0u8; 16]; - hex::decode_to_slice(&tenant_entry.name, &mut tenant) + hex::decode_to_slice(&tenant_name, &mut tenant) .context("decoding a tenant directory name")?; - let tenant_dir = self - .fs - .lookup_at(&self.dir, &tenant_entry.name) - .await - .map_err(|e| anyhow::anyhow!(e))?; - for entry in self.fs.read_dir(&tenant_dir).await? { + let mut entries = tokio::fs::read_dir(tenant_entry.path()).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name().into_string().unwrap_or_default(); // A write interrupted before its rename. Nothing published it, // so nothing may read it. - if entry.name.ends_with(".tmp") { - self.fs.unlink(&tenant_dir, &entry.name).await?; + if name.ends_with(".tmp") { + tokio::fs::remove_file(entry.path()).await?; continue; } total += 1; - let path = format!( - "/{RUNTIME_DIR}/{DEVICES_DIR}/{}/{}", - tenant_entry.name, entry.name - ); - let h = self.fs.open(&path, OpenFlags::read_only()).await?; - let bytes = self.fs.pread(&h, 0, MAX_RECORD + 1).await; - // Closed before the read is unwrapped, so a decode failure does - // not leak a handle on every attempt. - self.fs.close(&h).await?; - let bytes = bytes?; + let path = entry.path(); + let bytes = tokio::fs::read(&path).await?; ensure!(bytes.len() <= MAX_RECORD, "oversized device record"); let device: StoredDevice = @@ -188,8 +176,9 @@ impl DeviceRegistry { device.version == RECORD_VERSION && device.tenant == tenant && valid_token(&device.token) - && record_name(&device.token) == entry.name, - "invalid device record at {path}" + && record_name(&device.token) == name, + "invalid device record at {}", + path.display() ); devices.entry(tenant).or_default().push(device); } @@ -236,7 +225,7 @@ impl DeviceRegistry { Ok(devices) } - /// Publish a record: temp file, committed, then renamed into place. + /// Publish a record: temp file, then renamed into place. /// /// The rename is what makes the record visible, so a crash leaves either /// the old state or the new one and never a half-written record. @@ -244,36 +233,22 @@ impl DeviceRegistry { let bytes = serde_json::to_vec(device)?; ensure!(bytes.len() <= MAX_RECORD, "device record exceeds its limit"); - let tenant_name = hex::encode(device.tenant); - let tenant_dir = ensure_dir(&self.fs, &self.dir, &tenant_name).await?; + let tenant_dir = self.dir.join(hex::encode(device.tenant)); + tokio::fs::create_dir_all(&tenant_dir).await?; let name = record_name(&device.token); - let temp = format!("{name}.tmp"); - - // Remove an unpublished write left by a cancelled host call. - match self.fs.unlink(&tenant_dir, &temp).await { - Ok(()) | Err(FsError::NotFound) => {} - Err(e) => return Err(e.into()), - } - let path = format!("/{RUNTIME_DIR}/{DEVICES_DIR}/{tenant_name}/{temp}"); - let h = self.fs.open(&path, OpenFlags::create_new()).await?; - let write = self.fs.pwrite(&h, 0, &bytes).await; - let close = self.fs.close(&h).await; - write?; - close?; // close commits the complete contents before publication - self.fs - .rename(&tenant_dir, &temp, &tenant_dir, &name) - .await?; + // Truncates an unpublished write left by a cancelled host call. + let temp = tenant_dir.join(format!("{name}.tmp")); + tokio::fs::write(&temp, &bytes).await?; + tokio::fs::rename(&temp, tenant_dir.join(name)).await?; Ok(()) } async fn remove(&self, tenant: [u8; 16], token: &str) -> Result<()> { - let tenant_name = hex::encode(tenant); - let Ok(tenant_dir) = self.fs.lookup_at(&self.dir, &tenant_name).await else { - return Ok(()); - }; - match self.fs.unlink(&tenant_dir, &record_name(token)).await { - Ok(()) | Err(FsError::NotFound) => Ok(()), - Err(e) => Err(anyhow::anyhow!(e)).context("removing a device record"), + let path = self.dir.join(hex::encode(tenant)).join(record_name(token)); + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).context("removing a device record"), } } @@ -391,8 +366,9 @@ impl DeviceRegistry { #[cfg(test)] mod tests { use super::*; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, MasterSecret}; + use crate::store::backend::memory::MemoryBackend; + use crate::store::crypto::{KeyMaterial, MasterSecret}; + use crate::zfs::{Disk, Zfs}; const ALICE: [u8; 16] = [1; 16]; const BOB: [u8; 16] = [2; 16]; @@ -401,17 +377,19 @@ mod tests { format!("{seed}{}", "x".repeat(MIN_TOKEN_LEN)) } - async fn memory() -> Arc { - let backend = Arc::new(MemoryBackend::new()); - Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([5u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("filesystem") + async fn memory() -> Arc { + Zfs::scratch().await + } + + /// Alice's directory of device records. + async fn alice_dir(fs: &Zfs) -> PathBuf { + let dir = fs + .runtime_dir(DEVICES_DIR) + .await + .unwrap() + .join(hex::encode(ALICE)); + std::fs::create_dir_all(&dir).unwrap(); + dir } async fn registry() -> Arc { @@ -588,15 +566,20 @@ mod tests { } #[tokio::test] - async fn a_published_device_record_survives_a_fresh_filesystem_mount() { + async fn a_published_device_record_survives_reopening_the_pool() { let backend = Arc::new(MemoryBackend::new()); let master = MasterSecret::from_bytes([7u8; 32]); - let fs = Fs::create( - backend.clone(), + let keys = Arc::new(KeyMaterial::derive(&master, [8u8; 16]).unwrap()); + let disk = Disk::scratch().unwrap(); + let day = std::time::Duration::from_secs(86_400); + let fs = Zfs::create( + &disk, backend.clone(), + keys.clone(), &master, - [8u8; 16], - Arc::new(Config::default()), + "", + day, + Arc::new(crate::clock::HostClock), ) .await .unwrap(); @@ -606,13 +589,15 @@ mod tests { drop(r); drop(fs); - let fs = Fs::mount( - backend.clone(), + let fs = Zfs::open( + &disk, backend, + keys, &master, - [8u8; 16], - Arc::new(Config::default()), + "", + day, None, + Arc::new(crate::clock::HostClock), ) .await .unwrap(); @@ -628,12 +613,8 @@ mod tests { let r = DeviceRegistry::open(fs.clone()).await.unwrap(); r.register(ALICE, &token("a"), 1000).await.unwrap(); - let dir = format!("/{RUNTIME_DIR}/{DEVICES_DIR}/{}", hex::encode(ALICE)); - let h = fs - .open(&format!("{dir}/orphan.tmp"), OpenFlags::create_new()) - .await - .unwrap(); - fs.close(&h).await.unwrap(); + let dir = alice_dir(&fs).await; + std::fs::write(dir.join("orphan.tmp"), b"").unwrap(); assert_eq!( DeviceRegistry::open(fs.clone()) .await @@ -647,15 +628,7 @@ mod tests { // A published record that will not decode is a different matter: it is // storage this runtime does not understand, and guessing is worse than // refusing to start. - let h = fs - .open( - &format!("{dir}/{}", record_name(&token("bad"))), - OpenFlags::create_new(), - ) - .await - .unwrap(); - fs.pwrite(&h, 0, b"not json").await.unwrap(); - fs.close(&h).await.unwrap(); + std::fs::write(dir.join(record_name(&token("bad"))), b"not json").unwrap(); assert!(DeviceRegistry::open(fs).await.is_err()); } @@ -673,16 +646,8 @@ mod tests { token: token("forged"), created_ms: 1, }; - let path = format!( - "/{RUNTIME_DIR}/{DEVICES_DIR}/{}/{}", - hex::encode(ALICE), - record_name(&forged.token) - ); - let h = fs.open(&path, OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &serde_json::to_vec(&forged).unwrap()) - .await - .unwrap(); - fs.close(&h).await.unwrap(); + let path = alice_dir(&fs).await.join(record_name(&forged.token)); + std::fs::write(path, serde_json::to_vec(&forged).unwrap()).unwrap(); assert!(DeviceRegistry::open(fs).await.is_err()); } @@ -701,16 +666,10 @@ mod tests { token: token("real"), created_ms: 1, }; - let path = format!( - "/{RUNTIME_DIR}/{DEVICES_DIR}/{}/{}", - hex::encode(ALICE), - record_name(&token("someone-else")) - ); - let h = fs.open(&path, OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &serde_json::to_vec(&device).unwrap()) + let path = alice_dir(&fs) .await - .unwrap(); - fs.close(&h).await.unwrap(); + .join(record_name(&token("someone-else"))); + std::fs::write(path, serde_json::to_vec(&device).unwrap()).unwrap(); assert!(DeviceRegistry::open(fs).await.is_err()); } @@ -726,43 +685,26 @@ mod tests { token: token("a"), created_ms: 1, }; - let dir = ensure_dir( - &fs, - &fs.lookup_at(&fs.root(), RUNTIME_DIR).await.unwrap(), - DEVICES_DIR, - ) - .await - .unwrap(); - ensure_dir(&fs, &dir, &hex::encode(ALICE)).await.unwrap(); - let path = format!( - "/{RUNTIME_DIR}/{DEVICES_DIR}/{}/{}", - hex::encode(ALICE), - record_name(&device.token) - ); - let h = fs.open(&path, OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &serde_json::to_vec(&device).unwrap()) - .await - .unwrap(); - fs.close(&h).await.unwrap(); + let path = alice_dir(&fs).await.join(record_name(&device.token)); + std::fs::write(path, serde_json::to_vec(&device).unwrap()).unwrap(); assert!(DeviceRegistry::open(fs).await.is_err()); } - /// The registry sits above every tenant scope, so no guest can name it. + /// The registry sits outside every tenant's directory, so no guest + /// preopen reaches it. #[tokio::test] - async fn device_records_live_above_every_tenant_scope() { + async fn device_records_live_outside_every_tenant_directory() { let fs = memory().await; let r = DeviceRegistry::open(fs.clone()).await.unwrap(); r.register(ALICE, &token("a"), 1).await.unwrap(); - let root = fs.root(); - let runtime = fs.lookup_at(&root, RUNTIME_DIR).await.unwrap(); - let devices = fs.lookup_at(&runtime, DEVICES_DIR).await.unwrap(); - assert!(fs.lookup_at(&devices, &hex::encode(ALICE)).await.is_ok()); - - // A tenant's scope is a sibling of `/runtime`, never an ancestor. - let tenant = crate::tenant::tenant_root_by_id(&fs, ALICE).await.unwrap(); - assert_ne!(tenant.scope.objid(), root.objid()); - assert_ne!(tenant.scope.objid(), devices.objid()); + let records = alice_dir(&fs).await; + assert!(records.join(record_name(&token("a"))).exists()); + let (tenant, _) = fs.tenant_dir(ALICE).await.unwrap(); + assert!( + !records.starts_with(&tenant), + "a tenant's directory contains its device records" + ); } } diff --git a/runtime/src/notify/mod.rs b/runtime/src/notify/mod.rs index 043bc5a..a41fe4c 100644 --- a/runtime/src/notify/mod.rs +++ b/runtime/src/notify/mod.rs @@ -16,7 +16,7 @@ //! That is not a setting. A flag would be something a deployment turns on and a //! guest then uses, at which point it stops being a property. -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -99,6 +99,9 @@ struct Wake { } /// The guest-facing half: owns the registry and the queue, owns no network. +/// Wakes held per tenant until their invocation anchors: `(category, reference)`. +type StagedWakes = HashMap<[u8; 16], Vec<(String, Option)>>; + pub struct Notifier { registry: Arc, clock: Arc, @@ -111,6 +114,12 @@ pub struct Notifier { /// A `std` mutex, never tokio's: `raise` holds it, and `raise` must contain /// nothing that can pend. inflight: Mutex>, + /// Wakes a guest raised during an invocation, held until its writes are + /// anchored. A wake announces "something changed, come look", so sending + /// one before the change is durable could wake an owner for a state a + /// crash then rewinds. Keyed by tenant, and a tenant is serialised against + /// itself, so one invocation stages and delivers before the next. + staged: Mutex, counters: Arc, } @@ -192,6 +201,40 @@ impl Notifier { Ok(()) } + /// Hold a wake until the invocation that raised it is anchored. Validated + /// now (the guest learns of a bad label at once), delivered later. + pub fn stage(&self, tenant: [u8; 16], category: &str, reference: Option<&str>) -> Result<()> { + pinpoint::check_labels(category, reference)?; + self.staged + .lock() + .expect("staged poisoned") + .entry(tenant) + .or_default() + .push((category.to_string(), reference.map(str::to_string))); + Ok(()) + } + + /// Deliver a tenant's staged wakes — call once its writes are anchored. + pub fn deliver_staged(&self, tenant: [u8; 16]) { + let held = self + .staged + .lock() + .expect("staged poisoned") + .remove(&tenant) + .unwrap_or_default(); + for (category, reference) in held { + // A dropped or capped wake is not the guest's problem now; it is no + // longer in a host call that could return the error. + let _ = self.raise(tenant, &category, reference.as_deref()); + } + } + + /// Drop a tenant's staged wakes unsent — call if the anchor failed, so a + /// wake never outlives the writes it was about. + pub fn drop_staged(&self, tenant: [u8; 16]) { + self.staged.lock().expect("staged poisoned").remove(&tenant); + } + fn release(&self, tenant: [u8; 16], category: &str) { self.inflight .lock() @@ -238,6 +281,7 @@ pub fn start( clock: clock.clone(), tx, inflight: Mutex::new(HashSet::new()), + staged: Mutex::new(HashMap::new()), counters: counters.clone(), }); let task = tokio::spawn(forward( @@ -491,8 +535,11 @@ pub fn add_to_linker(linker: &mut wasmtime::component::Linker) -> wasmtim Box::new(async move { let result = async move { let ctx = context(store.data(), false)?; + // Staged, not sent: delivered once this invocation's writes + // are anchored (see `deliver_staged`), so an owner is never + // woken for a change a crash would then undo. ctx.notifier - .raise(ctx.tenant, &category, reference.as_deref()) + .stage(ctx.tenant, &category, reference.as_deref()) } .await; Ok((result.map_err(|e| e.to_string()),)) @@ -508,8 +555,7 @@ mod tests { use super::pinpoint::testing::{Counted, Recorder, SENT}; use super::*; use crate::clock::HostClock; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, Fs, MasterSecret}; + use crate::zfs::Zfs; const ALICE: [u8; 16] = [1; 16]; const BOB: [u8; 16] = [2; 16]; @@ -526,18 +572,9 @@ mod tests { Arc, Arc, ) { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([3u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); + let fs = Zfs::scratch().await; let registry = DeviceRegistry::open(fs).await.unwrap(); - let clock = Arc::new(WallClockAdapter::new(Box::new(HostClock)).unwrap()); + let clock = Arc::new(WallClockAdapter::new(Arc::new(HostClock)).unwrap()); let recorder = Recorder::with(replies); let client = PinpointClient::new( NotifyConfig { @@ -562,22 +599,14 @@ mod tests { /// assertion about what is in flight becomes a race with that task. Holding /// the receiver and never reading it makes the queue stand still. async fn detached() -> (Arc, mpsc::Receiver) { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([4u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); + let fs = Zfs::scratch().await; let (tx, rx) = mpsc::channel::(QUEUE_CAPACITY); let notifier = Arc::new(Notifier { registry: DeviceRegistry::open(fs).await.unwrap(), - clock: Arc::new(WallClockAdapter::new(Box::new(HostClock)).unwrap()), + clock: Arc::new(WallClockAdapter::new(Arc::new(HostClock)).unwrap()), tx, inflight: Mutex::new(HashSet::new()), + staged: Mutex::new(HashMap::new()), counters: Arc::new(Counters::default()), }); (notifier, rx) @@ -631,6 +660,31 @@ mod tests { forwarder.shutdown().await; } + #[tokio::test] + async fn a_staged_wake_is_sent_only_once_delivered_and_a_dropped_one_never() { + let (notifier, mut rx) = detached().await; + + // Staged, not sent. + notifier.stage(ALICE, "task-done", Some("job")).unwrap(); + assert!( + rx.try_recv().is_err(), + "a staged wake was sent before delivery" + ); + + // Delivered once the anchor is in: now it reaches the queue. + notifier.deliver_staged(ALICE); + assert!( + rx.try_recv().is_ok(), + "a delivered wake did not reach the queue" + ); + + // A wake whose anchor failed is dropped, not sent. + notifier.stage(ALICE, "task-done", Some("job")).unwrap(); + notifier.drop_staged(ALICE); + notifier.deliver_staged(ALICE); // nothing left to deliver + assert!(rx.try_recv().is_err(), "a dropped wake was still sent"); + } + #[tokio::test] async fn repeated_wakes_for_one_category_coalesce_into_one_send() { let (notifier, mut rx) = detached().await; diff --git a/runtime/src/run.rs b/runtime/src/run.rs index c43dee3..1bec021 100644 --- a/runtime/src/run.rs +++ b/runtime/src/run.rs @@ -4,7 +4,6 @@ use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; -use s3fs_core::Fs; use wasmtime_wasi::WasiCtxBuilder; use crate::clock::{TrustedClock, WallClockAdapter}; @@ -61,11 +60,10 @@ pub fn read_component(path: &Path) -> Result> { /// environments, so both go through here rather than each building a /// `WasiCtxBuilder` and drifting. /// -/// The filesystem is shared, deliberately: `Arc` is one mounted -/// filesystem with one transaction stream, so writes from separate requests -/// land in the same store rather than racing two views of it. +/// The pool is shared, deliberately: one disk, one anchor chain. What differs +/// between two clients is only which of its directories each one is handed. pub struct GuestEnvironment { - fs: Arc, + zfs: Arc, clock: Arc, entropy: Arc, env: Vec<(String, String)>, @@ -83,18 +81,17 @@ impl GuestEnvironment { /// and it put untrusted guest text straight onto the enclave console with /// nothing marking it as untrusted. pub fn new( - fs: Arc, - clock: Box, + zfs: Arc, + clock: std::sync::Arc, entropy: Arc, env: &[(String, String)], args: &[String], logs: GuestLogs, ) -> Result { Ok(GuestEnvironment { - fs, - // One adapter shared by the guest's clock interface and the - // filesystem's "now", so `set-times` cannot disagree with - // `wall-clock`. + zfs, + // One adapter shared by the guest's clock and the runtime's own + // records, so the two cannot disagree about "now". clock: Arc::new(WallClockAdapter::new(clock)?), entropy, env: env.to_vec(), @@ -103,23 +100,26 @@ impl GuestEnvironment { }) } - pub fn fs(&self) -> &Arc { - &self.fs + pub fn zfs(&self) -> &Arc { + &self.zfs } pub fn clock(&self) -> &Arc { &self.clock } - /// Build a fresh [`State`] whose guest sees `scope` as `/`. + /// Build a fresh [`State`] whose guest sees `dir` as `/`, or no + /// filesystem at all without one. /// - /// Everything else — the filesystem, the clock, the entropy source, the - /// environment, the arguments — is shared. One mounted filesystem, one - /// block cache, one transaction stream; what differs between two clients - /// is only which directory each of them calls `/`, and that separation is - /// enforced by the resolver rather than by anything the guest does. - pub fn new_state_scoped(&self, scope: Arc) -> Result { + /// Everything else — the clock, the entropy source, the environment, the + /// arguments — is shared. Each call draws a new insecure-random seed, so + /// two guest instances do not share a hash seed. + pub fn new_state(&self, dir: Option<&Path>) -> Result { let mut wasi = WasiCtxBuilder::new(); + if let Some(dir) = dir { + use wasmtime_wasi::{DirPerms, FilePerms}; + wasi.preopened_dir(dir, "/", DirPerms::all(), FilePerms::all())?; + } // Closed, not inherited. An enclave has no console to read from, so an // inherited stdin offered a guest nothing but a handle on whatever the // parent had attached to this process. Stated rather than left to the @@ -154,20 +154,7 @@ impl GuestEnvironment { wasi.insecure_random_seed(random.insecure_seed()?); wasi.secure_random(random); - Ok(State::scoped( - wasi.build(), - self.fs.clone(), - scope, - self.clock.clone(), - )) - } - - /// Build a fresh [`State`] with this environment's capabilities. - /// - /// Each call draws a new insecure-random seed from the entropy source, so - /// two guest instances do not share a hash seed. - pub fn new_state(&self) -> Result { - self.new_state_scoped(self.fs.root()) + Ok(State::new(wasi.build())) } } diff --git a/runtime/src/serve/acme.rs b/runtime/src/serve/acme.rs index 8b88a35..64e2a89 100644 --- a/runtime/src/serve/acme.rs +++ b/runtime/src/serve/acme.rs @@ -25,13 +25,13 @@ //! //! rustls-acme hands its cache the account key and the certificate's private //! key as PEM. Both are sealed with AES-256-GCM under a key derived from the -//! master secret and written as an ordinary object, so the parent instance -//! stores ciphertext it cannot read. +//! master secret and written as an ordinary object in the roots bucket, so the +//! parent instance stores ciphertext it cannot read. //! -//! Not in the filesystem: [`crate::wasi::host_preopens`] gives the guest a -//! descriptor for the filesystem *root*, so anything kept there is readable by -//! the guest — and a guest holding the TLS private key could impersonate the -//! enclave to every client. +//! In the bucket rather than on the pool, because the certificate is wanted +//! before anything else is served — and a guest holding the TLS private key +//! could impersonate the enclave to every client, so it is kept as far from +//! guests' directories as anything the runtime holds. //! //! Caching is not an optimisation. Let's Encrypt allows five duplicate //! certificates per week; an enclave that re-issued on every boot would run @@ -40,10 +40,10 @@ use std::sync::{Arc, RwLock}; use crate::serve::tls::TlsIdentity; +use crate::store::backend::{Backend, PutBlobInput}; +use crate::store::crypto::KeyMaterial; +use crate::store::StoreError; use anyhow::{Context, Result}; -use s3fs_core::backend::{Backend, PutBlobInput}; -use s3fs_core::crypto::KeyMaterial; -use s3fs_core::FsError; /// The identity currently being served — a rustls config and the leaf that /// config presents, together. @@ -162,7 +162,7 @@ impl SealedAcmeCache { } // A first boot has no cache. Everything else is a real failure and // must not be mistaken for one. - Err(FsError::NotFound) => Ok(None), + Err(StoreError::NotFound) => Ok(None), Err(e) => { Err(anyhow::Error::msg(e.to_string())).with_context(|| format!("reading {key}")) } @@ -518,7 +518,7 @@ pub fn start( #[cfg(test)] mod tests { use super::*; - use s3fs_core::crypto::MasterSecret; + use crate::store::crypto::MasterSecret; fn keys() -> Arc { Arc::new(KeyMaterial::derive(&MasterSecret::from_bytes([3u8; 32]), [1u8; 16]).unwrap()) diff --git a/runtime/src/serve/http.rs b/runtime/src/serve/http.rs index 2097b17..a9c33b4 100644 --- a/runtime/src/serve/http.rs +++ b/runtime/src/serve/http.rs @@ -32,16 +32,16 @@ use crate::run::GuestEnvironment; use crate::serve::acme::CertificateSlot; use crate::serve::attest::{nonce_from_headers, ResponseAttestor}; use crate::serve::client::apply_tenant; -use crate::serve::pool::{LiveTenant, PoolLimits, TenantPool}; +use crate::serve::pool::{PoolLimits, TenantPool}; use crate::serve::progress::{Counting, StreamProgress}; use crate::state::State; -use crate::tenant::tenant_root_by_id; use nitro_nsm::Nsm; /// How the guest is served. #[derive(Debug)] pub struct ServeConfig { - /// Opt-in standing authorization for tenant-bound durable tasks. + /// Limits for tenant-bound durable tasks, which run when the guest exports `run-task`. + /// `None` turns them off whatever the guest exports. pub background_tasks: Option, /// Opt-in push notifications. The registry and the forwarder are opened /// here rather than by the caller, because both need the mounted @@ -152,17 +152,15 @@ const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// holds it until the process ends. const DEFAULT_MAX_INTERACTION: Duration = Duration::from_secs(300); -/// A guest instance and the store it is bound to. +/// A guest instance and the store it is bound to, for one call. /// /// The two travel together because `instantiate_async` binds a `Proxy` to one /// store: separating them would produce a handle that looks usable and /// resolves against the wrong memory. -pub struct GuestInstance { +struct GuestInstance { store: Store, proxy: wasmtime_wasi_http::p2::bindings::Proxy, - /// Shared with this store's epoch callback, which was installed once and - /// outlives every request the instance serves. Reset per call rather than - /// replaced, so the callback never holds a stale one. + /// Shared with this store's epoch callback. progress: Arc, } @@ -206,9 +204,9 @@ pub struct ServeHandle { /// /// No backends and no mounting: there is one filesystem, and a client costs a /// directory in it. What is per-client is the *view* — which directory the -/// guest calls `/` — plus a warm instance and a lock. +/// guest calls `/` — and a lock. pub struct Tenancy { - pool: TenantPool, + pool: TenantPool, } impl std::fmt::Debug for Tenancy { @@ -226,7 +224,7 @@ impl Tenancy { } } - pub fn pool(&self) -> &TenantPool { + pub fn pool(&self) -> &TenantPool { &self.pool } } @@ -301,7 +299,7 @@ impl ServeHandle { &self.guest } - /// Give each authenticated client their own filesystem and warm instance. + /// Give each authenticated client their own filesystem and lock. pub fn with_tenancy(mut self, tenancy: Arc) -> Self { self.tenancy = Some(tenancy); self @@ -368,13 +366,9 @@ impl ServeHandle { .as_ref() .context("held connections require tenants")?; let checkout = tenancy.pool.checkout(&tenant); - let mut guard = checkout.slot().tenant().clone().lock_owned().await; - if let Some(t) = guard.as_mut() { - t.instance = None; - } + let _guard = checkout.slot().lock().clone().lock_owned().await; let work = async { - let scope = crate::tenant::existing_tenant_root(self.guest.fs(), tenant).await?; - let state = self.guest.new_state_scoped(scope)?; + let state = self.tenant_state(tenant).await?; let mut store = Store::new(self.pre.engine(), state); store.set_epoch_deadline(1); store.epoch_deadline_async_yield_and_update(1); @@ -410,6 +404,20 @@ impl ServeHandle { bytes.len() <= crate::stream::MAX_MESSAGE, "reply exceeds the message ceiling" ); + // Before the reply leaves: it may say what was written. + match self.guest.zfs().anchor_tenant(tenant).await { + Ok(()) => { + if let Some(notify) = &self.notify { + notify.deliver_staged(tenant); + } + } + Err(e) => { + if let Some(notify) = &self.notify { + notify.drop_staged(tenant); + } + return Err(e); + } + } Ok(bytes) }; // The same wall clock that bounds an interaction. A guest parked in a @@ -419,8 +427,7 @@ impl ServeHandle { .context("the guest took too long to answer a message")? } - /// A fresh instance shares the tenant lock; drop its warm HTTP instance so - /// no cached database handles survive a background mutation. + /// One task run: a fresh instance, under the same tenant lock as requests. pub(crate) async fn run_background( &self, queue: &Arc, @@ -431,18 +438,14 @@ impl ServeHandle { .as_ref() .context("background tasks require tenants")?; let checkout = tenancy.pool.checkout(&task.tenant); - let Ok(mut guard) = checkout.slot().tenant().clone().try_lock_owned() else { + let Ok(_guard) = checkout.slot().lock().clone().try_lock_owned() else { return Ok(None); }; if !queue.begin(task).await? { return Ok(Some(Vec::new())); } - if let Some(tenant) = guard.as_mut() { - tenant.instance = None; - } let work = async { - let scope = crate::tenant::existing_tenant_root(self.guest.fs(), task.tenant).await?; - let state = self.guest.new_state_scoped(scope)?; + let state = self.tenant_state(task.tenant).await?; let mut store = Store::new(self.pre.engine(), state); // Yield every tick, even in CPU-only loops, so the wall deadline // can cancel both guest code and async host calls. @@ -471,6 +474,7 @@ impl ServeHandle { .await?; let bytes = result.map_err(anyhow::Error::msg)?; anyhow::ensure!(bytes.len() <= 64 * 1024, "task result exceeds 64 KiB"); + // Anchored with the outcome, by the queue's `finish`. Ok(Some(bytes)) }; tokio::time::timeout(queue.limits.timeout, work) @@ -478,6 +482,22 @@ impl ServeHandle { .context("background task deadline exceeded")? } + /// Deliver a tenant's staged wakes — for the task worker, once `finish` has + /// anchored the outcome the wake announces. + pub(crate) fn deliver_wakes(&self, tenant: [u8; 16]) { + if let Some(notify) = &self.notify { + notify.deliver_staged(tenant); + } + } + + /// Drop a tenant's staged wakes unsent — if the outcome could not be + /// anchored. + pub(crate) fn drop_wakes(&self, tenant: [u8; 16]) { + if let Some(notify) = &self.notify { + notify.drop_staged(tenant); + } + } + /// Override the response-head deadline. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; @@ -568,7 +588,7 @@ impl ServeHandle { } } - /// One request, in this client's own filesystem and warm instance. + /// One request, in this client's own filesystem and a fresh instance. /// /// The lock is held for the whole call — including the body, because /// `call_handle` does not return until the guest has finished writing it — @@ -587,44 +607,23 @@ impl ServeHandle { B: hyper::body::Body + Send + Unpin + 'static, B::Error: Into, { + let head = req.method() == hyper::Method::HEAD; // Checked out before the lock is taken, so the eviction sweep can see // this tenant is busy and leave it alone. The guard releases that mark // however the request ends. let checkout = tenancy.pool.checkout(&tenant_id); // Owned, so it can move into the task below and outlive this function. - let mut guard = checkout.slot().tenant().clone().lock_owned().await; - - if guard.is_none() { - // First use of this slot, under the lock — so two simultaneous - // first requests from one client resolve to one directory, the - // second waiting here rather than racing the first. - let opened = tenant_root_by_id(self.guest.fs(), tenant_id).await?; - tracing::info!( - tenant = %hex::encode(opened.tenant_id), - arrival = ?opened.arrival, - "tenant directory ready" - ); - *guard = Some(LiveTenant { - scope: opened.scope, - tenant_id: opened.tenant_id, - instance: None, - requests: 0, - }); - } - - let tenant = guard.as_mut().expect("just built"); - // Rebuilt when absent, and when this one has served long enough: wasm - // linear memory never shrinks, so an instance that lived forever would - // only grow. Cheap — ~24 µs, and no I/O, because there is no - // filesystem to bring up with it. - let stale = tenant.requests >= tenancy.pool.limits().max_requests_per_instance; - if tenant.instance.is_none() || stale { - tenant.instance = Some(self.instantiate(tenant.scope.clone()).await?); - tenant.requests = 0; + let guard = checkout.slot().lock().clone().lock_owned().await; + + // Under the lock, so two simultaneous first requests from one client + // resolve to one directory, the second waiting here rather than racing + // the first into a second `zfs create`. + let (dir, arrival) = self.guest.zfs().tenant_dir(tenant_id).await?; + if arrival == crate::tenant::Arrival::New { + tracing::info!(tenant = %hex::encode(tenant_id), "tenant directory created"); } - tenant.requests += 1; - let instance = tenant.instance.as_mut().expect("just built"); + let mut instance = self.instantiate(&dir).await?; instance.store.data_mut().streams = self.streams .as_ref() @@ -650,11 +649,6 @@ impl ServeHandle { tenant: tenant_id, interactive: true, }); - // Reset every request: the epoch deadline is absolute, so a reused - // store would otherwise inherit whatever the last request left. The - // same is true of the progress this call will be judged on. - instance.store.set_epoch_deadline(self.watchdog()); - instance.progress.reset(); let progress = instance.progress.clone(); let (sender, receiver) = tokio::sync::oneshot::channel(); // Wrapped before it becomes a guest resource, which is the last point @@ -671,54 +665,59 @@ impl ServeHandle { .http() .new_response_outparam(sender)?; + let zfs = self.guest.zfs().clone(); + let notify = self.notify.clone(); + let (anchored_tx, anchored_rx) = tokio::sync::oneshot::channel(); let task = tokio::task::spawn(async move { - // Moved in so the lock outlives the response head. A pooled store - // must not be handed to this tenant's next request until the call - // has finished writing its body — which is also what makes "one - // active request per tenant" true rather than "one set of headers - // at a time". Released when this future completes, fails, or is - // aborted. - let mut guard = guard; + // Moved in so the lock outlives the response head: this tenant's + // next request waits until the call has finished writing its body, + // which is what makes "one active request per tenant" true rather + // than "one set of headers at a time". The instance is moved in + // too, so it dies with the call however the call ends — finished, + // trapped or aborted. + let _guard = guard; let _checkout = checkout; - let tenant = guard.as_mut().expect("held across the call"); - // Taken out of the slot for the duration, and put back only on a - // clean finish. Held *by reference* instead — as this once did — - // and an aborted call leaves the instance where it sits: `abort` - // drops this future at the await below, so nothing after it runs, - // and the next request for this tenant re-enters a store whose - // `call_handle` was cancelled part-way through. Ownership is what - // makes "an interrupted instance is never reused" true for the - // abort path and not only for the trap path, because a dropped - // future drops what it owns. - let mut instance = tenant.instance.take().expect("built before the call"); + let mut instance = instance; let result = instance .proxy .wasi_http_incoming_handler() .call_handle(&mut instance.store, req, out) .await; - - // One client's instance, and nothing else. The filesystem is - // shared and untouched; the next caller for this client gets a - // fresh instance over the same directory. if result.is_err() { - tracing::warn!("guest trapped; this client's instance will be rebuilt"); - } else if !instance.store.data().resources_settled() { - tracing::debug!("guest left resources behind; rebuilding its instance"); - } else { - tenant.instance = Some(instance); + tracing::warn!("guest trapped; its instance dies with the call"); + } + // Still under the tenant's lock, so its next request cannot write + // before this one's writes are anchored. + let anchored = zfs.anchor_tenant(tenant_id).await; + // Any wake the guest raised rides on that anchor: delivered once the + // writes it announces are durable, dropped if they are not. + if let Some(notify) = ¬ify { + match anchored { + Ok(()) => notify.deliver_staged(tenant_id), + Err(_) => notify.drop_staged(tenant_id), + } } + let _ = anchored_tx.send(anchored); result }); - await_head(self.timeout, self.max_interaction, task, receiver, progress).await + let response = + await_head(self.timeout, self.max_interaction, task, receiver, progress).await?; + after_anchor(response, anchored_rx, head).await + } + + /// A background or message run's state: the tenant's dataset as `/`. One + /// that has gone is not brought back by work running in its name. + async fn tenant_state(&self, tenant: [u8; 16]) -> Result { + let dir = self.guest.zfs().existing_tenant_dir(tenant).await?; + self.guest.new_state(Some(&dir)) } - /// Build an instance whose guest sees `scope` as `/`. + /// Build an instance whose guest sees `dir` as `/`. /// - /// The one place a store and an instance are made, so the per-request path - /// and the per-tenant path cannot drift in what a guest is handed. - async fn instantiate(&self, scope: Arc) -> Result { - let mut store = Store::new(self.pre.engine(), self.guest.new_state_scoped(scope)?); + /// Every tenant request gets one, used for that call and dropped with it. + async fn instantiate(&self, dir: &std::path::Path) -> Result { + let mut store = Store::new(self.pre.engine(), self.guest.new_state(Some(dir))?); let progress = Arc::new(StreamProgress::new()); store.set_epoch_deadline(self.watchdog()); self.arm_watchdog(&mut store, progress.clone()); @@ -770,8 +769,9 @@ impl ServeHandle { }); } - /// The only dispatch path. A fresh store, a fresh instance, both dropped - /// when the request ends — so nothing survives but what was committed. + /// The only dispatch path without a gate. A fresh store, a fresh instance, + /// both dropped when the request ends — so nothing survives but what was + /// written to the anonymous directory, and anchored. async fn in_fresh_instance( &self, scheme: Scheme, @@ -784,9 +784,13 @@ impl ServeHandle { // Anonymous callers are one identity, so they queue behind each other. // Taken before the store is built: an instance is a wasm linear memory, // and building one per waiting request is the exhaustion this prevents. + let head = req.method() == hyper::Method::HEAD; let anonymous = self.anonymous.clone().lock_owned().await; - let mut store = Store::new(self.pre.engine(), self.guest.new_state()?); + // One shared directory, never the pool's root: that would hand an + // unauthenticated guest `/runtime` and every tenant. + let (dir, _) = self.guest.zfs().tenant_dir(ANONYMOUS).await?; + let mut store = Store::new(self.pre.engine(), self.guest.new_state(Some(&dir))?); let progress = Arc::new(StreamProgress::new()); store.set_epoch_deadline(self.watchdog()); self.arm_watchdog(&mut store, progress.clone()); @@ -795,6 +799,8 @@ impl ServeHandle { let req = store.data_mut().http().new_incoming_request(scheme, req)?; let out = store.data_mut().http().new_response_outparam(sender)?; let pre = self.pre.clone(); + let zfs = self.guest.zfs().clone(); + let (anchored_tx, anchored_rx) = tokio::sync::oneshot::channel(); // The guest runs in its own task so it can keep streaming a body after // the status line and headers have gone out. The lock goes with it, so @@ -802,13 +808,17 @@ impl ServeHandle { let task = tokio::task::spawn(async move { let _anonymous = anonymous; let proxy = pre.instantiate_async(&mut store).await?; - proxy + let result = proxy .wasi_http_incoming_handler() .call_handle(store, req, out) - .await + .await; + let _ = anchored_tx.send(zfs.anchor_tenant(ANONYMOUS).await); + result }); - await_head(self.timeout, self.max_interaction, task, receiver, progress).await + let response = + await_head(self.timeout, self.max_interaction, task, receiver, progress).await?; + after_anchor(response, anchored_rx, head).await } /// Prove the guest instantiates, before a single request depends on it. @@ -824,7 +834,7 @@ impl ServeHandle { /// which is nothing new: [`ServeHandle::in_fresh_instance`] runs it on /// every request already. pub async fn verify_instantiates(&self) -> Result<()> { - let mut store = Store::new(self.pre.engine(), self.guest.new_state()?); + let mut store = Store::new(self.pre.engine(), self.guest.new_state(None)?); // A deadline of its own, or a guest that hangs in `start` would hang // the boot rather than failing it. store.set_epoch_deadline(self.watchdog()); @@ -972,6 +982,123 @@ fn resumed(stream: &tokio_rustls::server::TlsStream, peer: SocketAddr) -> false } +/// The directory callers without a gate share. Tenant ids are drawn from the +/// NSM, so none is ever all zeroes. +const ANONYMOUS: [u8; 16] = [0; 16]; + +/// Hold a response's end until what produced it is anchored, so no client can +/// see it finish before the writes it may describe would survive the host. +/// +/// A streamed body (no `Content-Length`) streams as the guest writes it — an +/// event stream or a gRPC call stays one — and only its end, trailers +/// included, waits. A sized body is held whole: a client counting +/// `Content-Length` would otherwise have all of it, and act on it, first. +/// +/// A response that may not have a body — to a HEAD, or a 1xx, 204 or 304 — +/// is held whole too. hyper never polls a body it may not send, so streamed +/// it would be complete the moment its head went out. +// ponytail: a sized body is buffered in memory; hold back just its last frame +// if a guest ever serves large sized downloads. +async fn after_anchor( + response: hyper::Response, + anchored: tokio::sync::oneshot::Receiver>, + head: bool, +) -> Result> { + let status = response.status(); + let bodiless = head + || status.is_informational() + || status == hyper::StatusCode::NO_CONTENT + || status == hyper::StatusCode::NOT_MODIFIED; + if !bodiless + && !response + .headers() + .contains_key(hyper::header::CONTENT_LENGTH) + { + use http_body_util::BodyExt; + return Ok(response.map(|inner| { + AnchoredBody { + inner, + anchored, + phase: Phase::Streaming, + } + .boxed_unsync() + })); + } + let (parts, body) = response.into_parts(); + let body = http_body_util::BodyExt::collect(body) + .await + .map_err(|e| anyhow::anyhow!("{e:?}"))? + .to_bytes(); + anchored + .await + .context("the guest's call ended before its writes were anchored")??; + Ok(hyper::Response::from_parts(parts, full(body))) +} + +/// A streamed body whose end waits for the anchor. Anchoring that fails +/// aborts the stream rather than ending it, so no client sees a clean finish. +struct AnchoredBody { + inner: HyperOutgoingBody, + anchored: tokio::sync::oneshot::Receiver>, + phase: Phase, +} + +enum Phase { + Streaming, + /// The guest's body is done; its trailers, if any, wait with its end. + Holding(Option>), + Finished, +} + +impl hyper::body::Body for AnchoredBody { + type Data = bytes::Bytes; + type Error = wasmtime_wasi_http::p2::bindings::http::types::ErrorCode; + + fn poll_frame( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + use std::future::Future; + use std::task::Poll; + let this = self.get_mut(); + loop { + match &mut this.phase { + Phase::Streaming => match std::pin::Pin::new(&mut this.inner).poll_frame(cx) { + Poll::Ready(Some(Ok(frame))) if frame.is_data() => { + return Poll::Ready(Some(Ok(frame))) + } + Poll::Ready(Some(Ok(trailers))) => this.phase = Phase::Holding(Some(trailers)), + Poll::Ready(None) => this.phase = Phase::Holding(None), + other => return other, + }, + Phase::Holding(trailers) => { + let outcome = match std::pin::Pin::new(&mut this.anchored).poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(Ok(()))) => Ok(trailers.take()), + Poll::Ready(Ok(Err(e))) => Err(format!("{e:#}")), + Poll::Ready(Err(_)) => Err("the call ended before it was anchored".into()), + }; + this.phase = Phase::Finished; + return Poll::Ready(match outcome { + Ok(trailers) => trailers.map(Ok), + Err(why) => { + tracing::error!(error = %why, "a response's writes were not anchored; aborting it"); + Some(Err(Self::Error::InternalError(Some( + "the response's writes could not be made durable".into(), + )))) + } + }); + } + Phase::Finished => return Poll::Ready(None), + } + } + } + + fn is_end_stream(&self) -> bool { + matches!(self.phase, Phase::Finished) + } +} + /// A fixed body, in the shape the guest dispatch wants. fn full(bytes: bytes::Bytes) -> HyperOutgoingBody { use http_body_util::BodyExt; @@ -1502,13 +1629,21 @@ pub async fn serve_component( if let Some(tenancy) = &config.tenancy { handle = handle.with_tenancy(tenancy.clone()); } - if let Some(limits) = config.background_tasks.take() { + // Whether there are tasks is the guest's to say, by exporting `run-task`: the guest is + // measured into PCR16, so its exports already are the decision, and a setting beside them + // could only disagree. + let runs_tasks = handle + .background_pre + .component() + .get_export_index(None, "run-task") + .is_some(); + if let Some(limits) = config.background_tasks.take().filter(|_| runs_tasks) { anyhow::ensure!( config.authentication.is_some(), - "background tasks require authentication" + "the guest exports run-task, and background tasks require authentication" ); let queue = crate::tasks::TaskQueue::open( - handle.environment().fs().clone(), + handle.environment().zfs().clone(), handle.environment().clock().clone(), limits, ) @@ -1522,7 +1657,8 @@ pub async fn serve_component( // every send — so enabling this widens nothing. if config.tenancy.is_some() && config.authentication.is_some() { let registry = - crate::stream::StreamRegistry::open_registry(handle.environment().fs().clone()).await?; + crate::stream::StreamRegistry::open_registry(handle.environment().zfs().clone()) + .await?; handle = handle.with_streams(registry)?; } let mut notify_forwarder = None; @@ -1533,7 +1669,7 @@ pub async fn serve_component( from a verified assertion, and without a gate there is none" ); let registry = - crate::notify::DeviceRegistry::open(handle.environment().fs().clone()).await?; + crate::notify::DeviceRegistry::open(handle.environment().zfs().clone()).await?; // Plaintext only where an endpoint override asked for it, which is the // emulator harness pointing at a local stub. let plaintext = notify @@ -1588,8 +1724,8 @@ pub async fn serve_component( Some((auth, gate)) => server = server.with_authentication(auth, gate), None => tracing::warn!( "serving with NO authentication: every request reaches the guest without a \ - WebAuthn assertion. Set --webauthn-rp-id and --webauthn-origin for anything \ - that is not a development run." + WebAuthn assertion. Set --webauthn-rp-id for anything that is not a \ + development run." ), } @@ -1870,4 +2006,43 @@ mod concurrency_tests { "a failed guest kept its tenant's lock" ); } + + /// A response with no body to stream — to a HEAD, or a 204 or 304 — goes + /// out only once its writes are anchored, and not at all if they are not. + /// hyper never polls such a body, so a streamed one would end at its head. + #[tokio::test] + async fn a_bodiless_response_waits_for_its_anchor() { + let cases = [ + (hyper::StatusCode::NO_CONTENT, false), + (hyper::StatusCode::NOT_MODIFIED, false), + (hyper::StatusCode::OK, true), + ]; + for (status, head) in cases { + let response = || { + hyper::Response::builder() + .status(status) + .body(full(bytes::Bytes::new())) + .unwrap() + }; + let (anchored, anchoring) = oneshot::channel(); + let mut waiting = Box::pin(after_anchor(response(), anchoring, head)); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut waiting) + .await + .is_err(), + "{status} (head: {head}) went out before its anchor" + ); + anchored.send(Ok(())).unwrap(); + assert_eq!(waiting.await.unwrap().status(), status); + + let (anchored, anchoring) = oneshot::channel(); + anchored + .send(Err(anyhow::anyhow!("anchor failed"))) + .unwrap(); + assert!( + after_anchor(response(), anchoring, head).await.is_err(), + "{status} (head: {head}) went out though its anchor failed" + ); + } + } } diff --git a/runtime/src/serve/mod.rs b/runtime/src/serve/mod.rs index d7cdb68..772c347 100644 --- a/runtime/src/serve/mod.rs +++ b/runtime/src/serve/mod.rs @@ -28,8 +28,8 @@ pub mod tls; pub use acme::{AcmeConfig, CertificateSlot, SealedAcmeCache}; pub use client::{apply_tenant, X_ENCLAVE_TENANT}; pub use egress::Origin; -pub use http::{serve_component, GuestInstance, ServeConfig, ServeHandle, Server, Tenancy}; -pub use pool::{Checkout, LiveTenant, PoolLimits, Slot, TenantPool}; +pub use http::{serve_component, ServeConfig, ServeHandle, Server, Tenancy}; +pub use pool::{Checkout, PoolLimits, Slot, TenantPool}; pub use tls::{TlsIdentity, TlsMode}; use wasmtime_wasi_http::p2::{ diff --git a/runtime/src/serve/pool.rs b/runtime/src/serve/pool.rs index d32ecc3..e558747 100644 --- a/runtime/src/serve/pool.rs +++ b/runtime/src/serve/pool.rs @@ -1,53 +1,42 @@ -//! Live tenants: one filesystem, one warm instance and one lock per client. +//! Live tenants: one lock per client. //! -//! ## What is kept, and why that and not something else +//! ## Only the lock is kept //! -//! Mounting a client's filesystem derives key material, reads a signed root -//! record, verifies it and opens the object set — measured at 51 µs against a -//! `HashMap`, which in production is that plus two or three S3 round trips. -//! Instantiating the guest is 24 µs and no I/O at all. So **the mount is what -//! is worth keeping warm**; the instance rides along because it is nearly free -//! and it is convenient to rebuild the two together. +//! Every request, task run and message gets a fresh instance over its +//! tenant's dataset, dropped when the call ends — instantiating is 24 µs and no +//! I/O. Nothing a guest holds in memory survives a call, so there is nothing +//! to invalidate when background work changes the dataset underneath it. //! -//! ## The lock is a correctness mechanism, not only a cache +//! ## The lock is a correctness mechanism //! //! Each client has its own `tokio::Mutex`, and that is the whole concurrency //! model: **a client is serialised against itself and against nobody else.** //! //! Serialised against itself, because a cosigner reserving a nonce must not //! race its own second request. Not against anyone else, because a client's -//! filesystem is theirs alone — separate `Store`, separate transaction lock — -//! so there is nothing for two clients to contend over, and making them queue -//! would mean one client's slow commit stalling everybody. +//! dataset is theirs alone, so there is nothing for two clients to contend +//! over in it. (Anchors are serialised across everyone — see [`crate::zfs`].) //! -//! ## Why anonymous callers get nothing +//! ## Keyed by tenant id //! -//! A slot is keyed by the SHA-256 of a client's TLS public key, which the -//! handshake proves. A caller who presented no certificate has no key to be -//! identified by, so they get a fresh instance over the runtime's own -//! filesystem and never occupy a slot — otherwise anyone who could open a -//! socket could fill the pool and evict every real client's mount. +//! The id the gate resolved from a passkey assertion. Callers with no tenant +//! never get a slot; they share one lock of their own in the serving path. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use s3fs_core::Inode; - -/// How large the pool may grow, and how long an idle tenant is kept. +/// How many tenants' locks are held, and how long an idle one is kept. /// -/// Both matter more than they look. A tenant costs a `BlockStore` cache — -/// `block_cache_bytes`, 64 MiB by default — plus a wasm linear memory, so a -/// pool sized by hope rather than arithmetic is an enclave that dies of memory -/// exhaustion under exactly the load it was built for. +/// A slot is a lock and two counters, so the cap bounds a map, not memory +/// worth worrying about. It stays because registration is open: without one, +/// every passkey that ever called would keep an entry for the life of the +/// process. #[derive(Debug, Clone)] pub struct PoolLimits { pub max_tenants: usize, pub idle_timeout: Duration, - /// Calls one instance serves before it is rebuilt. Wasm linear memory - /// never shrinks, so an instance that lives forever only grows. - pub max_requests_per_instance: u64, } impl Default for PoolLimits { @@ -55,53 +44,36 @@ impl Default for PoolLimits { PoolLimits { max_tenants: 64, idle_timeout: Duration::from_secs(900), - max_requests_per_instance: 10_000, } } } -/// A client's view of the filesystem, and its warm instance. -/// -/// `scope` is the directory this client's guest calls `/`. The filesystem -/// itself is shared and is not here — one mount, one block cache, one -/// transaction stream for every tenant. -/// -/// `instance` is an `Option` so a trap can discard the poisoned linear memory -/// while keeping the resolved directory. That is cheap either way; what it -/// mainly buys is that one client's trap is visible to nobody else. -pub struct LiveTenant { - pub scope: Arc, - pub tenant_id: [u8; 16], - pub instance: Option, - pub requests: u64, -} - /// One client's slot. /// /// `last_used` and `in_flight` sit *outside* the async mutex on purpose: the /// eviction sweep has to compare tenants without awaiting on any of them, and a /// sweep that could block on a busy tenant would be a sweep that stalls the /// server it is tidying. -pub struct Slot { +pub struct Slot { /// `Arc`'d so a request can take an *owned* guard and carry it into the /// task that runs the guest — the lock has to outlive the response head, /// and a borrowed guard could not. - tenant: Arc>>>, + lock: Arc>, last_used: AtomicU64, in_flight: AtomicUsize, } -impl Slot { +impl Slot { fn new(now: u64) -> Self { Slot { - tenant: Arc::new(tokio::sync::Mutex::new(None)), + lock: Arc::new(tokio::sync::Mutex::new(())), last_used: AtomicU64::new(now), in_flight: AtomicUsize::new(0), } } - pub fn tenant(&self) -> &Arc>>> { - &self.tenant + pub fn lock(&self) -> &Arc> { + &self.lock } } @@ -109,33 +81,33 @@ impl Slot { /// /// Holding this is what marks the tenant busy, so the sweep leaves it alone; /// dropping it releases that mark however the request ended, including a panic. -pub struct Checkout { - slot: Arc>, +pub struct Checkout { + slot: Arc, } -impl Checkout { - pub fn slot(&self) -> &Arc> { +impl Checkout { + pub fn slot(&self) -> &Arc { &self.slot } } -impl Drop for Checkout { +impl Drop for Checkout { fn drop(&mut self) { self.slot.in_flight.fetch_sub(1, Ordering::Release); } } -pub struct TenantPool { +pub struct TenantPool { /// A `std::sync::Mutex`, not a `tokio` one, and never held across an /// await: everything under it is a hash lookup and an `Arc` clone. The /// *per-tenant* lock is the async one, and it is a different lock for a /// different job. - slots: Mutex>>>, + slots: Mutex>>, limits: PoolLimits, started: Instant, } -impl TenantPool { +impl TenantPool { pub fn new(limits: PoolLimits) -> Self { TenantPool { slots: Mutex::new(HashMap::new()), @@ -154,12 +126,12 @@ impl TenantPool { /// Check out this client's slot, creating an empty one if needed. /// - /// The slot, not the tenant: the caller then takes the async lock and - /// builds the filesystem under it if it is not there yet. That ordering is - /// what makes two simultaneous first requests from one client produce - /// **one** filesystem — they find the same slot, and the second waits on - /// the lock the first is holding rather than racing it into a second mount. - pub fn checkout(&self, tenant: &[u8; 16]) -> Checkout { + /// The slot, not the tenant: the caller then takes the async lock and finds + /// or creates the client's dataset under it. That ordering is what makes + /// two simultaneous first requests from one client produce **one** — they + /// find the same slot, and the second waits on the lock the first is + /// holding rather than racing it into a second `zfs create`. + pub fn checkout(&self, tenant: &[u8; 16]) -> Checkout { let now = self.now(); let mut slots = self.slots.lock().expect("tenant pool mutex poisoned"); @@ -180,11 +152,9 @@ impl TenantPool { /// Drop idle and over-cap tenants. /// - /// Removing a slot from the map does not destroy it: a request already - /// holding the `Arc` keeps working and the filesystem unmounts when it - /// finishes. So eviction can never pull a filesystem out from under a call - /// in progress — it only stops future requests finding it. - fn evict_while_full(&self, slots: &mut HashMap<[u8; 16], Arc>>, now: u64) { + /// Only slots nobody has checked out, so a lock is never dropped while a + /// call holds it; a later request for that tenant simply makes a new one. + fn evict_while_full(&self, slots: &mut HashMap<[u8; 16], Arc>, now: u64) { let idle_ms = self.limits.idle_timeout.as_millis() as u64; slots.retain(|_, slot| { slot.in_flight.load(Ordering::Acquire) > 0 @@ -202,9 +172,8 @@ impl TenantPool { slots.remove(&key); } // Every tenant is busy. Refusing to evict is right: the - // alternative is unmounting a filesystem someone is mid-commit - // on. The pool runs slightly over its cap until they finish, - // which the global concurrency limit already bounds. + // alternative is a second lock for a tenant whose first is + // held. The pool runs over its cap until they finish. None => break, } } @@ -224,11 +193,7 @@ impl TenantPool { mod tests { use super::*; - /// A stand-in for the wasm instance: the pool never looks inside one. - #[derive(Debug, PartialEq, Eq)] - struct FakeInstance(u32); - - fn pool(max: usize) -> TenantPool { + fn pool(max: usize) -> TenantPool { TenantPool::new(PoolLimits { max_tenants: max, ..Default::default() @@ -242,7 +207,7 @@ mod tests { { let first = pool.checkout(&client); - *first.slot().tenant().lock().await = None; + drop(first.slot().lock().lock().await); } let again = pool.checkout(&client); assert_eq!(pool.len(), 1, "a second request made a second slot"); @@ -269,9 +234,9 @@ mod tests { let a = pool.checkout(&[0xaa; 16]); let b = pool.checkout(&[0xbb; 16]); - let held = a.slot().tenant().lock().await; + let held = a.slot().lock().lock().await; // B proceeds while A's lock is held, and needs no timeout to do it. - let other = b.slot().tenant().lock().await; + let other = b.slot().lock().lock().await; drop(other); drop(held); } @@ -286,9 +251,9 @@ mod tests { let second = pool.checkout(&client); assert!(Arc::ptr_eq(first.slot(), second.slot())); - let held = first.slot().tenant().lock().await; + let held = first.slot().lock().lock().await; assert!( - second.slot().tenant().try_lock().is_err(), + second.slot().lock().try_lock().is_err(), "two requests from one client held the tenant at once" ); drop(held); diff --git a/runtime/src/serve/tls.rs b/runtime/src/serve/tls.rs index 567e669..37b16dc 100644 --- a/runtime/src/serve/tls.rs +++ b/runtime/src/serve/tls.rs @@ -25,7 +25,7 @@ //! entropy. //! //! What makes that argument safe to rely on is that it is checked rather than -//! assumed: an enclave image sets `S3FS_RANDOM_SOURCE=nsm`, which refuses to +//! assumed: an enclave image sets `ENCLAVE_RANDOM_SOURCE=nsm`, which refuses to //! start without a working `/dev/nsm`. If the runtime got that far, it is in //! an enclave. diff --git a/runtime/src/state.rs b/runtime/src/state.rs index f269f2c..c397657 100644 --- a/runtime/src/state.rs +++ b/runtime/src/state.rs @@ -1,17 +1,11 @@ //! The store-data type both binaries hand to wasmtime. -use std::sync::Arc; - -use crate::wasi::descriptors::Descriptor; -use crate::wasi::{S3FsCtxView, S3WasiView}; -use s3fs_core::{Fs, Inode}; use wasmtime::component::ResourceTable; use wasmtime::{Result, Store}; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; -/// Implements both `WasiView` (so `wasmtime-wasi` can serve `wasi:io`, -/// `wasi:cli`, clocks, random, sockets) and `S3WasiView` (so [`crate::wasi`] -/// can serve `wasi:filesystem`). +/// Implements `WasiView`, so `wasmtime-wasi` serves every WASI interface — +/// the filesystem included, over the directory the guest was preopened. pub struct State { pub(crate) tasks: Option, /// The connections the runtime holds for this tenant — see [`crate::stream`]. @@ -19,12 +13,6 @@ pub struct State { pub(crate) notify: Option, wasi: WasiCtx, table: ResourceTable, - fs: Arc, - /// What this guest sees as `/` — see [`S3FsCtxView::scope`]. - scope: Arc, - /// The same clock the guest sees through `wasi:clocks`, so `set-times` - /// with "now" agrees with it. - clock: Arc, http: wasmtime_wasi_http::WasiHttpCtx, /// What `wasi:http/outgoing-handler` reaches: the public internet, and the /// same for every guest — see [`crate::serve::GuestEgress`]. @@ -32,93 +20,17 @@ pub struct State { } impl State { - pub fn new(wasi: WasiCtx, fs: Arc, clock: Arc) -> Self { - let scope = fs.root(); - Self::scoped(wasi, fs, scope, clock) - } - - /// A `State` whose guest sees `scope` as `/` and can name nothing above it. - pub fn scoped( - wasi: WasiCtx, - fs: Arc, - scope: Arc, - clock: Arc, - ) -> Self { + pub fn new(wasi: WasiCtx) -> Self { State { tasks: None, streams: None, notify: None, wasi, - scope, table: ResourceTable::new(), - fs, - clock, http: wasmtime_wasi_http::WasiHttpCtx::new(), egress: crate::serve::GuestEgress, } } - - pub fn fs(&self) -> &Arc { - &self.fs - } - - /// Whether the guest left anything behind in the resource table. - /// - /// For a `State` that lives one request the table goes with it, and the - /// `Drop` below releases whatever files it still held. It matters for a - /// *pooled* instance: the host pushes an `incoming-request` and a - /// `response-outparam` per call and never removes them, so everything - /// here is reclaimed by the guest dropping its handles. A guest that does - /// not is not leaking unboundedly — the table is a slab with a free list - /// — but it is leaving entries a later request from the same client could - /// still address. - pub fn resources_settled(&self) -> bool { - self.table.is_empty() - } -} - -/// A file the guest opened and never dropped is held open by the filesystem's -/// own handle table, not only by this one: dropping the resource table on a -/// trap, an abort or an eviction releases the guest's reference and nothing -/// else. Every discard path ends here, so this is the one place to let go. -/// -/// Abandoned, not closed: a close flushes, and `Drop` cannot wait for one. A -/// flush started here would land whenever it was scheduled — after the -/// tenant's lock had passed to its next request, and possibly on top of what -/// that request committed to the same file. So a guest that did not finish -/// loses what it had not synced, as it would on a crash. Abandoning can still -/// free an unlinked file, which is async, hence the spawn; without a runtime -/// there is nothing to spawn on, and the handles stay open until the process -/// does, which is what would have happened anyway. -impl Drop for State { - fn drop(&mut self) { - let handles: Vec<_> = self - .table - .iter_mut() - .filter_map(|entry| match entry.downcast_ref::() { - Some(Descriptor::File { handle, .. }) => Some(handle.clone()), - _ => None, - }) - .collect(); - if handles.is_empty() { - return; - } - let Ok(rt) = tokio::runtime::Handle::try_current() else { - tracing::warn!( - open = handles.len(), - "guest files left open with no runtime to close them" - ); - return; - }; - let fs = self.fs.clone(); - rt.spawn(async move { - for h in handles { - if let Err(e) = fs.abandon(&h).await { - tracing::warn!(error = %e, "releasing a file a discarded guest left open"); - } - } - }); - } } impl WasiView for State { @@ -130,25 +42,9 @@ impl WasiView for State { } } -impl S3WasiView for State { - fn s3fs_view(&mut self) -> S3FsCtxView<'_> { - // The SAME `ResourceTable` instance `wasmtime-wasi` uses. Stream - // resources this crate pushes are resolved by wasmtime-wasi's stream - // methods, so two tables would produce handles that look valid and - // resolve to nothing. - S3FsCtxView { - fs: &self.fs, - scope: &self.scope, - table: &mut self.table, - clock: &self.clock, - } - } -} - /// `wasi:http` needs its own context and its own hooks, projected out of the /// same `ResourceTable` as everything else — a second table would hand the -/// guest stream handles that look valid and resolve to nothing, the same trap -/// documented on [`S3WasiView`]. +/// guest stream handles that look valid and resolve to nothing. impl wasmtime_wasi_http::p2::WasiHttpView for State { fn http(&mut self) -> wasmtime_wasi_http::p2::WasiHttpCtxView<'_> { wasmtime_wasi_http::p2::WasiHttpCtxView { @@ -164,74 +60,3 @@ impl wasmtime_wasi_http::p2::WasiHttpView for State { pub fn new_store(engine: &wasmtime::Engine, state: State) -> Result> { Ok(Store::new(engine, state)) } - -#[cfg(test)] -mod tests { - use super::*; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, MasterSecret, OpenFlags}; - - /// A trapped guest's files are released by the runtime, not kept open - /// forever by the filesystem's handle table — and released *unflushed*. - /// The release runs after the tenant's next request may already have - /// committed, so a flush would land on top of that commit and undo it. - #[tokio::test] - async fn dropping_a_state_releases_its_files_without_flushing_them() { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([3; 32]), - [4; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); - let clock = Arc::new( - crate::clock::WallClockAdapter::new(Box::new(crate::clock::HostClock)).unwrap(), - ); - let mut state = State::new( - wasmtime_wasi::WasiCtxBuilder::new().build(), - fs.clone(), - clock, - ); - - let handle = fs - .open("/left-open", OpenFlags::create_new()) - .await - .unwrap(); - fs.pwrite(&handle, 0, b"OLD").await.unwrap(); - let id = handle.id; - state - .table - .push(Descriptor::File { - parent: fs.root(), - handle, - }) - .unwrap(); - drop(state); - - // The tenant's next request, which the lock lets in as soon as the - // dropped guest's call is over. - let next = fs - .open("/left-open", OpenFlags::read_write()) - .await - .unwrap(); - fs.pwrite(&next, 0, b"NEW").await.unwrap(); - fs.close(&next).await.unwrap(); - - for _ in 0..100 { - if fs.get_handle(id).is_none() { - break; - } - tokio::task::yield_now().await; - } - assert!(fs.get_handle(id).is_none(), "the handle is still open"); - let h = fs.open("/left-open", OpenFlags::read_only()).await.unwrap(); - assert_eq!( - fs.pread(&h, 0, 16).await.unwrap().as_ref(), - b"NEW", - "the dead guest's buffer landed on the next request's commit" - ); - } -} diff --git a/crates/s3fs-core/src/backend/aws.rs b/runtime/src/store/backend/aws.rs similarity index 93% rename from crates/s3fs-core/src/backend/aws.rs rename to runtime/src/store/backend/aws.rs index 7d05732..d6bae16 100644 --- a/crates/s3fs-core/src/backend/aws.rs +++ b/runtime/src/store/backend/aws.rs @@ -31,7 +31,7 @@ use super::{ ListBlobsInput, ListBlobsOutput, MultipartId, ObjectLock, ObjectLockMode, PartUploadOutput, PutBlobInput, }; -use crate::errors::{FsError, FsResult}; +use crate::store::error::{StoreError, StoreResult}; /// Apply Object Lock retention headers to a `PutObject` request. /// @@ -104,7 +104,7 @@ impl AwsS3Backend { /// Build a client from the given config and run a sanity check /// (`HeadBucket`) before returning. Use `connect_unchecked` to skip the /// startup check. - pub async fn connect(config: AwsS3BackendConfig) -> FsResult { + pub async fn connect(config: AwsS3BackendConfig) -> StoreResult { let backend = Self::connect_unchecked(config).await?; // HeadBucket as a startup probe; surfaces creds / bucket-not-found // errors at construction time rather than on the first op. @@ -119,7 +119,7 @@ impl AwsS3Backend { } /// Build a client without running the `HeadBucket` startup probe. - pub async fn connect_unchecked(config: AwsS3BackendConfig) -> FsResult { + pub async fn connect_unchecked(config: AwsS3BackendConfig) -> StoreResult { let mut conf_builder = aws_sdk_s3::Config::builder() .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest()) .region(Region::new(config.region.clone())) @@ -156,7 +156,7 @@ impl AwsS3Backend { sak, config.session_token.clone(), None, // expiry — None = static (no auto-refresh) - "s3fs-static", + "enclave-static", ); conf_builder = conf_builder.credentials_provider(creds); } @@ -195,7 +195,7 @@ impl Backend for AwsS3Backend { } } - async fn head_blob(&self, key: &str) -> FsResult { + async fn head_blob(&self, key: &str) -> StoreResult { let resp = self .client .head_object() @@ -207,7 +207,7 @@ impl Backend for AwsS3Backend { SdkError::ServiceError(svc) if matches!(svc.err(), HeadObjectError::NotFound(_)) => { - FsError::NotFound + StoreError::NotFound } _ => map_sdk_error("HeadObject", e), })?; @@ -227,7 +227,7 @@ impl Backend for AwsS3Backend { }) } - async fn get_blob(&self, key: &str, range: Option>) -> FsResult { + async fn get_blob(&self, key: &str, range: Option>) -> StoreResult { let mut req = self.client.get_object().bucket(&self.bucket).key(key); if let Some(r) = &range { // S3 Range header is inclusive on both ends. @@ -252,7 +252,7 @@ impl Backend for AwsS3Backend { .body .collect() .await - .map_err(|e| FsError::Io(format!("GetObject body: {e}")))? + .map_err(|e| StoreError::Io(format!("GetObject body: {e}")))? .into_bytes(); let size = body.len() as u64; @@ -270,7 +270,7 @@ impl Backend for AwsS3Backend { }) } - async fn get_retained_blob(&self, key: &str) -> FsResult { + async fn get_retained_blob(&self, key: &str) -> StoreResult { // `ListObjectVersions` still reports a version that a delete marker is // hiding, so this is what tells "never written" from "hidden". The // prefix is not an exact match, hence the `Key == key` filter. @@ -306,7 +306,7 @@ impl Backend for AwsS3Backend { key_marker = listed.next_key_marker; version_id_marker = listed.next_version_id_marker; if key_marker.is_none() { - return Err(FsError::Io( + return Err(StoreError::Io( "ListObjectVersions: truncated without a continuation marker".into(), )); } @@ -321,7 +321,7 @@ impl Backend for AwsS3Backend { .into_iter() .rev() .find_map(|v| v.version_id) - .ok_or(FsError::NotFound)?; + .ok_or(StoreError::NotFound)?; let resp = self .client @@ -345,7 +345,7 @@ impl Backend for AwsS3Backend { .body .collect() .await - .map_err(|e| FsError::Io(format!("GetObject(versionId) body: {e}")))? + .map_err(|e| StoreError::Io(format!("GetObject(versionId) body: {e}")))? .into_bytes(); let size = body.len() as u64; @@ -363,7 +363,7 @@ impl Backend for AwsS3Backend { }) } - async fn put_blob(&self, input: PutBlobInput) -> FsResult { + async fn put_blob(&self, input: PutBlobInput) -> StoreResult { let key = input.key.clone(); let body = ByteStream::from(input.body.to_vec()); let mut req = self @@ -398,7 +398,7 @@ impl Backend for AwsS3Backend { }) } - async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> FsResult { + async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> StoreResult { let key = input.key.clone(); let body = ByteStream::from(input.body.to_vec()); let mut req = self @@ -422,7 +422,7 @@ impl Backend for AwsS3Backend { let resp = req.send().await.map_err(|e| { // S3 returns 412 PreconditionFailed when If-None-Match: * fires. match service_code(&e) { - Some("PreconditionFailed") => FsError::AlreadyExists, + Some("PreconditionFailed") => StoreError::AlreadyExists, _ => map_sdk_error("PutObject(if-none-match)", e), } })?; @@ -437,7 +437,7 @@ impl Backend for AwsS3Backend { }) } - async fn delete_blob(&self, key: &str) -> FsResult<()> { + async fn delete_blob(&self, key: &str) -> StoreResult<()> { self.client .delete_object() .bucket(&self.bucket) @@ -448,7 +448,7 @@ impl Backend for AwsS3Backend { Ok(()) } - async fn delete_blobs(&self, keys: &[String]) -> FsResult<()> { + async fn delete_blobs(&self, keys: &[String]) -> StoreResult<()> { if keys.is_empty() { return Ok(()); } @@ -479,7 +479,7 @@ impl Backend for AwsS3Backend { Ok(()) } - async fn list_blobs(&self, input: ListBlobsInput<'_>) -> FsResult { + async fn list_blobs(&self, input: ListBlobsInput<'_>) -> StoreResult { let mut req = self .client .list_objects_v2() @@ -535,7 +535,7 @@ impl Backend for AwsS3Backend { }) } - async fn copy_blob(&self, input: CopyBlobInput) -> FsResult { + async fn copy_blob(&self, input: CopyBlobInput) -> StoreResult { let copy_source = format!("{}/{}", self.bucket, input.source_key); let mut req = self .client @@ -572,7 +572,7 @@ impl Backend for AwsS3Backend { }) } - async fn multipart_begin(&self, input: PutBlobInput) -> FsResult { + async fn multipart_begin(&self, input: PutBlobInput) -> StoreResult { let mut req = self .client .create_multipart_upload() @@ -601,7 +601,7 @@ impl Backend for AwsS3Backend { .map_err(|e| map_sdk_error("CreateMultipartUpload", e))?; let upload_id = resp .upload_id - .ok_or_else(|| FsError::Io("CreateMultipartUpload returned no upload_id".into()))?; + .ok_or_else(|| StoreError::Io("CreateMultipartUpload returned no upload_id".into()))?; Ok(MultipartId(upload_id)) } @@ -611,7 +611,7 @@ impl Backend for AwsS3Backend { upload_id: &MultipartId, part_number: u32, body: Bytes, - ) -> FsResult { + ) -> StoreResult { let resp = self .client .upload_part() @@ -635,7 +635,7 @@ impl Backend for AwsS3Backend { part_number: u32, source_key: &str, source_range: Range, - ) -> FsResult { + ) -> StoreResult { let copy_source = format!("{}/{}", self.bucket, source_key); // Inclusive end per S3. let copy_source_range = format!("bytes={}-{}", source_range.start, source_range.end - 1); @@ -664,7 +664,7 @@ impl Backend for AwsS3Backend { key: &str, upload_id: &MultipartId, parts: Vec, - ) -> FsResult { + ) -> StoreResult { let sdk_parts: Vec<_> = parts .iter() .map(|p| { @@ -698,7 +698,7 @@ impl Backend for AwsS3Backend { }) } - async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> FsResult<()> { + async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> StoreResult<()> { self.client .abort_multipart_upload() .bucket(&self.bucket) @@ -736,27 +736,27 @@ where /// Generic mapper: examines the service code and falls back to a category /// based on the SDK error variant. -fn map_sdk_error(_op: &'static str, e: SdkError) -> FsError +fn map_sdk_error(_op: &'static str, e: SdkError) -> StoreError where E: ProvideErrorMetadata + std::fmt::Debug, R: std::fmt::Debug, { if let Some(code) = service_code(&e) { return match code { - "NoSuchKey" | "NoSuchUpload" | "NoSuchBucket" | "NotFound" => FsError::NotFound, - "AccessDenied" | "Forbidden" => FsError::AccessDenied, - "PreconditionFailed" => FsError::Conflict, - "BucketAlreadyExists" | "BucketAlreadyOwnedByYou" => FsError::AlreadyExists, - "EntityTooLarge" | "EntityTooSmall" => FsError::Invalid("entity size"), - "RequestTimeout" | "SlowDown" | "RequestLimitExceeded" => FsError::IoTimeout, - _ => FsError::Io(format!("{e:?}")), + "NoSuchKey" | "NoSuchUpload" | "NoSuchBucket" | "NotFound" => StoreError::NotFound, + "AccessDenied" | "Forbidden" => StoreError::AccessDenied, + "PreconditionFailed" => StoreError::Conflict, + "BucketAlreadyExists" | "BucketAlreadyOwnedByYou" => StoreError::AlreadyExists, + "EntityTooLarge" | "EntityTooSmall" => StoreError::Invalid("entity size"), + "RequestTimeout" | "SlowDown" | "RequestLimitExceeded" => StoreError::IoTimeout, + _ => StoreError::Io(format!("{e:?}")), }; } match &e { - SdkError::TimeoutError(_) => FsError::IoTimeout, - SdkError::DispatchFailure(_) => FsError::Network(format!("{e:?}")), - SdkError::ResponseError(_) => FsError::Io(format!("{e:?}")), - _ => FsError::Io(format!("{e:?}")), + SdkError::TimeoutError(_) => StoreError::IoTimeout, + SdkError::DispatchFailure(_) => StoreError::Network(format!("{e:?}")), + SdkError::ResponseError(_) => StoreError::Io(format!("{e:?}")), + _ => StoreError::Io(format!("{e:?}")), } } diff --git a/crates/s3fs-core/src/backend/memory.rs b/runtime/src/store/backend/memory.rs similarity index 91% rename from crates/s3fs-core/src/backend/memory.rs rename to runtime/src/store/backend/memory.rs index e8dbe36..207ecab 100644 --- a/crates/s3fs-core/src/backend/memory.rs +++ b/runtime/src/store/backend/memory.rs @@ -40,7 +40,7 @@ use super::{ Backend, BlobItem, BlobMeta, Capabilities, CompletedPart, CopyBlobInput, GetBlobOutput, ListBlobsInput, ListBlobsOutput, MultipartId, ObjectLock, PartUploadOutput, PutBlobInput, }; -use crate::errors::{FsError, FsResult}; +use crate::store::error::{StoreError, StoreResult}; #[derive(Debug, Clone)] struct StoredBlob { @@ -147,7 +147,7 @@ pub struct MemoryBackend { next_etag: Arc, /// Test-helper: an error the next PUT, conditional or not, returns /// instead of storing. - fail_next_put: Arc>>, + fail_next_put: Arc>>, } impl Default for MemoryBackend { @@ -167,7 +167,7 @@ impl MemoryBackend { } /// Test-helper: make the next PUT fail with `e`, storing nothing. - pub fn fail_next_put(&self, e: FsError) { + pub fn fail_next_put(&self, e: StoreError) { *self.fail_next_put.write() = Some(e); } @@ -223,15 +223,15 @@ impl Backend for MemoryBackend { } } - async fn head_blob(&self, key: &str) -> FsResult { + async fn head_blob(&self, key: &str) -> StoreResult { let g = self.state.read(); - let b = g.current(key).ok_or(FsError::NotFound)?; + let b = g.current(key).ok_or(StoreError::NotFound)?; Ok(self.meta_from_stored(key, b)) } - async fn get_blob(&self, key: &str, range: Option>) -> FsResult { + async fn get_blob(&self, key: &str, range: Option>) -> StoreResult { let g = self.state.read(); - let b = g.current(key).ok_or(FsError::NotFound)?; + let b = g.current(key).ok_or(StoreError::NotFound)?; let meta = self.meta_from_stored(key, b); let body = match range { None => b.body.clone(), @@ -239,7 +239,7 @@ impl Backend for MemoryBackend { let start = r.start.min(b.body.len() as u64) as usize; let end = r.end.min(b.body.len() as u64) as usize; if start > end { - return Err(FsError::Invalid("range start > end")); + return Err(StoreError::Invalid("range start > end")); } b.body.slice(start..end) } @@ -248,16 +248,16 @@ impl Backend for MemoryBackend { } /// Reads straight through a delete marker, which is the whole point. - async fn get_retained_blob(&self, key: &str) -> FsResult { + async fn get_retained_blob(&self, key: &str) -> StoreResult { let g = self.state.read(); - let b = g.retained(key).ok_or(FsError::NotFound)?; + let b = g.retained(key).ok_or(StoreError::NotFound)?; Ok(GetBlobOutput { meta: self.meta_from_stored(key, b), body: b.body.clone(), }) } - async fn put_blob(&self, input: PutBlobInput) -> FsResult { + async fn put_blob(&self, input: PutBlobInput) -> StoreResult { if let Some(e) = self.fail_next_put.write().take() { return Err(e); } @@ -276,13 +276,13 @@ impl Backend for MemoryBackend { // This fake models a non-versioned bucket, so an overwrite replaces // the retained version rather than adding a new one. Refuse it. if g.objects.get(&key).is_some_and(|b| b.is_retained(now)) { - return Err(FsError::AccessDenied); + return Err(StoreError::AccessDenied); } g.objects.insert(key.clone(), stored.clone()); Ok(self.meta_from_stored(&key, &stored)) } - async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> FsResult { + async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> StoreResult { if let Some(e) = self.fail_next_put.write().take() { return Err(e); } @@ -293,7 +293,7 @@ impl Backend for MemoryBackend { // the first ever to write here. That is the attack this fake now // permits so it can be tested. if g.current(&input.key).is_some() { - return Err(FsError::AlreadyExists); + return Err(StoreError::AlreadyExists); } let now = SystemTime::now(); g.pin_before_overwrite(&input.key, now); @@ -312,14 +312,14 @@ impl Backend for MemoryBackend { Ok(self.meta_from_stored(&key, &stored)) } - async fn delete_blob(&self, key: &str) -> FsResult<()> { + async fn delete_blob(&self, key: &str) -> StoreResult<()> { let now = SystemTime::now(); let mut g = self.state.write(); delete_one(&mut g, key, now); Ok(()) } - async fn delete_blobs(&self, keys: &[String]) -> FsResult<()> { + async fn delete_blobs(&self, keys: &[String]) -> StoreResult<()> { let now = SystemTime::now(); let mut g = self.state.write(); for k in keys { @@ -328,7 +328,7 @@ impl Backend for MemoryBackend { Ok(()) } - async fn list_blobs(&self, input: ListBlobsInput<'_>) -> FsResult { + async fn list_blobs(&self, input: ListBlobsInput<'_>) -> StoreResult { let g = self.state.read(); // `ListObjectsV2` lists current versions, so a marked key is absent // from it too. `ListObjectVersions` is the call that still sees them, @@ -399,12 +399,12 @@ impl Backend for MemoryBackend { }) } - async fn copy_blob(&self, input: CopyBlobInput) -> FsResult { + async fn copy_blob(&self, input: CopyBlobInput) -> StoreResult { let mut g = self.state.write(); let src = g .objects .get(&input.source_key) - .ok_or(FsError::NotFound)? + .ok_or(StoreError::NotFound)? .clone(); let now = SystemTime::now(); @@ -412,7 +412,7 @@ impl Backend for MemoryBackend { .get(&input.destination_key) .is_some_and(|b| b.is_retained(now)) { - return Err(FsError::AccessDenied); + return Err(StoreError::AccessDenied); } let metadata = input.replace_metadata.unwrap_or(src.metadata); @@ -433,7 +433,7 @@ impl Backend for MemoryBackend { Ok(self.meta_from_stored(&input.destination_key, &stored)) } - async fn multipart_begin(&self, input: PutBlobInput) -> FsResult { + async fn multipart_begin(&self, input: PutBlobInput) -> StoreResult { let n = self.next_mpu_id.fetch_add(1, Ordering::Relaxed); let id = MultipartId(format!("mpu-{n}")); let object_lock = input.object_lock; @@ -454,18 +454,18 @@ impl Backend for MemoryBackend { upload_id: &MultipartId, part_number: u32, body: Bytes, - ) -> FsResult { + ) -> StoreResult { if part_number == 0 || part_number > 10_000 { - return Err(FsError::Invalid("part_number out of range")); + return Err(StoreError::Invalid("part_number out of range")); } let e_tag = self.make_etag(&body); let mut g = self.state.write(); let mpu = g .mpus .get_mut(upload_id) - .ok_or_else(|| FsError::Io(format!("unknown upload_id: {upload_id:?}")))?; + .ok_or_else(|| StoreError::Io(format!("unknown upload_id: {upload_id:?}")))?; if mpu.key != key { - return Err(FsError::Invalid("upload_id key mismatch")); + return Err(StoreError::Invalid("upload_id key mismatch")); } mpu.parts.insert( part_number, @@ -484,25 +484,29 @@ impl Backend for MemoryBackend { part_number: u32, source_key: &str, source_range: Range, - ) -> FsResult { + ) -> StoreResult { if part_number == 0 || part_number > 10_000 { - return Err(FsError::Invalid("part_number out of range")); + return Err(StoreError::Invalid("part_number out of range")); } let mut g = self.state.write(); - let src = g.objects.get(source_key).ok_or(FsError::NotFound)?.clone(); + let src = g + .objects + .get(source_key) + .ok_or(StoreError::NotFound)? + .clone(); let start = source_range.start as usize; let end = source_range.end as usize; if end > src.body.len() || start > end { - return Err(FsError::Invalid("source_range out of bounds")); + return Err(StoreError::Invalid("source_range out of bounds")); } let body = src.body.slice(start..end); let e_tag = self.make_etag(&body); let mpu = g .mpus .get_mut(upload_id) - .ok_or_else(|| FsError::Io(format!("unknown upload_id: {upload_id:?}")))?; + .ok_or_else(|| StoreError::Io(format!("unknown upload_id: {upload_id:?}")))?; if mpu.key != key { - return Err(FsError::Invalid("upload_id key mismatch")); + return Err(StoreError::Invalid("upload_id key mismatch")); } mpu.parts.insert( part_number, @@ -519,16 +523,16 @@ impl Backend for MemoryBackend { key: &str, upload_id: &MultipartId, parts: Vec, - ) -> FsResult { + ) -> StoreResult { let mut g = self.state.write(); let mpu = g .mpus .remove(upload_id) - .ok_or_else(|| FsError::Io(format!("unknown upload_id: {upload_id:?}")))?; + .ok_or_else(|| StoreError::Io(format!("unknown upload_id: {upload_id:?}")))?; if mpu.key != key { // Put it back; this is a programmer error, not a state mutation. g.mpus.insert(upload_id.clone(), mpu); - return Err(FsError::Invalid("upload_id key mismatch")); + return Err(StoreError::Invalid("upload_id key mismatch")); } // Validate parts in ascending order with matching ETags, assemble body. @@ -539,9 +543,9 @@ impl Backend for MemoryBackend { let part = mpu .parts .get(&cp.part_number) - .ok_or(FsError::Invalid("missing part on complete"))?; + .ok_or(StoreError::Invalid("missing part on complete"))?; if part.e_tag != cp.e_tag { - return Err(FsError::Invalid("etag mismatch on complete")); + return Err(StoreError::Invalid("etag mismatch on complete")); } body_acc.extend_from_slice(&part.body); } @@ -550,7 +554,7 @@ impl Backend for MemoryBackend { let e_tag = self.make_etag(&body); let now = SystemTime::now(); if g.objects.get(key).is_some_and(|b| b.is_retained(now)) { - return Err(FsError::AccessDenied); + return Err(StoreError::AccessDenied); } let stored = StoredBlob { body, @@ -564,11 +568,11 @@ impl Backend for MemoryBackend { Ok(self.meta_from_stored(key, &stored)) } - async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> FsResult<()> { + async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> StoreResult<()> { let mut g = self.state.write(); match g.mpus.get(upload_id) { None => Ok(()), // idempotent abort, like S3 - Some(mpu) if mpu.key != key => Err(FsError::Invalid("upload_id key mismatch")), + Some(mpu) if mpu.key != key => Err(StoreError::Invalid("upload_id key mismatch")), Some(_) => { g.mpus.remove(upload_id); Ok(()) @@ -607,7 +611,10 @@ mod tests { #[tokio::test] async fn head_missing_returns_not_found() { let b = MemoryBackend::new(); - assert!(matches!(b.head_blob("nope").await, Err(FsError::NotFound))); + assert!(matches!( + b.head_blob("nope").await, + Err(StoreError::NotFound) + )); } #[tokio::test] @@ -639,7 +646,7 @@ mod tests { b.put_blob_if_not_exists(body()).await.unwrap(); assert!(matches!( b.put_blob_if_not_exists(body()).await, - Err(FsError::AlreadyExists) + Err(StoreError::AlreadyExists) )); } @@ -896,7 +903,7 @@ mod tests { b.multipart_abort("k", &id).await.unwrap(); assert_eq!(b.mpu_count(), 0); // No object was ever committed. - assert!(matches!(b.head_blob("k").await, Err(FsError::NotFound))); + assert!(matches!(b.head_blob("k").await, Err(StoreError::NotFound))); } #[tokio::test] @@ -946,11 +953,11 @@ mod tests { // Hidden from everything that reads the current version. assert!(matches!( b.get_blob("roots/0001", None).await, - Err(FsError::NotFound) + Err(StoreError::NotFound) )); assert!(matches!( b.head_blob("roots/0001").await, - Err(FsError::NotFound) + Err(StoreError::NotFound) )); let listed = b .list_blobs(ListBlobsInput { @@ -982,7 +989,7 @@ mod tests { assert!(matches!( b.put_blob_if_not_exists(locked("origin/receipt", b"second", 3600)) .await, - Err(FsError::AlreadyExists) + Err(StoreError::AlreadyExists) )); b.delete_blob("origin/receipt").await.unwrap(); @@ -1005,7 +1012,7 @@ mod tests { Bytes::from_static(b"forged") )) .await, - Err(FsError::AccessDenied) + Err(StoreError::AccessDenied) )); assert!(matches!( b.copy_blob(CopyBlobInput { @@ -1015,7 +1022,7 @@ mod tests { replace_content_type: None, }) .await, - Err(FsError::AccessDenied) + Err(StoreError::AccessDenied) )); assert_eq!( b.get_blob("roots/0001", None).await.unwrap().body, @@ -1041,7 +1048,7 @@ mod tests { b.delete_blob("roots/0001").await.unwrap(); assert!(matches!( b.head_blob("roots/0001").await, - Err(FsError::NotFound) + Err(StoreError::NotFound) )); } diff --git a/crates/s3fs-core/src/backend/mod.rs b/runtime/src/store/backend/mod.rs similarity index 88% rename from crates/s3fs-core/src/backend/mod.rs rename to runtime/src/store/backend/mod.rs index ab95f43..d356ab8 100644 --- a/crates/s3fs-core/src/backend/mod.rs +++ b/runtime/src/store/backend/mod.rs @@ -1,8 +1,10 @@ //! `Backend` trait — the storage primitive surface. //! //! This trait abstracts over S3, S3-compatible (MinIO, R2), and an in-memory -//! test fake. Methods correspond closely to S3 wire operations; the engine -//! layered on top translates POSIX semantics into sequences of these calls. +//! test fake. Methods correspond closely to S3 wire operations. What the +//! runtime builds on them is the anchor chain ([`crate::zfs`]) and the boot +//! records ([`crate::boot`]): conditional creates under Object Lock, and reads +//! of the retained version beneath any delete marker. //! //! ## Method coverage //! @@ -30,13 +32,11 @@ use std::time::SystemTime; use async_trait::async_trait; use bytes::Bytes; -use crate::errors::FsResult; +use crate::store::error::StoreResult; pub mod memory; -#[cfg(feature = "aws")] pub mod aws; -#[cfg(feature = "aws")] pub use aws::{AwsS3Backend, AwsS3BackendConfig}; /// Per-backend feature flags. @@ -222,9 +222,9 @@ pub struct CompletedPart { pub trait Backend: Send + Sync + std::fmt::Debug + 'static { fn capabilities(&self) -> Capabilities; - async fn head_blob(&self, key: &str) -> FsResult; + async fn head_blob(&self, key: &str) -> StoreResult; - async fn get_blob(&self, key: &str, range: Option>) -> FsResult; + async fn get_blob(&self, key: &str, range: Option>) -> StoreResult; /// Read the **retained** version of an object, seeing past a delete marker. /// @@ -246,31 +246,31 @@ pub trait Backend: Send + Sync + std::fmt::Debug + 'static { /// using a conditional PUT, which is to say not by us. /// /// Needs `s3:ListBucketVersions` in addition to `s3:GetObject`. - async fn get_retained_blob(&self, key: &str) -> FsResult; + async fn get_retained_blob(&self, key: &str) -> StoreResult; - async fn put_blob(&self, input: PutBlobInput) -> FsResult; + async fn put_blob(&self, input: PutBlobInput) -> StoreResult; - /// Atomic create. Returns [`crate::errors::FsError::AlreadyExists`] if the + /// Atomic create. Returns [`crate::store::error::StoreError::AlreadyExists`] if the /// key already has an object. Backends without conditional-PUT support - /// must return [`crate::errors::FsError::NotSupported`] from this method + /// must return [`crate::store::error::StoreError::NotSupported`] from this method /// and report `capabilities().conditional_put == false`. - async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> FsResult; + async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> StoreResult; - async fn delete_blob(&self, key: &str) -> FsResult<()>; + async fn delete_blob(&self, key: &str) -> StoreResult<()>; /// Batch delete. Default impl falls back to per-key `delete_blob`. - async fn delete_blobs(&self, keys: &[String]) -> FsResult<()> { + async fn delete_blobs(&self, keys: &[String]) -> StoreResult<()> { for k in keys { self.delete_blob(k).await?; } Ok(()) } - async fn list_blobs(&self, input: ListBlobsInput<'_>) -> FsResult; + async fn list_blobs(&self, input: ListBlobsInput<'_>) -> StoreResult; - async fn copy_blob(&self, input: CopyBlobInput) -> FsResult; + async fn copy_blob(&self, input: CopyBlobInput) -> StoreResult; - async fn multipart_begin(&self, input: PutBlobInput) -> FsResult; + async fn multipart_begin(&self, input: PutBlobInput) -> StoreResult; async fn multipart_upload_part( &self, @@ -278,7 +278,7 @@ pub trait Backend: Send + Sync + std::fmt::Debug + 'static { upload_id: &MultipartId, part_number: u32, body: Bytes, - ) -> FsResult; + ) -> StoreResult; /// Server-side range copy into a part of an in-flight MPU. The load-bearing /// primitive for in-place updates and append. @@ -289,14 +289,14 @@ pub trait Backend: Send + Sync + std::fmt::Debug + 'static { part_number: u32, source_key: &str, source_range: Range, - ) -> FsResult; + ) -> StoreResult; async fn multipart_complete( &self, key: &str, upload_id: &MultipartId, parts: Vec, - ) -> FsResult; + ) -> StoreResult; - async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> FsResult<()>; + async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> StoreResult<()>; } diff --git a/crates/s3fs-core/src/crypto/hash.rs b/runtime/src/store/crypto/hash.rs similarity index 97% rename from crates/s3fs-core/src/crypto/hash.rs rename to runtime/src/store/crypto/hash.rs index def5a63..ea6a30d 100644 --- a/crates/s3fs-core/src/crypto/hash.rs +++ b/runtime/src/store/crypto/hash.rs @@ -8,7 +8,7 @@ use std::fmt; -use crate::errors::{FsError, FsResult}; +use crate::store::error::{StoreError, StoreResult}; /// Length of a hash in bytes. pub const HASH_LEN: usize = 32; @@ -60,7 +60,7 @@ impl Hash256 { /// here would leak how many leading bytes of a forged block matched, which /// is enough to mount a byte-at-a-time forgery search against any code /// path that lets an attacker submit candidate blocks. - pub fn verify(&self, expected: &Hash256, context: &'static str) -> FsResult<()> { + pub fn verify(&self, expected: &Hash256, context: &'static str) -> StoreResult<()> { let mut diff = 0u8; for i in 0..HASH_LEN { diff |= self.0[i] ^ expected.0[i]; @@ -68,7 +68,7 @@ impl Hash256 { if diff == 0 { Ok(()) } else { - Err(FsError::Integrity(context)) + Err(StoreError::Integrity(context)) } } } @@ -149,7 +149,7 @@ mod tests { let other = Hash256::of(b"block!"); assert!(matches!( h.verify(&other, "blkptr checksum"), - Err(FsError::Integrity("blkptr checksum")) + Err(StoreError::Integrity("blkptr checksum")) )); } diff --git a/crates/s3fs-core/src/crypto/keys.rs b/runtime/src/store/crypto/keys.rs similarity index 70% rename from crates/s3fs-core/src/crypto/keys.rs rename to runtime/src/store/crypto/keys.rs index a3857eb..d4052b9 100644 --- a/crates/s3fs-core/src/crypto/keys.rs +++ b/runtime/src/store/crypto/keys.rs @@ -13,35 +13,30 @@ //! HKDF-SHA384(salt = fs_uuid, info = purpose) //! ┌──────────────────┼──────────────────┐ //! ▼ ▼ ▼ -//! block AEAD key Ed25519 seed dir-hash key -//! (AES-256-GCM) (root signing) (keyed BLAKE3) +//! Ed25519 seed runtime seal key pool dm-crypt key +//! (anchor signing) (AES-256-GCM) (AES-XTS, via [`hkdf`]) //! ``` //! //! Separate keys per purpose so that a weakness in one use cannot be pivoted -//! into another — in particular, the directory hash key is exposed to -//! chosen-input attacks (a guest picks filenames) in a way the block key -//! never is. +//! into another. use aws_lc_rs::aead::{LessSafeKey, UnboundKey, AES_256_GCM}; use aws_lc_rs::hkdf::{KeyType, Salt, HKDF_SHA384}; use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair}; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::errors::{FsError, FsResult}; +use crate::store::error::{StoreError, StoreResult}; /// HKDF info strings. Versioned so a future format revision can rotate /// derived keys without changing the master secret. -const INFO_BLOCK: &[u8] = b"s3fs/block/v1"; -const INFO_ROOTSIGN: &[u8] = b"s3fs/rootsign/v1"; -const INFO_DIRHASH: &[u8] = b"s3fs/dirhash/v1"; +const INFO_ROOTSIGN: &[u8] = b"enclave/anchor-sign/v1"; /// Seals the runtime's own secrets — the ACME account key and the TLS -/// certificate's private key — which live outside the filesystem. +/// certificate's private key — which live in the roots bucket. /// -/// Outside, because a tenant's guest is confined to its own directory but the -/// runtime's own material must be unreachable from *any* of them. A separate -/// label keeps it cryptographically distinct from block data as well as -/// physically separate. -const INFO_RUNTIME_SEAL: &[u8] = b"s3fs/runtime-seal/v1"; +/// In the bucket rather than on the pool, because a certificate has to be +/// served before anything else is, and a separate label keeps it +/// cryptographically distinct from everything else derived here. +const INFO_RUNTIME_SEAL: &[u8] = b"enclave/runtime-seal/v1"; /// Length of an Ed25519 public key and of a raw signing seed. pub const ED25519_PUBLIC_KEY_LEN: usize = 32; @@ -70,25 +65,25 @@ impl MasterSecret { /// Parse a 64-character hex string, as supplied by `--data-key` or an /// environment variable in development. - pub fn from_hex(s: &str) -> FsResult { + pub fn from_hex(s: &str) -> StoreResult { let s = s.trim(); if s.len() != 64 { - return Err(FsError::Invalid("master key must be 64 hex characters")); + return Err(StoreError::Invalid("master key must be 64 hex characters")); } let mut out = [0u8; 32]; for (i, byte) in out.iter_mut().enumerate() { *byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16) - .map_err(|_| FsError::Invalid("master key is not valid hex"))?; + .map_err(|_| StoreError::Invalid("master key is not valid hex"))?; } Ok(MasterSecret(out)) } /// Generate a fresh random secret. Used when formatting a new filesystem /// in development; production keys come from KMS. - pub fn generate() -> FsResult { + pub fn generate() -> StoreResult { let mut out = [0u8; 32]; aws_lc_rs::rand::fill(&mut out) - .map_err(|_| FsError::Io("system RNG unavailable".to_string()))?; + .map_err(|_| StoreError::Io("system RNG unavailable".to_string()))?; Ok(MasterSecret(out)) } } @@ -109,20 +104,20 @@ impl KeyType for OkmLen { } } -fn hkdf(secret: &MasterSecret, salt: &[u8], info: &[u8], out: &mut [u8]) -> FsResult<()> { +/// HKDF-SHA384 from the master secret. Public for keys that live outside +/// [`KeyMaterial`], such as the pool's dm-crypt key. +pub fn hkdf(secret: &MasterSecret, salt: &[u8], info: &[u8], out: &mut [u8]) -> StoreResult<()> { let prk = Salt::new(HKDF_SHA384, salt).extract(&secret.0); prk.expand(&[info], OkmLen(out.len())) .and_then(|okm| okm.fill(out)) - .map_err(|_| FsError::Io("HKDF expansion failed".to_string())) + .map_err(|_| StoreError::Io("HKDF expansion failed".to_string())) } /// All keys derived from one master secret, for one filesystem. pub struct KeyMaterial { - block_key: LessSafeKey, runtime_seal_key: LessSafeKey, signing_key: Ed25519KeyPair, public_key: [u8; ED25519_PUBLIC_KEY_LEN], - dir_hash_key: [u8; 32], fs_uuid: [u8; 16], } @@ -130,51 +125,34 @@ impl KeyMaterial { /// Derive every per-purpose key. /// /// `fs_uuid` is the HKDF salt, so two filesystems formatted from the same - /// master secret still get independent keys. It is stored in the root - /// record, and is not a secret. - pub fn derive(master: &MasterSecret, fs_uuid: [u8; 16]) -> FsResult { - let mut block_bytes = [0u8; 32]; - hkdf(master, &fs_uuid, INFO_BLOCK, &mut block_bytes)?; - let block_key = LessSafeKey::new( - UnboundKey::new(&AES_256_GCM, &block_bytes) - .map_err(|_| FsError::Io("AES key setup failed".to_string()))?, - ); - block_bytes.zeroize(); - + /// master secret still get independent keys. It is in every anchor, and + /// is not a secret. + pub fn derive(master: &MasterSecret, fs_uuid: [u8; 16]) -> StoreResult { let mut seal_bytes = [0u8; 32]; hkdf(master, &fs_uuid, INFO_RUNTIME_SEAL, &mut seal_bytes)?; let runtime_seal_key = LessSafeKey::new( UnboundKey::new(&AES_256_GCM, &seal_bytes) - .map_err(|_| FsError::Io("AES key setup failed".to_string()))?, + .map_err(|_| StoreError::Io("AES key setup failed".to_string()))?, ); seal_bytes.zeroize(); let mut seed = [0u8; 32]; hkdf(master, &fs_uuid, INFO_ROOTSIGN, &mut seed)?; let signing_key = Ed25519KeyPair::from_seed_unchecked(&seed) - .map_err(|_| FsError::Io("Ed25519 key derivation failed".to_string()))?; + .map_err(|_| StoreError::Io("Ed25519 key derivation failed".to_string()))?; seed.zeroize(); let mut public_key = [0u8; ED25519_PUBLIC_KEY_LEN]; public_key.copy_from_slice(signing_key.public_key().as_ref()); - let mut dir_hash_key = [0u8; 32]; - hkdf(master, &fs_uuid, INFO_DIRHASH, &mut dir_hash_key)?; - Ok(KeyMaterial { - block_key, runtime_seal_key, signing_key, public_key, - dir_hash_key, fs_uuid, }) } - pub fn block_key(&self) -> &LessSafeKey { - &self.block_key - } - /// Seals runtime secrets stored outside the filesystem. See /// [`INFO_RUNTIME_SEAL`]. pub fn runtime_seal_key(&self) -> &LessSafeKey { @@ -185,27 +163,17 @@ impl KeyMaterial { &self.signing_key } - /// Public half of the root-signing key. Written into each root record so - /// an external auditor can verify the chain without the master secret. + /// Public half of the anchor-signing key. Written into each anchor so an + /// external auditor can verify the chain without the master secret. pub fn public_key(&self) -> &[u8; ED25519_PUBLIC_KEY_LEN] { &self.public_key } - pub fn dir_hash_key(&self) -> &[u8; 32] { - &self.dir_hash_key - } - pub fn fs_uuid(&self) -> &[u8; 16] { &self.fs_uuid } } -impl Drop for KeyMaterial { - fn drop(&mut self) { - self.dir_hash_key.zeroize(); - } -} - impl std::fmt::Debug for KeyMaterial { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("KeyMaterial") @@ -232,7 +200,6 @@ mod tests { let a = KeyMaterial::derive(&master(), [1u8; 16]).unwrap(); let b = KeyMaterial::derive(&master(), [1u8; 16]).unwrap(); assert_eq!(a.public_key(), b.public_key()); - assert_eq!(a.dir_hash_key(), b.dir_hash_key()); } /// The salt is what keeps two filesystems formatted from the same master @@ -242,7 +209,6 @@ mod tests { let a = KeyMaterial::derive(&master(), [1u8; 16]).unwrap(); let b = KeyMaterial::derive(&master(), [2u8; 16]).unwrap(); assert_ne!(a.public_key(), b.public_key()); - assert_ne!(a.dir_hash_key(), b.dir_hash_key()); } #[test] @@ -250,17 +216,17 @@ mod tests { let a = KeyMaterial::derive(&MasterSecret::from_bytes([1u8; 32]), [0u8; 16]).unwrap(); let b = KeyMaterial::derive(&MasterSecret::from_bytes([2u8; 32]), [0u8; 16]).unwrap(); assert_ne!(a.public_key(), b.public_key()); - assert_ne!(a.dir_hash_key(), b.dir_hash_key()); } - /// Purpose separation: the signing seed and the directory hash key are - /// derived from the same secret and salt, differing only in the info - /// string. If those collided, the info strings would not be doing - /// anything. + /// Purpose separation: two keys from the same secret and salt, differing + /// only in the info string. If those collided, the info strings would not + /// be doing anything. #[test] fn purposes_are_separated() { - let km = KeyMaterial::derive(&master(), [0u8; 16]).unwrap(); - assert_ne!(&km.dir_hash_key()[..], &km.public_key()[..]); + let (mut a, mut b) = ([0u8; 32], [0u8; 32]); + hkdf(&master(), &[0u8; 16], INFO_ROOTSIGN, &mut a).unwrap(); + hkdf(&master(), &[0u8; 16], INFO_RUNTIME_SEAL, &mut b).unwrap(); + assert_ne!(a, b); } #[test] @@ -298,6 +264,6 @@ mod tests { let km = KeyMaterial::derive(&m, [0u8; 16]).unwrap(); let s = format!("{km:?}"); - assert!(!s.contains(&hex16(km.dir_hash_key()))); + assert!(!s.contains("abab")); } } diff --git a/runtime/src/store/crypto/mod.rs b/runtime/src/store/crypto/mod.rs new file mode 100644 index 0000000..dad1009 --- /dev/null +++ b/runtime/src/store/crypto/mod.rs @@ -0,0 +1,16 @@ +//! Cryptographic primitives. +//! +//! - [`hash`] — BLAKE3 digests. +//! - [`sign`] — Ed25519, so an anchor cannot be minted or re-pointed by anyone +//! holding only bucket write access. +//! - [`keys`] — one master secret, HKDF-separated per purpose. +//! +//! Primitives come from `aws-lc-rs` (the same stack KMS speaks, FIPS-capable) +//! except BLAKE3, which has no aws-lc equivalent. + +pub mod hash; +pub mod keys; +pub mod sign; + +pub use hash::{Hash256, HASH_LEN}; +pub use keys::{KeyMaterial, MasterSecret, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN}; diff --git a/crates/s3fs-core/src/crypto/sign.rs b/runtime/src/store/crypto/sign.rs similarity index 91% rename from crates/s3fs-core/src/crypto/sign.rs rename to runtime/src/store/crypto/sign.rs index d574a31..b4878a6 100644 --- a/crates/s3fs-core/src/crypto/sign.rs +++ b/runtime/src/store/crypto/sign.rs @@ -9,7 +9,7 @@ use aws_lc_rs::signature::{Ed25519KeyPair, UnparsedPublicKey, ED25519}; -use crate::errors::{FsError, FsResult}; +use crate::store::error::{StoreError, StoreResult}; use super::keys::{ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN}; @@ -23,23 +23,23 @@ pub fn sign(key: &Ed25519KeyPair, message: &[u8]) -> [u8; ED25519_SIGNATURE_LEN] /// Verify a detached signature. /// -/// Returns [`FsError::Integrity`] on any failure. The caller must treat that +/// Returns [`StoreError::Integrity`] on any failure. The caller must treat that /// as fatal for the mount: a bad root signature means the store is serving /// something we did not write. pub fn verify( public_key: &[u8; ED25519_PUBLIC_KEY_LEN], message: &[u8], signature: &[u8; ED25519_SIGNATURE_LEN], -) -> FsResult<()> { +) -> StoreResult<()> { UnparsedPublicKey::new(&ED25519, public_key) .verify(message, signature) - .map_err(|_| FsError::Integrity("root signature verification failed")) + .map_err(|_| StoreError::Integrity("root signature verification failed")) } #[cfg(test)] mod tests { use super::*; - use crate::crypto::keys::{KeyMaterial, MasterSecret}; + use crate::store::crypto::keys::{KeyMaterial, MasterSecret}; fn material(master: u8) -> KeyMaterial { KeyMaterial::derive(&MasterSecret::from_bytes([master; 32]), [0u8; 16]).unwrap() @@ -59,7 +59,7 @@ mod tests { let sig = sign(km.signing_key(), b"root record bytes"); assert!(matches!( verify(km.public_key(), b"root record bytez", &sig), - Err(FsError::Integrity(_)) + Err(StoreError::Integrity(_)) )); } diff --git a/crates/s3fs-core/src/errors.rs b/runtime/src/store/error.rs similarity index 80% rename from crates/s3fs-core/src/errors.rs rename to runtime/src/store/error.rs index 0157bd4..7b3a881 100644 --- a/crates/s3fs-core/src/errors.rs +++ b/runtime/src/store/error.rs @@ -1,4 +1,4 @@ -//! `FsError` — the engine's internal error type. +//! `StoreError` — the engine's internal error type. //! //! Translation to WASI Preview 2 `wasi:filesystem/types::error-code` happens in //! the `enclave-runtime` crate. Inside the engine we keep error variants close to @@ -10,7 +10,7 @@ use thiserror::Error; /// `wasi:filesystem/types::error-code` while staying intelligible inside the /// engine itself. #[derive(Debug, Clone, Error)] -pub enum FsError { +pub enum StoreError { #[error("not found")] NotFound, @@ -90,7 +90,7 @@ pub enum FsError { /// The store offered a root record older than one we have already /// accepted, or older than the configured floor. Distinct from - /// [`FsError::Integrity`] because the data is *valid* — correctly signed + /// [`StoreError::Integrity`] because the data is *valid* — correctly signed /// and internally consistent — just stale. That is the signature of a /// rollback attempt rather than corruption. #[error("rollback detected: root seq {found} is not newer than {expected}")] @@ -106,20 +106,20 @@ pub enum FsError { Network(String), } -impl FsError { +impl StoreError { /// `true` if the error is plausibly transient and worth retrying with /// backoff (network blips, throttling, 5xx). Persistent errors like /// `NotFound` or `AccessDenied` return `false`. pub fn is_transient(&self) -> bool { matches!( self, - FsError::IoTimeout | FsError::Network(_) | FsError::WouldBlock + StoreError::IoTimeout | StoreError::Network(_) | StoreError::WouldBlock ) } } /// Convenience alias used throughout the crate. -pub type FsResult = Result; +pub type StoreResult = Result; #[cfg(test)] mod tests { @@ -127,21 +127,21 @@ mod tests { #[test] fn display_is_stable() { - assert_eq!(FsError::NotFound.to_string(), "not found"); + assert_eq!(StoreError::NotFound.to_string(), "not found"); assert_eq!( - FsError::Loop.to_string(), + StoreError::Loop.to_string(), "symlink loop or recursion limit exceeded" ); assert_eq!( - FsError::Invalid("bad part number").to_string(), + StoreError::Invalid("bad part number").to_string(), "invalid argument: bad part number" ); assert_eq!( - FsError::Integrity("blkptr checksum").to_string(), + StoreError::Integrity("blkptr checksum").to_string(), "integrity check failed: blkptr checksum" ); assert_eq!( - FsError::Rollback { + StoreError::Rollback { expected: 42, found: 41 } @@ -154,8 +154,8 @@ mod tests { /// Retrying them would turn a detected attack into a spin loop. #[test] fn verification_failures_are_never_transient() { - assert!(!FsError::Integrity("root signature").is_transient()); - assert!(!FsError::Rollback { + assert!(!StoreError::Integrity("root signature").is_transient()); + assert!(!StoreError::Rollback { expected: 2, found: 1 } @@ -164,13 +164,13 @@ mod tests { #[test] fn is_transient_categorisation() { - assert!(FsError::IoTimeout.is_transient()); - assert!(FsError::Network("dns".into()).is_transient()); - assert!(FsError::WouldBlock.is_transient()); - - assert!(!FsError::NotFound.is_transient()); - assert!(!FsError::AccessDenied.is_transient()); - assert!(!FsError::Loop.is_transient()); - assert!(!FsError::Conflict.is_transient()); + assert!(StoreError::IoTimeout.is_transient()); + assert!(StoreError::Network("dns".into()).is_transient()); + assert!(StoreError::WouldBlock.is_transient()); + + assert!(!StoreError::NotFound.is_transient()); + assert!(!StoreError::AccessDenied.is_transient()); + assert!(!StoreError::Loop.is_transient()); + assert!(!StoreError::Conflict.is_transient()); } } diff --git a/runtime/src/store/mod.rs b/runtime/src/store/mod.rs new file mode 100644 index 0000000..36bc489 --- /dev/null +++ b/runtime/src/store/mod.rs @@ -0,0 +1,17 @@ +//! The bucket and the keys: what the runtime keeps outside the pool. +//! +//! - [`backend`] — S3 (and S3-compatible) object storage, plus an in-memory +//! fake, with the Object Lock and retained-version reads the anchor chain +//! and the boot records depend on. +//! - [`crypto`] — the key hierarchy off one master secret, Ed25519 signing, +//! and BLAKE3. +//! - [`error`] — the error type both speak. +//! +//! The state itself is on the ZFS pool — see [`crate::zfs`]. + +pub mod backend; +pub mod crypto; +pub mod error; + +pub use crypto::MasterSecret; +pub use error::{StoreError, StoreResult}; diff --git a/runtime/src/stream.rs b/runtime/src/stream.rs index 18ed147..79820fe 100644 --- a/runtime/src/stream.rs +++ b/runtime/src/stream.rs @@ -65,15 +65,15 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{ensure, Context, Result}; -use s3fs_core::{Fs, Inode, OpenFlags}; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, Notify}; use crate::serve::attest::{ResponseAttestor, ATTESTATION_HEADER}; use crate::serve::egress::Origin; -use crate::tenant::ensure_dir; +use crate::zfs::Zfs; -const DIR: &str = "/runtime/streams"; +/// Under `/runtime`. +const DIR: &str = "streams"; const MAX_RECORD: usize = 8 * 1024; /// One message, in either direction. Large enough for a key package and a /// transaction; small enough that a peer cannot make the runtime hold a lot. @@ -194,8 +194,7 @@ struct Live { /// The registry, and the supervisor that keeps its instructions true. pub struct StreamRegistry { - fs: Arc, - dir: Arc, + zfs: Arc, records: Mutex>, live: Mutex>, /// Woken when a record appears or goes. `notify_one`, never `notify_waiters`: there is exactly @@ -226,12 +225,9 @@ pub struct StreamContext { } impl StreamRegistry { - pub async fn open_registry(fs: Arc) -> Result> { - let runtime = ensure_dir(&fs, &fs.root(), "runtime").await?; - let dir = ensure_dir(&fs, &runtime, "streams").await?; + pub async fn open_registry(zfs: Arc) -> Result> { let registry = Arc::new(Self { - fs, - dir, + zfs, records: Mutex::new(BTreeMap::new()), live: Mutex::new(BTreeMap::new()), changed: Notify::new(), @@ -242,29 +238,35 @@ impl StreamRegistry { // Everything standing, read back before anything is served. A record on // disk is the whole of a connection's existence, so this is also the - // answer to "what reconnects after a restart": this loop. + // answer to "what reconnects after a restart": this loop. Each tenant's + // records are on its own pool; scan only the pools the catalog flags. let mut records = registry.records.lock().await; - for entry in registry.fs.read_dir(®istry.dir).await? { - if entry.name.ends_with(".tmp") { - registry.fs.unlink(®istry.dir, &entry.name).await?; - continue; + for tenant in registry.zfs.background_tenants().await? { + let dir = registry.zfs.tenant_runtime_dir(tenant, DIR).await?; + let mut entries = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry + .file_name() + .into_string() + .ok() + .context("non-UTF-8 stream record name")?; + if name.ends_with(".tmp") { + tokio::fs::remove_file(entry.path()).await?; + continue; + } + let bytes = tokio::fs::read(entry.path()).await?; + ensure!(bytes.len() <= MAX_RECORD, "oversized stream record"); + let record: StreamRecord = + serde_json::from_slice(&bytes).context("decoding a stream record")?; + ensure!( + record.version == 1 + && valid_id(&record.id) + && record.tenant == tenant + && record.key() == name, + "malformed stream record {name}" + ); + records.insert((record.tenant, record.id.clone()), record); } - let h = registry - .fs - .open(&format!("{DIR}/{}", entry.name), OpenFlags::read_only()) - .await?; - let bytes = registry.fs.pread(&h, 0, MAX_RECORD + 1).await; - registry.fs.close(&h).await?; - let bytes = bytes?; - ensure!(bytes.len() <= MAX_RECORD, "oversized stream record"); - let record: StreamRecord = - serde_json::from_slice(&bytes).context("decoding a stream record")?; - ensure!( - record.version == 1 && valid_id(&record.id) && record.key() == entry.name, - "malformed stream record {}", - entry.name - ); - records.insert((record.tenant, record.id.clone()), record); } drop(records); Ok(registry) @@ -280,6 +282,9 @@ impl StreamRegistry { pub async fn open(&self, tenant: [u8; 16], id: String, origin: String) -> Result<()> { ensure!(valid_id(&id), "a stream id is 1..=64 of [A-Za-z0-9_-]"); Origin::parse(&origin).context("that is not an origin")?; + // The tenant's pool must be scanned on the next boot; flag it (durable + // on the control pool) before its first record is written. + self.zfs.mark_background(tenant).await?; let mut records = self.records.lock().await; if let Some(existing) = records.get(&(tenant, id.clone())) { @@ -313,8 +318,10 @@ impl StreamRegistry { pub async fn close(&self, tenant: [u8; 16], id: &str) -> Result<()> { let mut records = self.records.lock().await; if let Some(record) = records.remove(&(tenant, id.to_string())) { - match self.fs.unlink(&self.dir, &record.key()).await { - Ok(()) | Err(s3fs_core::FsError::NotFound) => {} + let dir = self.zfs.tenant_runtime_dir(tenant, DIR).await?; + match tokio::fs::remove_file(dir.join(record.key())).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), } } @@ -434,22 +441,10 @@ impl StreamRegistry { async fn publish(&self, record: &StreamRecord) -> Result<()> { let bytes = serde_json::to_vec(record)?; ensure!(bytes.len() <= MAX_RECORD, "stream record exceeds limit"); - let temp = format!("{}.tmp", record.key()); - match self.fs.unlink(&self.dir, &temp).await { - Ok(()) | Err(s3fs_core::FsError::NotFound) => {} - Err(e) => return Err(e.into()), - } - let h = self - .fs - .open(&format!("{DIR}/{temp}"), OpenFlags::create_new()) - .await?; - let write = self.fs.pwrite(&h, 0, &bytes).await; - let close = self.fs.close(&h).await; - write?; - close?; // close commits the contents before publication - self.fs - .rename(&self.dir, &temp, &self.dir, &record.key()) - .await?; + let dir = self.zfs.tenant_runtime_dir(record.tenant, DIR).await?; + let temp = dir.join(format!("{}.tmp", record.key())); + tokio::fs::write(&temp, &bytes).await?; + tokio::fs::rename(&temp, dir.join(record.key())).await?; Ok(()) } @@ -793,23 +788,10 @@ pub fn add_to_linker( #[cfg(test)] mod tests { use super::*; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, MasterSecret}; - - async fn registry() -> (Arc, Arc) { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend.clone(), - &MasterSecret::from_bytes([7; 32]), - [8; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); - crate::tenant::tenant_root_by_id(&fs, [1; 16]) - .await - .unwrap(); + + async fn registry() -> (Arc, Arc) { + let fs = Zfs::scratch().await; + fs.tenant_dir([1; 16]).await.unwrap(); let r = StreamRegistry::open_registry(fs.clone()).await.unwrap(); (r, fs) } @@ -921,7 +903,7 @@ mod tests { #[tokio::test] async fn a_tenant_cannot_hold_unboundedly_many() { - let (r, _fs) = registry().await; + let (r, fs) = registry().await; for i in 0..PER_TENANT { r.open([1; 16], format!("esc{i}"), "https://svc.example".into()) .await @@ -934,9 +916,7 @@ mod tests { assert!(format!("{err:#}").contains("at most"), "{err:#}"); // Another tenant is unaffected: the cap is per tenant, not global. - crate::tenant::tenant_root_by_id(&r.fs, [2; 16]) - .await - .unwrap(); + fs.tenant_dir([2; 16]).await.unwrap(); r.open([2; 16], "esc".into(), "https://svc.example".into()) .await .expect("one tenant's quota is not another's"); @@ -1143,14 +1123,14 @@ mod tests { } } - async fn guest(fs: Arc) -> Arc { + async fn guest(fs: Arc) -> Arc { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../examples/guest-http/target/wasm32-wasip2/release/guest-http.wasm"); let bytes = std::fs::read(path).expect("build examples/guest-http for wasm32-wasip2"); let (logs, _collector) = crate::guest_io::start(Arc::new(crate::TracingLogSink)); let env = crate::GuestEnvironment::new( fs, - Box::new(crate::clock::HostClock), + Arc::new(crate::clock::HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), &[], &[], diff --git a/runtime/src/tasks.rs b/runtime/src/tasks.rs index 7dac3ff..5fdd0b0 100644 --- a/runtime/src/tasks.rs +++ b/runtime/src/tasks.rs @@ -1,6 +1,7 @@ -//! Durable, tenant-bound background work. One queue owner per mounted filesystem. +//! Durable, tenant-bound background work. One queue owner per pool. //! -//! A synced temporary file is atomically renamed to publish each record. The +//! A temporary file is atomically renamed to publish each record, and a +//! finished attempt is anchored before anything is told it finished. The //! record itself is the scheduling index; notifications are only an optimization. //! Recovery tolerates unpublished temporary files, never malformed live records. //! Execution is at least once: handlers must deduplicate their effects by run ID. @@ -9,19 +10,19 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{ensure, Context, Result}; -use s3fs_core::{Fs, Inode, OpenFlags}; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, Notify}; use wasmtime_wasi::HostWallClock; use crate::clock::WallClockAdapter; use crate::state::State; -use crate::tenant::ensure_dir; +use crate::zfs::Zfs; const MAX_PAYLOAD: usize = 64 * 1024; const MAX_RECORD: usize = 1024 * 1024; const MAX_ATTEMPTS: u32 = 5; -const DIR: &str = "/runtime/tasks"; +/// Under `/runtime`. +const DIR: &str = "tasks"; /// How long one attempt at a background task may run. Fixed, not a setting: a task that drives a /// round with an outside service waits on that service's schedule, which is minutes, and a limit @@ -110,8 +111,7 @@ struct Records { last_tenant: Option<[u8; 16]>, } pub struct TaskQueue { - fs: Arc, - dir: Arc, + zfs: Arc, clock: Arc, pub limits: TaskLimits, records: Mutex, @@ -119,7 +119,7 @@ pub struct TaskQueue { } impl TaskQueue { pub async fn open( - fs: Arc, + zfs: Arc, clock: Arc, limits: TaskLimits, ) -> Result> { @@ -130,65 +130,70 @@ impl TaskQueue { && !limits.timeout.is_zero(), "task limits must be positive" ); - let runtime = ensure_dir(&fs, &fs.root(), "runtime").await?; - let dir = ensure_dir(&fs, &runtime, "tasks").await?; let queue = Arc::new(Self { - fs, - dir, + zfs, clock, limits, records: Mutex::new(Records::default()), changed: Notify::new(), }); + // Each tenant's tasks are on its own pool. Scan only the tenants the + // catalog flags as having background work, so a restart need not import + // every pool to find the tasks due. let mut records = queue.records.lock().await; - for entry in queue.fs.read_dir(&queue.dir).await? { - if entry.name.ends_with(".tmp") { - queue.fs.unlink(&queue.dir, &entry.name).await?; - continue; - } - ensure!( - records.tasks.len() < queue.limits.max_records, - "task store exceeds configured quota" - ); - let h = queue - .fs - .open(&format!("{DIR}/{}", entry.name), OpenFlags::read_only()) - .await?; - let bytes = queue.fs.pread(&h, 0, MAX_RECORD + 1).await; - queue.fs.close(&h).await?; - let bytes = bytes?; - ensure!(bytes.len() <= MAX_RECORD, "oversized task record"); - let mut task: Task = serde_json::from_slice(&bytes).context("decoding task record")?; - ensure!( - task.version == 1 - && valid_id(&task.id) - && task.key() == entry.name - && task.payload.len() <= MAX_PAYLOAD - && task.result.len() <= MAX_PAYLOAD - && task.interval_ms.is_none_or(|i| i >= 1000), - "invalid task record" - ); - // Retry interrupted attempts with their original occurrence ID. - if task.status == Status::Running { - task.status = if task.attempts >= MAX_ATTEMPTS { - Status::Failed - } else { - Status::Pending - }; - task.error = Some("enclave stopped during execution".into()); - queue.write(&task).await?; - tracing::warn!(tenant = %hex::encode(task.tenant), task = %task.id, - status = ?task.status, attempts = task.attempts, - "background task attempt interrupted by a restart"); - } - if task.status == Status::Pending { - // Recovery jitter prevents a fleet of overdue tasks firing together. - let due = task - .run_at - .max(queue.now().saturating_add(offset(&task.key(), 1000))); - records.due.insert((due, task.key())); + for tenant in queue.zfs.background_tenants().await? { + let dir = queue.zfs.tenant_runtime_dir(tenant, DIR).await?; + let mut entries = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry + .file_name() + .into_string() + .ok() + .context("non-UTF-8 task record name")?; + if name.ends_with(".tmp") { + tokio::fs::remove_file(entry.path()).await?; + continue; + } + ensure!( + records.tasks.len() < queue.limits.max_records, + "task store exceeds configured quota" + ); + let bytes = tokio::fs::read(entry.path()).await?; + ensure!(bytes.len() <= MAX_RECORD, "oversized task record"); + let mut task: Task = + serde_json::from_slice(&bytes).context("decoding task record")?; + ensure!( + task.version == 1 + && valid_id(&task.id) + && task.tenant == tenant + && task.key() == name + && task.payload.len() <= MAX_PAYLOAD + && task.result.len() <= MAX_PAYLOAD + && task.interval_ms.is_none_or(|i| i >= 1000), + "invalid task record" + ); + // Retry interrupted attempts with their original occurrence ID. + if task.status == Status::Running { + task.status = if task.attempts >= MAX_ATTEMPTS { + Status::Failed + } else { + Status::Pending + }; + task.error = Some("enclave stopped during execution".into()); + queue.write(&task).await?; + tracing::warn!(tenant = %hex::encode(task.tenant), task = %task.id, + status = ?task.status, attempts = task.attempts, + "background task attempt interrupted by a restart"); + } + if task.status == Status::Pending { + // Recovery jitter stops a fleet of overdue tasks firing together. + let due = task + .run_at + .max(queue.now().saturating_add(offset(&task.key(), 1000))); + records.due.insert((due, task.key())); + } + records.tasks.insert(task.key(), task); } - records.tasks.insert(task.key(), task); } drop(records); Ok(queue) @@ -199,23 +204,11 @@ impl TaskQueue { async fn write(&self, task: &Task) -> Result<()> { let bytes = serde_json::to_vec(task)?; ensure!(bytes.len() <= MAX_RECORD, "task record exceeds limit"); - let temp = format!("{}.tmp", task.key()); - // Remove an unpublished write left by a cancelled host call. - match self.fs.unlink(&self.dir, &temp).await { - Ok(()) | Err(s3fs_core::FsError::NotFound) => {} - Err(e) => return Err(e.into()), - } - let h = self - .fs - .open(&format!("{DIR}/{temp}"), OpenFlags::create_new()) - .await?; - let write = self.fs.pwrite(&h, 0, &bytes).await; - let close = self.fs.close(&h).await; - write?; - close?; // close commits the complete contents before publication - self.fs - .rename(&self.dir, &temp, &self.dir, &task.key()) - .await?; + let dir = self.zfs.tenant_runtime_dir(task.tenant, DIR).await?; + // Truncates an unpublished write left by a cancelled host call. + let temp = dir.join(format!("{}.tmp", task.key())); + tokio::fs::write(&temp, &bytes).await?; + tokio::fs::rename(&temp, dir.join(task.key())).await?; Ok(()) } pub async fn enqueue( @@ -232,7 +225,11 @@ impl TaskQueue { interval_ms.is_none_or(|i| i >= 1000), "interval must be at least 1000 ms" ); - crate::tenant::existing_tenant_root(&self.fs, tenant).await?; + self.zfs.existing_tenant_dir(tenant).await?; + // The tenant's pool must be scanned on the next boot, so flag it before + // its first record is written: durable (on the control pool) ahead of + // the record, so a crash never leaves a task the scan would miss. + self.zfs.mark_background(tenant).await?; let mut r = self.records.lock().await; let k = key(tenant, &id); if let Some(old) = r.tasks.get(&k) { @@ -300,7 +297,8 @@ impl TaskQueue { !r.active.contains(&k) && r.tasks.get(&k).context("no such task")?.terminal(), "task is still active" ); - self.fs.unlink(&self.dir, &k).await?; + let dir = self.zfs.tenant_runtime_dir(tenant, DIR).await?; + tokio::fs::remove_file(dir.join(&k)).await?; r.tasks.remove(&k); Ok(()) } @@ -342,7 +340,18 @@ impl TaskQueue { current.status = Status::Running; current.attempts += 1; self.write(¤t).await?; + // Anchor the attempt before running it, so a task that crashes the + // enclave still spends the attempt: without this the Running record + // rewinds with the crash, the attempt count never advances, and a task + // that brings the enclave down loops on it for ever instead of failing + // at MAX_ATTEMPTS. + let tenant = current.tenant; r.tasks.insert(current.key(), current); + drop(r); + self.zfs + .anchor_tenant(tenant) + .await + .context("anchoring a task attempt")?; Ok(true) } async fn finish(&self, task: &Task, outcome: Result>>) -> Result<()> { @@ -403,6 +412,12 @@ impl TaskQueue { Ok(None) => unreachable!(), } self.write(¤t).await?; + // The outcome and whatever the guest wrote reach the anchor together, + // before anything — a waiting client, a wake — can be told of it. + self.zfs + .anchor_tenant(current.tenant) + .await + .context("anchoring a task outcome")?; // A failure is logged with its reason. The record is sealed, so // otherwise the only thing an operator sees is that five attempts // failed. The text is what the guest could already print to its own @@ -432,7 +447,14 @@ impl TaskQueue { let guest = guest.clone(); workers.spawn(async move { let result = guest.run_background(&queue, &task).await; - queue.finish(&task, result).await + let finished = queue.finish(&task, result).await; + // The task's wake rides on `finish`'s anchor: delivered once + // the outcome it announces is durable, dropped otherwise. + match &finished { + Ok(()) => guest.deliver_wakes(task.tenant), + Err(_) => guest.drop_wakes(task.tenant), + } + finished }); } let next = self.records.lock().await.due.first().map(|(at, _)| *at); @@ -528,8 +550,9 @@ pub fn add_to_linker(linker: &mut wasmtime::component::Linker) -> wasmtim mod tests { use super::*; use crate::clock::{HostClock, TrustedClock}; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, MasterSecret}; + use crate::store::backend::memory::MemoryBackend; + use crate::store::crypto::{KeyMaterial, MasterSecret}; + use crate::zfs::{Disk, Zfs}; use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Debug, Clone)] @@ -546,23 +569,14 @@ mod tests { } } async fn queue(limits: TaskLimits) -> (Arc, Clock) { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([9; 32]), - [1; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); + let fs = Zfs::scratch().await; for tenant in [[1; 16], [2; 16]] { - crate::tenant::tenant_root_by_id(&fs, tenant).await.unwrap(); + fs.tenant_dir(tenant).await.unwrap(); } let clock = Clock(Arc::new(AtomicU64::new(1000))); let queue = TaskQueue::open( fs, - Arc::new(WallClockAdapter::new(Box::new(clock.clone())).unwrap()), + Arc::new(WallClockAdapter::new(Arc::new(clock.clone())).unwrap()), limits, ) .await @@ -627,7 +641,7 @@ mod tests { add(&q, 1, "job", 0).await; let task = q.due().await.unwrap(); assert!(q.begin(&task).await.unwrap()); - let fs = q.fs.clone(); + let fs = q.zfs.clone(); let timer = q.clock.clone(); drop(q); let recovered = TaskQueue::open(fs, timer, Default::default()) @@ -646,7 +660,7 @@ mod tests { assert_eq!(result.status, Status::Completed); assert_eq!(result.result, b"done"); let reopened = TaskQueue::open( - recovered.fs.clone(), + recovered.zfs.clone(), recovered.clock.clone(), Default::default(), ) @@ -759,18 +773,11 @@ mod tests { // separates five real invocations from one cached result replayed five // times. The directory itself exists because the guest creates it // before it looks at the payload. - let root = crate::tenant::existing_tenant_root(&q.fs, [1; 16]) - .await - .unwrap(); - let dir = q.fs.lookup_at(&root, "http-example").await.unwrap(); - let tasks = q.fs.lookup_at(&dir, "tasks").await.unwrap(); - let names: Vec = - q.fs.read_dir(&tasks) - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .collect(); + let tasks = q.zfs.tenant_path([1; 16]).join("http-example/tasks"); + let names: Vec = std::fs::read_dir(tasks) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); assert!( !names.iter().any(|n| n.starts_with("job")), "a task that only ever failed still wrote a result: {names:?}" @@ -796,23 +803,19 @@ mod tests { #[tokio::test] async fn unpublished_files_are_ignored_but_live_corruption_is_refused() { let (q, _) = queue(Default::default()).await; - let h = - q.fs.open(&format!("{DIR}/orphan.tmp"), OpenFlags::create_new()) - .await - .unwrap(); - q.fs.pwrite(&h, 0, b"incomplete").await.unwrap(); - q.fs.close(&h).await.unwrap(); - let recovered = TaskQueue::open(q.fs.clone(), q.clock.clone(), Default::default()) + add(&q, 1, "job", 0).await; // flags tenant 1 and creates its task dir + let dir = q.zfs.tenant_runtime_dir([1; 16], DIR).await.unwrap(); + std::fs::write(dir.join("orphan.tmp"), b"incomplete").unwrap(); + let recovered = TaskQueue::open(q.zfs.clone(), q.clock.clone(), Default::default()) .await .unwrap(); - assert!(recovered.due().await.is_none()); - let h = - q.fs.open(&format!("{DIR}/bad"), OpenFlags::create_new()) - .await - .unwrap(); - q.fs.close(&h).await.unwrap(); assert!( - TaskQueue::open(q.fs.clone(), q.clock.clone(), Default::default()) + recovered.status([1; 16], "job").await.is_ok(), + "the real task was lost" + ); + std::fs::write(dir.join("bad"), b"").unwrap(); + assert!( + TaskQueue::open(q.zfs.clone(), q.clock.clone(), Default::default()) .await .is_err() ); @@ -829,8 +832,8 @@ mod tests { let bytes = std::fs::read(path).expect("build examples/guest-http for wasm32-wasip2"); let (logs, _collector) = crate::guest_io::start(Arc::new(crate::TracingLogSink)); let env = crate::GuestEnvironment::new( - q.fs.clone(), - Box::new(HostClock), + q.zfs.clone(), + Arc::new(HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), &[], &[], @@ -893,7 +896,7 @@ mod tests { ); assert_eq!(request(&handle, 2, "GET", "/tasks/job", b"").await.0, 404); drop(handle); - let reopened = TaskQueue::open(q.fs.clone(), q.clock.clone(), Default::default()) + let reopened = TaskQueue::open(q.zfs.clone(), q.clock.clone(), Default::default()) .await .unwrap(); clock.0.store(20_000, Ordering::Relaxed); @@ -907,16 +910,7 @@ mod tests { ); let record = reopened.status([1; 16], "job").await.unwrap(); assert_eq!(record.result, b"alice"); - assert!(q - .fs - .lookup_at( - &crate::tenant::existing_tenant_root(&q.fs, [2; 16]) - .await - .unwrap(), - "http-example" - ) - .await - .is_err()); + assert!(!q.zfs.tenant_path([2; 16]).join("http-example").exists()); assert_eq!(request(&handle, 1, "GET", "/tasks/job", b"").await.0, 200); } #[tokio::test] @@ -952,22 +946,25 @@ mod tests { assert_eq!(request(&handle, 1, "GET", "/memory", b"").await.0, 200); } #[tokio::test] - async fn published_task_survives_a_fresh_filesystem_mount() { + async fn published_task_survives_reopening_the_pool() { let backend = Arc::new(MemoryBackend::new()); let master = MasterSecret::from_bytes([7; 32]); - let fs = Fs::create( - backend.clone(), + let keys = Arc::new(KeyMaterial::derive(&master, [8; 16]).unwrap()); + let disk = Disk::scratch().unwrap(); + let day = Duration::from_secs(86_400); + let fs = Zfs::create( + &disk, backend.clone(), + keys.clone(), &master, - [8; 16], - Arc::new(Config::default()), + "", + day, + Arc::new(HostClock), ) .await .unwrap(); - crate::tenant::tenant_root_by_id(&fs, [1; 16]) - .await - .unwrap(); - let clock = Arc::new(WallClockAdapter::new(Box::new(HostClock)).unwrap()); + fs.tenant_dir([1; 16]).await.unwrap(); + let clock = Arc::new(WallClockAdapter::new(Arc::new(HostClock)).unwrap()); let q = TaskQueue::open(fs.clone(), clock.clone(), Default::default()) .await .unwrap(); @@ -975,13 +972,15 @@ mod tests { let run_id = q.status([1; 16], "durable").await.unwrap().run_id(); drop(q); drop(fs); - let fs = Fs::mount( - backend.clone(), + let fs = Zfs::open( + &disk, backend, + keys, &master, - [8; 16], - Arc::new(Config::default()), + "", + day, None, + Arc::new(HostClock), ) .await .unwrap(); @@ -1008,22 +1007,11 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[ignore = "requires built guest-http component"] async fn a_recurring_task_fires_again_through_the_running_scheduler() { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([9; 32]), - [1; 16], - Arc::new(Config::default()), - ) - .await - .unwrap(); - crate::tenant::tenant_root_by_id(&fs, [1; 16]) - .await - .unwrap(); + let fs = Zfs::scratch().await; + fs.tenant_dir([1; 16]).await.unwrap(); let q = TaskQueue::open( fs, - Arc::new(WallClockAdapter::new(Box::new(HostClock)).unwrap()), + Arc::new(WallClockAdapter::new(Arc::new(HostClock)).unwrap()), Default::default(), ) .await @@ -1043,21 +1031,14 @@ mod tests { let names = tokio::time::timeout(Duration::from_secs(15), async { loop { assert!(!worker.is_finished(), "scheduler stopped"); - if let Ok(root) = crate::tenant::existing_tenant_root(&q.fs, [1; 16]).await { - if let Ok(dir) = q.fs.lookup_at(&root, "http-example").await { - if let Ok(tasks) = q.fs.lookup_at(&dir, "tasks").await { - let names: Vec = - q.fs.read_dir(&tasks) - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .filter(|n| n.starts_with("beat")) - .collect(); - if names.len() >= 2 { - return names; - } - } + let tasks = q.zfs.tenant_path([1; 16]).join("http-example/tasks"); + if let Ok(entries) = std::fs::read_dir(tasks) { + let names: Vec = entries + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .filter(|n| n.starts_with("beat")) + .collect(); + if names.len() >= 2 { + return names; } } tokio::time::sleep(Duration::from_millis(50)).await; @@ -1103,7 +1084,7 @@ mod tests { .await; let pool = Arc::new(crate::Tenancy::new(Default::default())); let checkout = pool.pool().checkout(&[1; 16]); - let lock = checkout.slot().tenant().clone().lock_owned().await; + let lock = checkout.slot().lock().clone().lock_owned().await; let handle = guest_with_pool(&q, pool).await; add(&q, 1, "blocked", 0).await; add(&q, 2, "ready", 0).await; @@ -1136,13 +1117,10 @@ mod tests { let (q, _) = queue(Default::default()).await; let handle = guest(&q).await; add(&q, 1, "job", 0).await; - let parent = q.fs.lookup_at(&q.fs.root(), "tenants").await.unwrap(); - q.fs.rmdir(&parent, &hex::encode([1; 16])).await.unwrap(); + std::fs::remove_dir_all(q.zfs.tenant_path([1; 16])).unwrap(); let task = q.due().await.unwrap(); assert!(handle.run_background(&q, &task).await.is_err()); - assert!(crate::tenant::existing_tenant_root(&q.fs, [1; 16]) - .await - .is_err()); + assert!(!q.zfs.tenant_path([1; 16]).exists()); } #[tokio::test] #[ignore = "requires built guest-http component"] @@ -1154,9 +1132,7 @@ mod tests { .await; let handle = guest(&q).await; for tenant in 1..=16 { - crate::tenant::tenant_root_by_id(&q.fs, [tenant; 16]) - .await - .unwrap(); + q.zfs.tenant_dir([tenant; 16]).await.unwrap(); for job in 0..3 { add(&q, tenant, &format!("job-{job}"), 0).await; } diff --git a/runtime/src/tenant.rs b/runtime/src/tenant.rs index 034005c..adfbd1b 100644 --- a/runtime/src/tenant.rs +++ b/runtime/src/tenant.rs @@ -1,50 +1,35 @@ -//! One filesystem, one directory per client, and a `/` that means different -//! things to different guests. +//! One pool, one dataset per client, and a `/` that means different things to +//! different guests. //! -//! Every client's data lives under `/tenants//` in the runtime's -//! single mounted filesystem. There is one `Fs`, one `BlockStore`, one block -//! cache and one transaction stream — a client costs a directory, not a mount. +//! Every client's data lives in its own ZFS dataset, `/tenants/`, on the +//! runtime's single pool — see [`crate::zfs`]. A client costs a dataset, not a +//! pool: one disk, one anchor chain, one cache. //! //! ## Where the separation actually is //! //! Not in the guest. A tenant's instance is handed its own directory as its -//! preopen, and [`s3fs_core::Fs::lookup_within`] refuses every way a path can -//! name something above it: absolute paths restart at the tenant's root rather -//! than the filesystem's, `..` stops there, and an absolute symlink target -//! resolves from there too. A guest that tries `/tenants/other/secret` gets -//! its own `tenants/other/secret`, which does not exist. +//! only preopen, and `wasmtime-wasi` resolves every path through `cap-std`, +//! which refuses each way a path can name something above it: `..` stops at +//! the preopen, an absolute path has nothing to start from but it, and a +//! symlink cannot point out of it. A guest that tries `/tenants/other/secret` +//! gets its own `tenants/other/secret`, which does not exist. //! //! That is a capability, not a convention, and it is why this is safe to do -//! with a shared filesystem at all. +//! on a shared pool at all. //! -//! ## Why the identifier is derived +//! ## Where the identifier comes from //! -//! `HKDF(master, sha256(client SPKI))`, so it need never be stored or looked -//! up: the same client lands on the same directory on every boot, from nothing -//! but the TLS handshake. Unguessable without the master secret, so holding a -//! client's certificate does not tell an outsider which directory is theirs. +//! Sixteen bytes derived from the passkey's credential id — see +//! [`crate::auth::credential::tenant_of`]. Nothing is stored for it: a host +//! cannot create a dataset on the pool, so a predictable name gives it +//! nothing. //! //! ## Why there is no separate register //! -//! An earlier design gave each client their own filesystem, and then needed a -//! register to answer "should this client have one?" — because a host who hid -//! a client's root record would otherwise get a fresh, empty filesystem built -//! for them, resetting their policy and emptying their nonce ledger. -//! -//! With one filesystem that question answers itself. `/tenants/` either -//! exists in the Merkle tree or it does not, the tree is covered by one signed -//! root record, and that record is attested by the state-origin receipt at -//! boot. Hiding one client's directory means changing the root hash, which -//! fails the signature before any request is served. **The filesystem is the -//! register.** - -use std::sync::Arc; - -use anyhow::{Context, Result}; -use s3fs_core::{Fs, FsError, Inode}; - -/// The directory holding every tenant's subtree. -const TENANTS_DIR: &str = "tenants"; +//! `/tenants/` either exists in the pool or it does not, and the pool is +//! pinned by an anchor the boot checks. Hiding one client's dataset means +//! serving a pool state no anchor names, which the boot refuses before any +//! request is served. **The pool is the register.** /// A client's arrival. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -54,182 +39,3 @@ pub enum Arrival { /// First contact: it was created. New, } - -/// A client's own directory, and how they arrived at it. -#[derive(Debug, Clone)] -pub struct TenantRoot { - pub scope: Arc, - pub tenant_id: [u8; 16], - pub arrival: Arrival, -} - -/// Find or create a client's directory in the shared filesystem. -/// -/// Cheap by construction: a lookup, and on first contact one `mkdir`. There is -/// no mount, no key derivation for a second filesystem, and no root record to -/// read — the costs the per-filesystem design would have paid on every cold -/// client. -/// Find or create a tenant's directory. -/// -/// Registration mints an id and needs the directory for it; a request resolves -/// an id from a verified assertion and needs the same. One function, so the two -/// cannot disagree about where a tenant lives. -/// -/// Cheap by construction: a lookup, and on first contact one `mkdir`. No mount, -/// no key derivation, no root record to read. -pub async fn tenant_root_by_id(fs: &Arc, tenant_id: [u8; 16]) -> Result { - let name = hex::encode(tenant_id); - let root = fs.root(); - let parent = ensure_dir(fs, &root, TENANTS_DIR).await?; - - // Looked up before it is created, so an arrival can be reported honestly: - // "new" means this runtime had never seen the tenant, which is worth a log - // line and, later, worth a policy. - let arrival = match fs.lookup_at(&parent, &name).await { - Ok(_) => Arrival::Returning, - Err(FsError::NotFound) => Arrival::New, - Err(e) => return Err(anyhow::anyhow!(e)).context("opening a tenant directory"), - }; - let scope = ensure_dir(fs, &parent, &name).await?; - Ok(TenantRoot { - scope, - tenant_id, - arrival, - }) -} - -/// Background work must never create a tenant whose directory is missing. -pub async fn existing_tenant_root(fs: &Arc, tenant_id: [u8; 16]) -> Result> { - let parent = fs.lookup_at(&fs.root(), TENANTS_DIR).await?; - fs.lookup_at(&parent, &hex::encode(tenant_id)) - .await - .context("scheduled tenant no longer exists") -} - -/// Open a directory, creating it if it is not there. -/// -/// `AlreadyExists` is success, not failure: two requests racing to first -/// contact both want the same directory, and whichever loses should find what -/// the winner made rather than report a conflict nobody caused. -pub async fn ensure_dir(fs: &Arc, parent: &Arc, name: &str) -> Result> { - match fs.lookup_at(parent, name).await { - Ok(dir) => Ok(dir), - Err(FsError::NotFound) => match fs.mkdir(parent, name).await { - Ok(dir) => Ok(dir), - Err(FsError::AlreadyExists) => fs - .lookup_at(parent, name) - .await - .map_err(|e| anyhow::anyhow!(e)) - .with_context(|| format!("opening {name} after losing the race to create it")), - Err(e) => Err(anyhow::anyhow!(e)).with_context(|| format!("creating {name}")), - }, - Err(e) => Err(anyhow::anyhow!(e)).with_context(|| format!("opening {name}")), - } -} - -/// Every client with a directory. Diagnostics, and a future sweeper. -pub async fn tenants(fs: &Arc) -> Result> { - let root = fs.root(); - let dir = match fs.lookup_at(&root, TENANTS_DIR).await { - Ok(dir) => dir, - Err(FsError::NotFound) => return Ok(Vec::new()), - Err(e) => return Err(anyhow::anyhow!(e)).context("opening the tenants directory"), - }; - Ok(fs - .read_dir(&dir) - .await - .map_err(|e| anyhow::anyhow!(e)) - .context("listing tenants")? - .into_iter() - .map(|e| e.name) - .collect()) -} - -#[cfg(test)] -mod tests { - use super::*; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, MasterSecret}; - - async fn shared_fs() -> Arc { - let backend = Arc::new(MemoryBackend::new()); - Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("the shared filesystem") - } - - /// A returning client lands where it left its data. If this were not - /// stable their state would look lost on every request. - #[tokio::test] - async fn a_client_returns_to_the_same_directory() { - let fs = shared_fs().await; - let first = tenant_root_by_id(&fs, [0xab; 16]).await.unwrap(); - let second = tenant_root_by_id(&fs, [0xab; 16]).await.unwrap(); - - assert_eq!(first.tenant_id, second.tenant_id); - assert_eq!(first.scope.objid(), second.scope.objid()); - assert_eq!(first.arrival, Arrival::New); - assert_eq!(second.arrival, Arrival::Returning); - } - - #[tokio::test] - async fn two_clients_get_two_directories() { - let fs = shared_fs().await; - let a = tenant_root_by_id(&fs, [0xaa; 16]).await.unwrap(); - let b = tenant_root_by_id(&fs, [0xbb; 16]).await.unwrap(); - assert_ne!(a.tenant_id, b.tenant_id); - assert_ne!(a.scope.objid(), b.scope.objid()); - } - - /// A registration and a later request must land in the same directory, or - /// a passkey would be registered against storage it could never reach. - #[tokio::test] - async fn registration_and_a_request_land_in_the_same_directory() { - let fs = shared_fs().await; - let at_registration = tenant_root_by_id(&fs, [0xcd; 16]).await.unwrap(); - let at_request = tenant_root_by_id(&fs, [0xcd; 16]).await.unwrap(); - assert_eq!(at_request.scope.objid(), at_registration.scope.objid()); - assert_eq!(at_registration.arrival, Arrival::New); - assert_eq!(at_request.arrival, Arrival::Returning); - } - - /// Losing the race to create a directory must not be an error: both - /// callers want the same directory. - #[tokio::test] - async fn ensure_dir_is_idempotent() { - let fs = shared_fs().await; - let root = fs.root(); - let first = ensure_dir(&fs, &root, "runtime").await.unwrap(); - let second = ensure_dir(&fs, &root, "runtime").await.unwrap(); - assert_eq!(first.objid(), second.objid()); - } - - #[tokio::test] - async fn tenants_are_listed_and_none_is_not_an_error() { - let fs = shared_fs().await; - assert!(tenants(&fs).await.unwrap().is_empty()); - - let a = tenant_root_by_id(&fs, [0xaa; 16]).await.unwrap(); - let b = tenant_root_by_id(&fs, [0xbb; 16]).await.unwrap(); - let listed = tenants(&fs).await.unwrap(); - assert_eq!(listed.len(), 2); - assert!(listed.contains(&hex::encode(a.tenant_id))); - assert!(listed.contains(&hex::encode(b.tenant_id))); - } - - /// Tenant directories sit under one parent, and that parent is above every - /// tenant's scope — which is what keeps `/runtime/` unreachable from a - /// guest too. - #[tokio::test] - async fn a_tenant_directory_is_not_the_filesystem_root() { - let fs = shared_fs().await; - let t = tenant_root_by_id(&fs, [0xab; 16]).await.unwrap(); - assert_ne!(t.scope.objid(), fs.root().objid()); - } -} diff --git a/runtime/src/testing/mod.rs b/runtime/src/testing/mod.rs index e7ad083..6382993 100644 --- a/runtime/src/testing/mod.rs +++ b/runtime/src/testing/mod.rs @@ -146,19 +146,10 @@ impl Enclave { AuthEndpoints, ChallengeStore, FilesystemCredentials, Gate, GuestEnvironment, HostClock, PoolLimits, ServeConfig, Tenancy, TlsIdentity, }; - use s3fs_core::backend::memory::MemoryBackend; - use s3fs_core::{Config, Fs, MasterSecret}; - - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([9u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .context("creating the harness filesystem")?; + + // A directory standing in for the pool: the harness runs without root + // and without ZFS. What ZFS itself does is the emulator's to prove. + let fs = crate::zfs::Zfs::scratch().await; let nsm = Arc::new(SigningNsm::new()?); // As `main` does, before anything could attest: the documents this @@ -187,8 +178,8 @@ impl Enclave { // which is what a harness wants. Production drains it explicitly. let (logs, _collector) = crate::guest_io::start(Arc::new(crate::TracingLogSink)); // As the binary does it: what the guest file carries is the guest's environment. - let env = crate::env::with_guest_settings(Vec::new(), &builder.guest)?; - let guest_env = GuestEnvironment::new(fs, Box::new(HostClock), entropy, &env, &[], logs) + let env = crate::env::from_guest(&builder.guest)?; + let guest_env = GuestEnvironment::new(fs, Arc::new(HostClock), entropy, &env, &[], logs) .context("building the guest environment")?; let push = if builder.notify { diff --git a/runtime/src/testing/nsm.rs b/runtime/src/testing/nsm.rs index cedf10d..d4aae01 100644 --- a/runtime/src/testing/nsm.rs +++ b/runtime/src/testing/nsm.rs @@ -17,8 +17,8 @@ pub struct SigningNsm { chain: nitro_attestation::testing::TestChain, pcrs: Mutex>, /// Counted, so successive draws differ. A device that returned the same - /// bytes every time would mint one tenant id for every passkey, which is - /// the isolation property quietly inverted. + /// bytes every time would give every registration, challenge and token the + /// same id. draws: AtomicU64, } diff --git a/runtime/src/wasi/bindings.rs b/runtime/src/wasi/bindings.rs deleted file mode 100644 index e566b59..0000000 --- a/runtime/src/wasi/bindings.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! `wasmtime::component::bindgen!` invocation against the vendored WASI 0.2.6 -//! WIT. Generates the host traits we then implement in `host_*.rs`. -//! -//! `with:` reuses `wasmtime-wasi`'s pre-generated bindings for `wasi:io` and -//! `wasi:clocks` so streams interoperate with the rest of the runtime, then -//! plugs our own resource types in for `wasi:filesystem/types/descriptor` -//! and `wasi:filesystem/types/directory-entry-stream`. - -wasmtime::component::bindgen!({ - path: "../wit", - world: "enclave-runtime", - imports: { default: async | trappable }, - trappable_error_type: { - "wasi:filesystem/types.error-code" => crate::wasi::error_map::S3WasiFsError, - }, - with: { - "wasi:io/poll": wasmtime_wasi::p2::bindings::io::poll, - "wasi:io/streams": wasmtime_wasi::p2::bindings::io::streams, - "wasi:io/error": wasmtime_wasi::p2::bindings::io::error, - "wasi:clocks/wall-clock": wasmtime_wasi::p2::bindings::clocks::wall_clock, - "wasi:filesystem/types.descriptor": crate::wasi::descriptors::Descriptor, - "wasi:filesystem/types.directory-entry-stream": crate::wasi::descriptors::DirectoryEntryStream, - }, -}); diff --git a/runtime/src/wasi/descriptors.rs b/runtime/src/wasi/descriptors.rs deleted file mode 100644 index abb5aed..0000000 --- a/runtime/src/wasi/descriptors.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Resource types stored in the wasmtime `ResourceTable`. - -use std::sync::Arc; - -use s3fs_core::{FileHandle, Inode}; - -/// Wasmtime resource handle for `wasi:filesystem/types/descriptor`. -/// -/// A descriptor is one of two flavours: -/// - **File descriptor**: an `Arc` plus the directory it was -/// opened under, so paths used in `*-at` operations resolve relative to the -/// right place. -/// - **Directory descriptor**: the directory inode itself. Reads and writes -/// against one return `is-directory`. -/// -/// The parent is captured at open time rather than walked back to on demand: -/// `at_base` is synchronous, and the parent link now lives in the dnode, which -/// would make resolving it an I/O operation. -#[derive(Debug)] -pub enum Descriptor { - File { - handle: Arc, - parent: Arc, - }, - Dir { - inode: Arc, - }, -} - -impl Descriptor { - /// The inode this descriptor opens or is rooted under. For files, the - /// file's own inode; for dirs, the directory inode itself. - pub fn inode(&self) -> &Arc { - match self { - Descriptor::File { handle, .. } => &handle.inode, - Descriptor::Dir { inode } => inode, - } - } - - /// The directory to use as the base for `*-at` path resolution. - pub fn at_base(&self) -> Arc { - match self { - Descriptor::Dir { inode } => inode.clone(), - Descriptor::File { parent, .. } => parent.clone(), - } - } -} - -/// Wasmtime resource handle for `wasi:filesystem/types/directory-entry-stream`. -/// -/// Holds a snapshot of directory entries plus an iteration cursor. -#[derive(Debug)] -pub struct DirectoryEntryStream { - pub entries: Vec, - pub cursor: usize, -} - -impl DirectoryEntryStream { - pub fn new(entries: Vec) -> Self { - Self { entries, cursor: 0 } - } -} diff --git a/runtime/src/wasi/error_map.rs b/runtime/src/wasi/error_map.rs deleted file mode 100644 index d8646db..0000000 --- a/runtime/src/wasi/error_map.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Error mapping between `s3fs_core::FsError` and the WIT -//! `wasi:filesystem/types/error-code` enum. -//! -//! `S3WasiFsError` is the trappable wrapper bindgen wants — it holds either -//! a clean `ErrorCode` value (returned to the guest) or an `anyhow::Error` -//! (which becomes a trap). - -use s3fs_core::FsError; -use wasmtime_wasi::TrappableError; - -use crate::wasi::bindings::wasi::filesystem::types::ErrorCode; - -/// The host-side error type the bindgen-generated traits return. -pub type S3WasiFsError = TrappableError; - -/// Convenience alias. -pub type S3WasiFsResult = Result; - -/// Translate an `s3fs_core::FsError` to a WIT `error-code`. Categories that -/// don't have a clean WIT equivalent fall back to `Io`. -pub fn from_fs(e: FsError) -> ErrorCode { - match e { - FsError::NotFound => ErrorCode::NoEntry, - FsError::AccessDenied => ErrorCode::Access, - FsError::AlreadyExists => ErrorCode::Exist, - FsError::IsDirectory => ErrorCode::IsDirectory, - FsError::NotDirectory => ErrorCode::NotDirectory, - FsError::NotEmpty => ErrorCode::NotEmpty, - FsError::NameTooLong => ErrorCode::NameTooLong, - FsError::IllegalByteSequence => ErrorCode::IllegalByteSequence, - FsError::FileTooLarge => ErrorCode::FileTooLarge, - FsError::NotSupported => ErrorCode::Unsupported, - FsError::NotPermitted => ErrorCode::NotPermitted, - FsError::Invalid(_) => ErrorCode::Invalid, - FsError::OutOfMemory => ErrorCode::InsufficientMemory, - FsError::Loop => ErrorCode::Loop, - FsError::CrossDevice => ErrorCode::CrossDevice, - FsError::BadDescriptor => ErrorCode::BadDescriptor, - // A guest can never see this: it is raised while deciding whether to - // mount, long before any descriptor exists. Mapped rather than left to - // the catch-all so that adding a variant to `FsError` keeps producing - // a compile error here, which is how this arm was noticed. - FsError::NoFilesystem => ErrorCode::NoEntry, - FsError::WouldBlock => ErrorCode::WouldBlock, - // PreconditionFailed (CAS conflict) — surface as Exist for `O_EXCL`- - // style failures; callers that want different semantics can re-map - // before this point. - FsError::Conflict => ErrorCode::Exist, - // Verification failures. WASI has no "the storage lied to us" code, so - // these surface as `Io` — the guest sees an unreadable filesystem - // rather than plausible-looking wrong bytes, which is the whole point. - FsError::Integrity(_) | FsError::Rollback { .. } => ErrorCode::Io, - FsError::IoTimeout => ErrorCode::Io, - FsError::Io(_) | FsError::Network(_) => ErrorCode::Io, - } -} - -/// Helper used throughout the host impls: convert any `Result` -/// into the trappable wrapper bindgen expects. -pub trait IntoS3WasiResult { - fn into_wasi(self) -> S3WasiFsResult; -} - -impl IntoS3WasiResult for Result { - fn into_wasi(self) -> S3WasiFsResult { - self.map_err(|e| S3WasiFsError::from(from_fs(e))) - } -} diff --git a/runtime/src/wasi/host_filesystem.rs b/runtime/src/wasi/host_filesystem.rs deleted file mode 100644 index b218da8..0000000 --- a/runtime/src/wasi/host_filesystem.rs +++ /dev/null @@ -1,677 +0,0 @@ -//! `wasi:filesystem/types::Host` and the per-resource trait implementations. -//! -//! For v1 we implement everything the Compatibility Matrix marks as ✅ or ⚠️ -//! using the synchronous `descriptor.read` / `descriptor.write` paths. -//! Stream-based ops (`read-via-stream`, `write-via-stream`, -//! `append-via-stream`) currently return `error-code::unsupported`; wiring -//! them through `wasmtime-wasi`'s stream machinery is reserved for a follow- -//! up. - -use s3fs_core::{Attrs, Inode, InodeKind, OpenFlags}; -use wasmtime::component::Resource; -use wasmtime::Result; -use wasmtime_wasi::p2::bindings::io::streams::{InputStream, OutputStream}; - -use crate::wasi::bindings::wasi::filesystem::types::{ - self as wit, Descriptor as WitDescriptor, DescriptorFlags, DescriptorStat, DescriptorType, - DirectoryEntry, DirectoryEntryStream as WitDirectoryEntryStream, ErrorCode, Filesize, - HostDescriptor, HostDirectoryEntryStream, MetadataHashValue, NewTimestamp, - OpenFlags as WitOpenFlags, PathFlags, -}; -use crate::wasi::descriptors::{Descriptor, DirectoryEntryStream}; -use crate::wasi::error_map::{from_fs, IntoS3WasiResult, S3WasiFsError, S3WasiFsResult}; -use crate::wasi::view::S3FsCtxView; - -// --------------------------------------------------------------------------- -// types::Host -// --------------------------------------------------------------------------- - -impl wit::Host for S3FsCtxView<'_> { - fn convert_error_code(&mut self, err: S3WasiFsError) -> Result { - err.downcast() - } - - async fn filesystem_error_code( - &mut self, - _err: Resource, - ) -> Result> { - // Streams are not yet wired up; nothing to inspect. - Ok(None) - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn get_descriptor_owned( - table: &mut wasmtime::component::ResourceTable, - fd: &Resource, -) -> Result { - // Clone via match — both variants are cheap (Arc clones). - let d = table.get(fd).map_err(wasmtime::Error::from)?; - Ok(match d { - Descriptor::File { handle, parent } => Descriptor::File { - handle: handle.clone(), - parent: parent.clone(), - }, - Descriptor::Dir { inode } => Descriptor::Dir { - inode: inode.clone(), - }, - }) -} - -fn descriptor_type_for(kind: InodeKind) -> DescriptorType { - match kind { - InodeKind::RegularFile => DescriptorType::RegularFile, - InodeKind::Directory => DescriptorType::Directory, - InodeKind::Symlink => DescriptorType::SymbolicLink, - } -} - -/// Every field here is now read straight from the dnode. The link count used -/// to be hardcoded to 1 and the three timestamps were all the same value, -/// because the previous engine had nowhere to keep them. -fn stat_from_attrs(attrs: &Attrs) -> DescriptorStat { - DescriptorStat { - type_: descriptor_type_for(attrs.kind), - link_count: u64::from(attrs.nlink), - size: attrs.size, - data_access_timestamp: Some(systemtime_to_wit(attrs.atime)), - data_modification_timestamp: Some(systemtime_to_wit(attrs.mtime)), - status_change_timestamp: Some(systemtime_to_wit(attrs.ctime)), - } -} - -fn systemtime_to_wit( - t: std::time::SystemTime, -) -> wasmtime_wasi::p2::bindings::clocks::wall_clock::Datetime { - let d = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - wasmtime_wasi::p2::bindings::clocks::wall_clock::Datetime { - seconds: d.as_secs(), - nanoseconds: d.subsec_nanos(), - } -} - -/// Convert a WIT `new-timestamp` to `Option` per WASI semantics: -/// - `NoChange` → `None` (don't update this field) -/// - `Now` → `Some(now)` -/// - `Timestamp(dt)` → `Some(epoch + dt.seconds + dt.nanoseconds)` -fn wit_timestamp_to_systemtime( - ts: NewTimestamp, - clock: &crate::clock::WallClockAdapter, -) -> Option { - use wasmtime_wasi::HostWallClock; - match ts { - NewTimestamp::NoChange => None, - // The runtime's clock, not the host's: a guest that reads - // `wall-clock` and then calls `set-times` with "now" must not see two - // different answers. - NewTimestamp::Now => Some(std::time::UNIX_EPOCH + clock.now()), - NewTimestamp::Timestamp(dt) => { - Some(std::time::UNIX_EPOCH + std::time::Duration::new(dt.seconds, dt.nanoseconds)) - } - } -} - -fn open_flags_from_wit(flags: WitOpenFlags) -> OpenFlags { - OpenFlags { - // read/write come from descriptor-flags, not open-flags, in P2. - read: false, - write: false, - append: false, - truncate: flags.contains(WitOpenFlags::TRUNCATE), - create: flags.contains(WitOpenFlags::CREATE), - exclusive: flags.contains(WitOpenFlags::EXCLUSIVE), - } -} - -/// Split a relative path into `(parent_path, basename)` for `*-at` ops. -fn split_at_path(p: &str) -> (&str, &str) { - match p.rfind('/') { - Some(i) => (&p[..i], &p[i + 1..]), - None => ("", p), - } -} - -async fn resolve_parent( - fs: &s3fs_core::Fs, - scope: &std::sync::Arc, - base: &std::sync::Arc, - parent_path: &str, -) -> S3WasiFsResult> { - if parent_path.is_empty() { - Ok(base.clone()) - } else { - fs.lookup_within(scope, base, parent_path) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e))) - } -} - -// --------------------------------------------------------------------------- -// HostDescriptor — every method in the WIT -// --------------------------------------------------------------------------- - -impl HostDescriptor for S3FsCtxView<'_> { - async fn read_via_stream( - &mut self, - fd: Resource, - offset: Filesize, - ) -> Result, S3WasiFsError> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - let s: wasmtime_wasi::p2::DynInputStream = Box::new( - crate::wasi::streams::S3InputStream::read_at(self.fs.clone(), handle, offset), - ); - let res = self - .table - .push(s) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))?; - Ok(res) - } - - async fn write_via_stream( - &mut self, - fd: Resource, - offset: Filesize, - ) -> Result, S3WasiFsError> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - let s: wasmtime_wasi::p2::DynOutputStream = Box::new( - crate::wasi::streams::S3OutputStream::write_at(self.fs.clone(), handle, offset), - ); - let res = self - .table - .push(s) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))?; - Ok(res) - } - - async fn append_via_stream( - &mut self, - fd: Resource, - ) -> Result, S3WasiFsError> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - let offset = handle.size().await; - let s: wasmtime_wasi::p2::DynOutputStream = Box::new( - crate::wasi::streams::S3OutputStream::write_at(self.fs.clone(), handle, offset), - ); - let res = self - .table - .push(s) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))?; - Ok(res) - } - - async fn advise( - &mut self, - _fd: Resource, - _offset: Filesize, - _length: Filesize, - _advice: wit::Advice, - ) -> S3WasiFsResult<()> { - Ok(()) - } - - async fn sync_data(&mut self, fd: Resource) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - match d { - Descriptor::File { handle, .. } => self.fs.sync(&handle).await.into_wasi(), - Descriptor::Dir { .. } => Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - } - } - - async fn get_flags(&mut self, _fd: Resource) -> S3WasiFsResult { - Ok(DescriptorFlags::READ | DescriptorFlags::WRITE | DescriptorFlags::DATA_INTEGRITY_SYNC) - } - - async fn get_type(&mut self, fd: Resource) -> S3WasiFsResult { - let inode = self - .table - .get(&fd) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))? - .inode() - .clone(); - let attrs = self.fs.stat(&inode).await.map_err(from_fs)?; - Ok(descriptor_type_for(attrs.kind)) - } - - async fn set_size( - &mut self, - fd: Resource, - size: Filesize, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - self.fs.set_size(&handle, size).await.into_wasi() - } - - async fn set_times( - &mut self, - fd: Resource, - data_access_timestamp: NewTimestamp, - data_modification_timestamp: NewTimestamp, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - let atime = wit_timestamp_to_systemtime(data_access_timestamp, self.clock); - let mtime = wit_timestamp_to_systemtime(data_modification_timestamp, self.clock); - self.fs.set_times(&handle, atime, mtime).await.into_wasi() - } - - async fn read( - &mut self, - fd: Resource, - length: Filesize, - offset: Filesize, - ) -> S3WasiFsResult<(Vec, bool)> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - let bytes = self - .fs - .pread(&handle, offset, length as usize) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e)))?; - let eof = bytes.is_empty() || (bytes.len() as u64) < length; - Ok((bytes.to_vec(), eof)) - } - - async fn write( - &mut self, - fd: Resource, - buffer: Vec, - offset: Filesize, - ) -> S3WasiFsResult { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let handle = match d { - Descriptor::File { handle, .. } => handle, - Descriptor::Dir { .. } => return Err(S3WasiFsError::from(ErrorCode::IsDirectory)), - }; - let n = self - .fs - .pwrite(&handle, offset, &buffer) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e)))?; - Ok(n as u64) - } - - async fn read_directory( - &mut self, - fd: Resource, - ) -> S3WasiFsResult> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let dir = match d { - Descriptor::Dir { inode } => inode, - Descriptor::File { .. } => return Err(S3WasiFsError::from(ErrorCode::NotDirectory)), - }; - let entries = self - .fs - .read_dir(&dir) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e)))?; - let res = self - .table - .push(DirectoryEntryStream::new(entries)) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))?; - Ok(res) - } - - async fn sync(&mut self, fd: Resource) -> S3WasiFsResult<()> { - self.sync_data(fd).await - } - - async fn create_directory_at( - &mut self, - fd: Resource, - path: String, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let (parent_path, name) = split_at_path(&path); - let parent = resolve_parent(self.fs, self.scope, &base, parent_path).await?; - self.fs.mkdir(&parent, name).await.map(|_| ()).into_wasi() - } - - async fn stat(&mut self, fd: Resource) -> S3WasiFsResult { - let inode = self - .table - .get(&fd) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))? - .inode() - .clone(); - let attrs = self.fs.stat(&inode).await.map_err(from_fs)?; - Ok(stat_from_attrs(&attrs)) - } - - async fn stat_at( - &mut self, - fd: Resource, - path_flags: PathFlags, - path: String, - ) -> S3WasiFsResult { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let follow = path_flags.contains(PathFlags::SYMLINK_FOLLOW); - let attrs = self - .fs - .stat_at(&base, &path, follow) - .await - .map_err(from_fs)?; - Ok(stat_from_attrs(&attrs)) - } - - async fn set_times_at( - &mut self, - fd: Resource, - path_flags: PathFlags, - path: String, - data_access_timestamp: NewTimestamp, - data_modification_timestamp: NewTimestamp, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let follow = path_flags.contains(PathFlags::SYMLINK_FOLLOW); - let atime = wit_timestamp_to_systemtime(data_access_timestamp, self.clock); - let mtime = wit_timestamp_to_systemtime(data_modification_timestamp, self.clock); - self.fs - .set_times_at(&base, &path, atime, mtime, follow) - .await - .into_wasi() - } - - async fn link_at( - &mut self, - fd: Resource, - old_path_flags: PathFlags, - old_path: String, - new_descriptor: Resource, - new_path: String, - ) -> S3WasiFsResult<()> { - let old_d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let new_d = - get_descriptor_owned(self.table, &new_descriptor).map_err(S3WasiFsError::trap)?; - let old_base = old_d.at_base(); - let new_base = new_d.at_base(); - let (parent_path, name) = split_at_path(&new_path); - let new_parent = resolve_parent(self.fs, self.scope, &new_base, parent_path).await?; - - self.fs - .link_within( - self.scope, - &old_base, - &old_path, - &new_parent, - name, - old_path_flags.contains(PathFlags::SYMLINK_FOLLOW), - ) - .await - .into_wasi() - } - - async fn open_at( - &mut self, - fd: Resource, - path_flags: PathFlags, - path: String, - open_flags: WitOpenFlags, - descriptor_flags: DescriptorFlags, - ) -> S3WasiFsResult> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - - let mut flags = open_flags_from_wit(open_flags); - flags.read = descriptor_flags.contains(DescriptorFlags::READ); - flags.write = descriptor_flags.contains(DescriptorFlags::WRITE); - - let want_dir = open_flags.contains(WitOpenFlags::DIRECTORY); - - // O_NOFOLLOW (path-flags::symlink-follow cleared): if the leaf is a - // symlink, return Loop per WASI spec rather than following. - if !path_flags.contains(PathFlags::SYMLINK_FOLLOW) { - // Probe the leaf without following. NotFound is fine — open_at - // may be creating. Actual symlink → Loop. - if let Ok(probe) = self - .fs - .lookup_within_no_follow(self.scope, &base, &path) - .await - { - if matches!( - self.fs.stat(&probe).await.map(|a| a.kind), - Ok(InodeKind::Symlink) - ) { - return Err(S3WasiFsError::from(ErrorCode::Loop)); - } - } - } - - let handle = self - .fs - .open_within(self.scope, &base, &path, flags) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e)))?; - - let is_dir = matches!( - self.fs.stat(&handle.inode).await.map_err(from_fs)?.kind, - InodeKind::Directory - ); - let descriptor = if is_dir { - // Don't keep a file handle for a directory. - self.fs - .close(&handle) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e)))?; - Descriptor::Dir { - inode: handle.inode.clone(), - } - } else { - if want_dir { - self.fs - .close(&handle) - .await - .map_err(|e| S3WasiFsError::from(from_fs(e)))?; - return Err(S3WasiFsError::from(ErrorCode::NotDirectory)); - } - Descriptor::File { - parent: self - .fs - .parent_within(self.scope, &handle.inode) - .await - .map_err(from_fs)?, - handle, - } - }; - - let res = self - .table - .push(descriptor) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))?; - Ok(res) - } - - async fn readlink_at( - &mut self, - fd: Resource, - path: String, - ) -> S3WasiFsResult { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let (parent_path, name) = split_at_path(&path); - let parent = resolve_parent(self.fs, self.scope, &base, parent_path).await?; - self.fs.readlink_at(&parent, name).await.into_wasi() - } - - async fn remove_directory_at( - &mut self, - fd: Resource, - path: String, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let (parent_path, name) = split_at_path(&path); - let parent = resolve_parent(self.fs, self.scope, &base, parent_path).await?; - self.fs.rmdir(&parent, name).await.into_wasi() - } - - async fn rename_at( - &mut self, - fd: Resource, - old_path: String, - new_descriptor: Resource, - new_path: String, - ) -> S3WasiFsResult<()> { - let old_d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let new_d = - get_descriptor_owned(self.table, &new_descriptor).map_err(S3WasiFsError::trap)?; - let old_base = old_d.at_base(); - let new_base = new_d.at_base(); - let (op, oname) = split_at_path(&old_path); - let (np, nname) = split_at_path(&new_path); - let old_parent = resolve_parent(self.fs, self.scope, &old_base, op).await?; - let new_parent = resolve_parent(self.fs, self.scope, &new_base, np).await?; - self.fs - .rename(&old_parent, oname, &new_parent, nname) - .await - .into_wasi() - } - - async fn symlink_at( - &mut self, - fd: Resource, - old_path: String, - new_path: String, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let (parent_path, name) = split_at_path(&new_path); - let parent = resolve_parent(self.fs, self.scope, &base, parent_path).await?; - self.fs - .symlink_at(&parent, name, &old_path) - .await - .map(|_| ()) - .into_wasi() - } - - async fn unlink_file_at( - &mut self, - fd: Resource, - path: String, - ) -> S3WasiFsResult<()> { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let (parent_path, name) = split_at_path(&path); - let parent = resolve_parent(self.fs, self.scope, &base, parent_path).await?; - self.fs.unlink(&parent, name).await.into_wasi() - } - - async fn is_same_object( - &mut self, - a: Resource, - b: Resource, - ) -> Result { - // Object ids are never reused and the generation distinguishes a - // reused slot, so this is exact rather than a hash of an ETag. - let da = **self.table.get(&a)?.inode(); - let db = **self.table.get(&b)?.inode(); - Ok(da == db) - } - - async fn metadata_hash( - &mut self, - fd: Resource, - ) -> S3WasiFsResult { - let inode = self - .table - .get(&fd) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))? - .inode() - .clone(); - Ok(metadata_hash_for(&inode)) - } - - async fn metadata_hash_at( - &mut self, - fd: Resource, - _path_flags: PathFlags, - path: String, - ) -> S3WasiFsResult { - let d = get_descriptor_owned(self.table, &fd).map_err(S3WasiFsError::trap)?; - let base = d.at_base(); - let follow = _path_flags.contains(PathFlags::SYMLINK_FOLLOW); - let target = self - .fs - .resolve_for_hash(&base, &path, follow) - .await - .map_err(from_fs)?; - Ok(metadata_hash_for(&target)) - } - - async fn drop(&mut self, fd: Resource) -> Result<()> { - let d = self.table.delete(fd)?; - if let Descriptor::File { handle, .. } = d { - // Best-effort close, which flushes a writable handle. - let _ = self.fs.close(&handle).await; - } - Ok(()) - } -} - -/// Identity, not content: the object id and its generation. WASI only -/// requires that the value differ when the objects differ, and this pair is -/// exactly that — stable across mounts, and never recycled. -fn metadata_hash_for(inode: &Inode) -> MetadataHashValue { - MetadataHashValue { - lower: inode.objid(), - upper: inode.gen, - } -} - -// --------------------------------------------------------------------------- -// HostDirectoryEntryStream -// --------------------------------------------------------------------------- - -impl HostDirectoryEntryStream for S3FsCtxView<'_> { - async fn read_directory_entry( - &mut self, - stream: Resource, - ) -> S3WasiFsResult> { - let s = self - .table - .get_mut(&stream) - .map_err(|e| S3WasiFsError::trap(wasmtime::Error::from(e)))?; - if s.cursor >= s.entries.len() { - return Ok(None); - } - let entry = &s.entries[s.cursor]; - let dir_entry = DirectoryEntry { - type_: descriptor_type_for(entry.kind), - name: entry.name.clone(), - }; - s.cursor += 1; - Ok(Some(dir_entry)) - } - - async fn drop(&mut self, stream: Resource) -> Result<()> { - let _ = self.table.delete(stream)?; - Ok(()) - } -} diff --git a/runtime/src/wasi/host_preopens.rs b/runtime/src/wasi/host_preopens.rs deleted file mode 100644 index 047c12b..0000000 --- a/runtime/src/wasi/host_preopens.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! `wasi:filesystem/preopens::Host` — exposes the single root descriptor. - -use wasmtime::component::Resource; -use wasmtime::Result; - -use crate::wasi::bindings::wasi::filesystem::preopens::Host; -use crate::wasi::bindings::wasi::filesystem::types::Descriptor as WitDescriptor; -use crate::wasi::descriptors::Descriptor; -use crate::wasi::view::S3FsCtxView; - -impl Host for S3FsCtxView<'_> { - async fn get_directories(&mut self) -> Result, String)>> { - // The scope, not the filesystem root. This is the only descriptor a - // guest is ever handed for free, so it is the only place a tenant's - // view of `/` has to be established — everything else is derived from - // it by resolution that cannot leave it. - let root = self.scope.clone(); - let descriptor = self.table.push(Descriptor::Dir { inode: root })?; - Ok(vec![(descriptor, self.fs.config.mount_path.clone())]) - } -} diff --git a/runtime/src/wasi/mod.rs b/runtime/src/wasi/mod.rs deleted file mode 100644 index 1800145..0000000 --- a/runtime/src/wasi/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! `wasi:filesystem@0.2.x` implemented over `s3fs-core`'s `Fs`. -//! -//! Only `wasi:filesystem/{types,preopens}` lives here. Everything else a guest -//! needs — `wasi:io`, `wasi:cli`, clocks, random, sockets — comes from -//! `wasmtime-wasi` unchanged, and [`crate::linker`] assembles the two. -//! -//! This module depends on nothing AWS-specific: it is written against -//! `s3fs_core::Fs`, so it works over any [`s3fs_core::backend::Backend`], -//! including the in-memory one. That is why `mount` — the only part that needs -//! the AWS SDK — sits behind a feature flag rather than here. - -pub mod bindings; -pub mod descriptors; -pub mod error_map; -pub mod host_filesystem; -pub mod host_preopens; -pub mod streams; -pub mod view; - -pub use descriptors::{Descriptor, DirectoryEntryStream}; -pub use view::{S3FsCtxView, S3WasiView}; - -use wasmtime::component::{HasData, Linker}; -use wasmtime::Result; - -/// `HasData` marker so bindgen knows the trait impls live on -/// [`S3FsCtxView<'_>`]. -pub struct HasS3Fs; -impl HasData for HasS3Fs { - type Data<'a> = S3FsCtxView<'a>; -} - -/// Add `wasi:filesystem/{types,preopens}` to `linker`, backed by the `Fs` the -/// store yields through [`S3WasiView`]. -/// -/// Public and generic over `T` so a host with its own state type can take just -/// the filesystem and build the rest of its linker however it likes. -/// [`crate::build_linker`] is the batteries-included version. -pub fn add_filesystem_to_linker(linker: &mut Linker) -> Result<()> { - fn getter(t: &mut T) -> S3FsCtxView<'_> { - t.s3fs_view() - } - bindings::wasi::filesystem::types::add_to_linker::(linker, getter::)?; - bindings::wasi::filesystem::preopens::add_to_linker::(linker, getter::)?; - Ok(()) -} diff --git a/runtime/src/wasi/streams.rs b/runtime/src/wasi/streams.rs deleted file mode 100644 index 9510610..0000000 --- a/runtime/src/wasi/streams.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! `wasi:io/streams` impls over `s3fs-core::Fs::pread` / `pwrite`. -//! -//! The strategy keeps the synchronous `read`/`write`/`check_write`/`flush` -//! trait methods cheap by deferring all real I/O to `Pollable::ready()`, -//! which IS async — write/read just queue a request and ready does the -//! actual `pwrite`/`pread` await. Guests that follow the standard WASI -//! pattern (write → poll/block → check_write → write) drive this correctly. - -use std::sync::Arc; - -use async_trait::async_trait; -use bytes::Bytes; -use s3fs_core::{FileHandle, Fs}; -use wasmtime_wasi::p2::{InputStream, OutputStream, Pollable, StreamError, StreamResult}; - -use crate::wasi::error_map::from_fs; - -/// Soft cap on `check_write` permits. Big enough to fit a typical write -/// buffer in one shot. -const WRITE_PERMIT: usize = 64 * 1024; - -#[derive(Debug)] -pub struct S3OutputStream { - fs: Arc, - handle: Arc, - position: u64, - pending: Option, - last_error: Option, - closed: bool, -} - -impl S3OutputStream { - pub fn write_at(fs: Arc, handle: Arc, offset: u64) -> Self { - Self { - fs, - handle, - position: offset, - pending: None, - last_error: None, - closed: false, - } - } -} - -#[async_trait] -impl Pollable for S3OutputStream { - async fn ready(&mut self) { - if let Some(bytes) = self.pending.take() { - let len = bytes.len(); - match self.fs.pwrite(&self.handle, self.position, &bytes).await { - Ok(written) => { - debug_assert_eq!(written, len); - self.position += written as u64; - } - Err(e) => { - self.last_error = Some(wasmtime::Error::msg(format!("{:?}", from_fs(e)))); - } - } - } - } -} - -impl OutputStream for S3OutputStream { - fn check_write(&mut self) -> StreamResult { - if self.closed { - return Err(StreamError::Closed); - } - if let Some(e) = self.last_error.take() { - return Err(StreamError::LastOperationFailed(e)); - } - if self.pending.is_some() { - // Caller must wait on `ready()` before writing more. - return Ok(0); - } - Ok(WRITE_PERMIT) - } - - fn write(&mut self, bytes: Bytes) -> StreamResult<()> { - if self.closed { - return Err(StreamError::Closed); - } - if self.pending.is_some() { - return Err(StreamError::trap( - "write called before check_write returned a positive permit", - )); - } - if bytes.is_empty() { - return Ok(()); - } - self.pending = Some(bytes); - Ok(()) - } - - fn flush(&mut self) -> StreamResult<()> { - // Pending writes are drained by `ready()`. We don't sync to S3 here — - // the guest must call `descriptor.sync()` explicitly for durability. - Ok(()) - } -} - -#[derive(Debug)] -pub struct S3InputStream { - fs: Arc, - handle: Arc, - position: u64, - /// Pending request size — set by `read`, consumed by `ready`. - pending: Option, - /// Result of the last fetch, surfaced on next `read`. - buffered: Option, - last_error: Option, - eof: bool, -} - -impl S3InputStream { - pub fn read_at(fs: Arc, handle: Arc, offset: u64) -> Self { - Self { - fs, - handle, - position: offset, - pending: None, - buffered: None, - last_error: None, - eof: false, - } - } -} - -#[async_trait] -impl Pollable for S3InputStream { - async fn ready(&mut self) { - if self.buffered.is_some() || self.eof || self.last_error.is_some() { - return; - } - let size = self.pending.take().unwrap_or(WRITE_PERMIT); - match self.fs.pread(&self.handle, self.position, size).await { - Ok(b) => { - if b.is_empty() { - self.eof = true; - } else { - self.position += b.len() as u64; - } - self.buffered = Some(b); - } - Err(e) => { - self.last_error = Some(wasmtime::Error::msg(format!("{:?}", from_fs(e)))); - } - } - } -} - -impl InputStream for S3InputStream { - fn read(&mut self, size: usize) -> StreamResult { - if let Some(e) = self.last_error.take() { - return Err(StreamError::LastOperationFailed(e)); - } - if let Some(buf) = self.buffered.take() { - if buf.is_empty() && self.eof { - return Err(StreamError::Closed); - } - if buf.len() <= size { - return Ok(buf); - } - let split = buf.slice(0..size); - self.buffered = Some(buf.slice(size..)); - return Ok(split); - } - if self.eof { - return Err(StreamError::Closed); - } - self.pending = Some(size); - Ok(Bytes::new()) - } -} diff --git a/runtime/src/wasi/view.rs b/runtime/src/wasi/view.rs deleted file mode 100644 index 2a15239..0000000 --- a/runtime/src/wasi/view.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Host state and the trait that connects user `T` (the wasmtime store data) -//! to the bits this crate needs. - -use std::sync::Arc; - -use s3fs_core::{Fs, Inode}; -use wasmtime::component::ResourceTable; - -/// "View" struct holding mutable references the host trait impls need. -/// Built fresh by the [`S3WasiView::s3fs_view`] closure for each linker -/// call — this is the same shape `wasmtime-wasi` uses (e.g. -/// `WasiCtxView`, `WasiFilesystemCtxView`). -/// -/// Crucially, `table` is the SAME `ResourceTable` instance used by -/// `wasmtime-wasi`'s host impls. Sharing the table is required so that -/// stream resources we push (`DynInputStream`, `DynOutputStream`) can be -/// looked up by `wasmtime-wasi`'s stream methods when the guest reads / -/// writes them. -pub struct S3FsCtxView<'a> { - pub fs: &'a Arc, - /// What this guest sees as `/`. - /// - /// The filesystem root for a guest that has the whole store; a tenant's - /// own directory when clients are separated inside one filesystem. Every - /// path this view resolves is confined to it — see - /// [`s3fs_core::Fs::lookup_within`] — so separation is a property of the - /// capability layer rather than something guest code is trusted to keep. - pub scope: &'a Arc, - pub table: &'a mut ResourceTable, - /// The same clock the guest sees through `wasi:clocks/wall-clock`, so - /// `set-times` with "now" cannot disagree with what the guest just read. - pub clock: &'a Arc, -} - -/// Implement this on your store-data type `T` to plug the filesystem into -/// a `Linker`. The closure passed to [`crate::add_to_linker`] uses this -/// trait to fetch a fresh view per call. -pub trait S3WasiView: Send { - fn s3fs_view(&mut self) -> S3FsCtxView<'_>; -} diff --git a/runtime/src/zfs.rs b/runtime/src/zfs.rs new file mode 100644 index 0000000..e955e23 --- /dev/null +++ b/runtime/src/zfs.rs @@ -0,0 +1,2362 @@ +//! Storage: a ZFS pool per tenant, plus a control pool, on one disk. +//! +//! ```text +//! /pools//data a tenant's pool, mounted here: the guest's preopen +//! /runtime/... credentials, tasks, streams, devices, the catalog +//! pool "enclave" region 0: the control pool (records + catalog) +//! pool "p" region N: a tenant's pool, one data dataset +//! /dev/mapper/zcrypt-* plain dm-crypt per region, a key per pool +//! /dev/nbd0 the parent's disk, cut into equal regions, over vsock +//! ``` +//! +//! Each pool sits on its own region of the one disk and anchors on its own +//! chain. A tenant's data is theirs alone, so one tenant's anchor can never +//! cover another's write; the control pool holds the catalog that maps a +//! tenant to its region and pool, out of every guest's reach. +//! +//! dm-crypt is what makes the host's disk safe to read. Without the key the +//! host can only replay sectors it once saw or scramble them, and to ZFS both +//! look like the ordinary corruption its checksums exist to catch. No header +//! is parsed from the disk: plain mode has none. +//! +//! ## The anchor +//! +//! Every ZFS block pointer carries its child's checksum, so a pool state is +//! pinned by its uberblock. But a txg number does not name one: forks reuse +//! numbers, and every uberblock the disk ever held may still be on it for the +//! host to serve. +//! +//! So each anchor writes a fresh random value into the pool itself, the marker +//! `zfs set enclave:anchor=--`, which also +//! syncs everything written before it. A second sync follows, and the txg that +//! reached is the one the anchor names. Signed with the nonce, chained to the +//! anchor before, it is published to the roots bucket under Object Lock. +//! Nothing is acknowledged — a response, a registration, a finished task — +//! until the anchor covering it exists. +//! +//! The marker's txg is older than the anchored one, and a write that lands +//! between the two is acknowledged by the anchor, so the nonce alone does not +//! pin the state. Boot imports the newest state on the disk and asks the kernel +//! which txg it loaded. It refuses one older than the anchored txg, and one +//! whose marker is neither the newest anchor's nor that of an unpublished +//! successor naming it ([`admit`]). A rolled-back disk, an abandoned fork and +//! the disk as of the marker are all refused. +//! +//! There is no rewind. What an enclave synced and died before anchoring stays, +//! unacknowledged: rewinding even three txgs can find the anchored state's +//! blocks reused, and ZFS would then quietly load an older one. +//! +//! Anchors cannot be hidden either: they are found by their retained version, +//! beneath any delete marker, and Object Lock keeps every one. What a cold boot +//! cannot know on its own is whether the store is still adding them; that is +//! what `--min-root-seq` is for. + +use std::collections::HashMap; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::store::backend::{Backend, ObjectLock, ObjectLockMode, PutBlobInput}; +use crate::store::crypto::keys::hkdf; +use crate::store::crypto::{ + sign, Hash256, KeyMaterial, MasterSecret, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN, +}; +use crate::store::error::{StoreError, StoreResult}; +use anyhow::{anyhow, bail, ensure, Context, Result}; +use bytes::Bytes; +use rustix::ioctl::{self, opcode, IntegerSetter, NoArg}; +use rustix::net::addr::{SocketAddrArg, SocketAddrLen, SocketAddrOpaque}; +use rustix::net::{AddressFamily, SocketType}; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::sync::Mutex; +use zeroize::Zeroize; + +use crate::clock::TrustedClock; +use crate::tenant::Arrival; + +/// The absolute time an Object Lock should hold until: the enclave's *trusted* +/// time (PTP on Nitro), never the host's system clock — a host that wound its +/// clock back could otherwise shorten the lock and make an anchor, a +/// generation record or the sealed key deletable early. +fn retain_until( + clock: &Arc, + retention: Duration, +) -> Result { + Ok(std::time::UNIX_EPOCH + clock.now()? + retention) +} + +pub const POOL: &str = "enclave"; +const NBD_DEVICE: &str = "/dev/nbd0"; +/// The parent, as every enclave sees it. +const PARENT_CID: u32 = 3; +/// Where the parent serves the disk: NBD's registered port, on vsock. +const NBD_PORT: u32 = 10809; +const PROPERTY: &str = "enclave:anchor"; +/// No txg starts on a timer, a request's writes fit in one txg, and the txg +/// history reaches back further than the gap between two anchors. +/// The debug log records which uberblock an import loaded. +const ZFS_PARAMS: &str = "zfs_txg_timeout=3600 zfs_txg_history=4096 \ + zfs_arc_max=268435456 zfs_dirty_data_max=268435456 zfs_dirty_data_sync_percent=90 \ + spa_load_verify_data=0 zfs_dbgmsg_enable=1"; + +const ANCHOR_MAGIC: &[u8; 8] = b"ZFSANCH2"; + +// magic | seq | prev | fs_uuid | kind | pool_id | tenant_id | pool_guid | txg | nonce | pubkey | sig +const OFF_SEQ: usize = 8; +const OFF_PREV: usize = OFF_SEQ + 8; +const OFF_FS_UUID: usize = OFF_PREV + 32; +const OFF_KIND: usize = OFF_FS_UUID + 16; +const OFF_POOL_ID: usize = OFF_KIND + 1; +const OFF_TENANT_ID: usize = OFF_POOL_ID + 16; +const OFF_POOL_GUID: usize = OFF_TENANT_ID + 16; +const OFF_TXG: usize = OFF_POOL_GUID + 8; +const OFF_NONCE: usize = OFF_TXG + 8; +const OFF_PUBKEY: usize = OFF_NONCE + 32; +const SIGNED_LEN: usize = OFF_PUBKEY + ED25519_PUBLIC_KEY_LEN; +pub const ANCHOR_LEN: usize = SIGNED_LEN + ED25519_SIGNATURE_LEN; + +/// The control pool holds the catalog and runtime records; a tenant pool holds +/// one tenant's data. Which a pool is settles what an anchor speaks for, so it +/// is signed into the anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PoolKind { + Control, + Tenant, +} + +impl PoolKind { + fn byte(self) -> u8 { + match self { + PoolKind::Control => 0, + PoolKind::Tenant => 1, + } + } + fn from_byte(b: u8) -> Option { + match b { + 0 => Some(PoolKind::Control), + 1 => Some(PoolKind::Tenant), + _ => None, + } + } +} + +/// The one control pool's id: a fixed sentinel, never a random tenant id. +const CONTROL_POOL_ID: [u8; 16] = *b"enclave-control!"; + +fn u64_at(b: &[u8], off: usize) -> u64 { + u64::from_be_bytes(b[off..off + 8].try_into().expect("8 bytes")) +} + +fn id_at(b: &[u8], off: usize) -> [u8; 16] { + b[off..off + 16].try_into().expect("16 bytes") +} + +/// The disk is cut into equal regions, one pool each: region 0 the control +/// pool, the rest tenant pools. 512-byte sectors; a region is 4 KiB-aligned so +/// its dm-crypt mapping starts on a block boundary. +const SECTORS_PER_MIB: u64 = (1024 * 1024) / 512; + +/// The default region size until a deployment sets one with `--region-mib`. A +/// ZFS pool needs only tens of MiB, so this is deliberately small. +const DEFAULT_REGION_MIB: u64 = 200; + +/// A nominal disk size, in regions, for a directory-backed manager: enough for +/// the tests' tenants without a real disk behind it. +#[cfg(any(test, feature = "testing"))] +const SCRATCH_REGIONS: u64 = 64; + +/// The region an index occupies, as `(offset_sectors, length_sectors)`, or +/// `None` if it does not fit. Regions never overlap and never run past the +/// disk: region `i` is `[i*len, (i+1)*len)`, and a trailing partial region is +/// unused. +fn region_bounds(index: u64, region_sectors: u64, disk_sectors: u64) -> Option<(u64, u64)> { + let offset = index.checked_mul(region_sectors)?; + (offset.checked_add(region_sectors)? <= disk_sectors).then_some((offset, region_sectors)) +} + +/// How many whole regions a disk holds: the cap on pools, control included. +fn region_count(region_sectors: u64, disk_sectors: u64) -> u64 { + disk_sectors / region_sectors.max(1) +} + +/// What a pool needs once the manager has mapped its device. +struct PoolSpec { + name: String, + mapped: String, + kind: PoolKind, + pool_id: [u8; 16], + tenant_id: [u8; 16], + anchor_prefix: String, + root: PathBuf, + is_pool: bool, +} + +/// One control pool, plus a pool per tenant allocated to a region. The names +/// and paths each pool uses, derived from its id so two tenants never collide. +fn tenant_pool_name(pool_id: &[u8; 16]) -> String { + // zpool names must start with a letter. + format!("p{}", hex::encode(pool_id)) +} +fn tenant_dev_name(pool_id: &[u8; 16]) -> String { + format!("zcrypt-{}", hex::encode(pool_id)) +} + +/// One anchored pool state, chained to the one before it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Anchor { + pub seq: u64, + pub prev: Hash256, + pub kind: PoolKind, + /// Which pool this anchor speaks for: a fixed sentinel for the control + /// pool, a random id for a tenant's. Signed in, and checked on load, so + /// the host cannot serve one pool's anchor as another's. + pub pool_id: [u8; 16], + /// The tenant the pool belongs to, or zero for the control pool. + pub tenant_id: [u8; 16], + pub pool_guid: u64, + pub txg: u64, + /// Also in the pool, as `enclave:anchor`: what tells this state apart + /// from another that reached the same txg. + pub nonce: [u8; 32], + encoded: Bytes, +} + +impl Anchor { + #[allow(clippy::too_many_arguments)] + pub fn seal( + keys: &KeyMaterial, + prev: Option<&Anchor>, + kind: PoolKind, + pool_id: [u8; 16], + tenant_id: [u8; 16], + pool_guid: u64, + txg: u64, + nonce: [u8; 32], + ) -> Anchor { + let seq = prev.map_or(0, |p| p.seq + 1); + let prev_hash = prev.map_or(Hash256::ZERO, Anchor::hash); + let mut b = Vec::with_capacity(ANCHOR_LEN); + b.extend_from_slice(ANCHOR_MAGIC); + b.extend_from_slice(&seq.to_be_bytes()); + b.extend_from_slice(prev_hash.as_bytes()); + b.extend_from_slice(keys.fs_uuid()); + b.push(kind.byte()); + b.extend_from_slice(&pool_id); + b.extend_from_slice(&tenant_id); + b.extend_from_slice(&pool_guid.to_be_bytes()); + b.extend_from_slice(&txg.to_be_bytes()); + b.extend_from_slice(&nonce); + b.extend_from_slice(keys.public_key()); + let sig = sign::sign(keys.signing_key(), &b); + b.extend_from_slice(&sig); + Anchor { + seq, + prev: prev_hash, + kind, + pool_id, + tenant_id, + pool_guid, + txg, + nonce, + encoded: Bytes::from(b), + } + } + + /// Identity for chaining: the full encoding, signature included. + pub fn hash(&self) -> Hash256 { + Hash256::of(&self.encoded) + } + + pub fn encoded(&self) -> Bytes { + self.encoded.clone() + } + + /// The value written to the pool's `enclave:anchor` property. + pub fn property(&self) -> String { + marker(self.seq, &self.nonce, &self.prev) + } + + /// The checks `RootRecord::decode_and_verify` makes, for the same + /// reasons: our key and not the one the record names, our filesystem, and + /// a sequence that matches the key it was read from. + pub fn decode_and_verify( + bytes: &[u8], + keys: &KeyMaterial, + expected_seq: u64, + expected_pool_id: [u8; 16], + ) -> StoreResult { + if bytes.len() != ANCHOR_LEN || &bytes[..8] != ANCHOR_MAGIC { + return Err(StoreError::Integrity("zfs anchor: not an anchor")); + } + if &bytes[OFF_PUBKEY..SIGNED_LEN] != keys.public_key() { + return Err(StoreError::Integrity( + "zfs anchor: signed by an unexpected key", + )); + } + let sig: [u8; ED25519_SIGNATURE_LEN] = bytes[SIGNED_LEN..].try_into().expect("64 bytes"); + sign::verify(keys.public_key(), &bytes[..SIGNED_LEN], &sig)?; + if &bytes[OFF_FS_UUID..OFF_KIND] != keys.fs_uuid() { + return Err(StoreError::Integrity( + "zfs anchor: belongs to another filesystem", + )); + } + if id_at(bytes, OFF_POOL_ID) != expected_pool_id { + return Err(StoreError::Integrity("zfs anchor: belongs to another pool")); + } + let kind = PoolKind::from_byte(bytes[OFF_KIND]) + .ok_or(StoreError::Integrity("zfs anchor: unknown pool kind"))?; + let seq = u64_at(bytes, OFF_SEQ); + if seq != expected_seq { + return Err(StoreError::Integrity( + "zfs anchor: sequence does not match its key", + )); + } + let prev = Hash256::from_bytes(bytes[OFF_PREV..OFF_FS_UUID].try_into().expect("32 bytes")); + if seq == 0 && !prev.is_zero() { + return Err(StoreError::Integrity( + "zfs anchor: genesis has a predecessor", + )); + } + Ok(Anchor { + seq, + prev, + kind, + pool_id: id_at(bytes, OFF_POOL_ID), + tenant_id: id_at(bytes, OFF_TENANT_ID), + pool_guid: u64_at(bytes, OFF_POOL_GUID), + txg: u64_at(bytes, OFF_TXG), + nonce: bytes[OFF_NONCE..OFF_PUBKEY].try_into().expect("32 bytes"), + encoded: Bytes::copy_from_slice(bytes), + }) + } +} + +const GEN_MAGIC: &[u8; 8] = b"ZFSGEN01"; +// magic | generation | fs_uuid | pubkey | sig +const GEN_OFF_G: usize = 8; +const GEN_OFF_FS_UUID: usize = GEN_OFF_G + 8; +const GEN_OFF_PUBKEY: usize = GEN_OFF_FS_UUID + 16; +const GEN_SIGNED_LEN: usize = GEN_OFF_PUBKEY + ED25519_PUBLIC_KEY_LEN; +const GEN_LEN: usize = GEN_SIGNED_LEN + ED25519_SIGNATURE_LEN; + +/// A boot's generation claim. One per enclave start, chained only by its +/// number: a running enclave stops anchoring for good once a higher one +/// exists, so a second enclave the host starts while the first is mid-anchor +/// cannot let the first acknowledge a write the second has forked away from. +/// Signed and Object-Locked like an anchor, so the host can neither forge one +/// for another filesystem nor hide the newest. +fn seal_generation(keys: &KeyMaterial, generation: u64) -> Bytes { + let mut b = Vec::with_capacity(GEN_LEN); + b.extend_from_slice(GEN_MAGIC); + b.extend_from_slice(&generation.to_be_bytes()); + b.extend_from_slice(keys.fs_uuid()); + b.extend_from_slice(keys.public_key()); + let sig = sign::sign(keys.signing_key(), &b); + b.extend_from_slice(&sig); + Bytes::from(b) +} + +fn generation_key(prefix: &str, generation: u64) -> String { + format!("{prefix}zfs/generations/{generation:016x}") +} + +/// Does a retained object exist at `key`? A delete marker reads as absent. +async fn exists_raw(roots: &Arc, key: &str) -> Result { + match roots.get_retained_blob(key).await { + Ok(_) => Ok(true), + Err(StoreError::NotFound) => Ok(false), + Err(e) => Err(e.into()), + } +} + +/// The newest number of a contiguous, never-deleted chain: existence is +/// monotone, so gallop then bisect. +async fn chain_tip(roots: &Arc, key: impl Fn(u64) -> String) -> Result> { + if !exists_raw(roots, &key(0)).await? { + return Ok(None); + } + let (mut lo, mut step) = (0u64, 1u64); + while let Some(next) = lo.checked_add(step) { + if !exists_raw(roots, &key(next)).await? { + break; + } + lo = next; + step = step.saturating_mul(2); + } + let mut hi = lo.saturating_add(step); + while hi - lo > 1 { + let mid = lo + (hi - lo) / 2; + if exists_raw(roots, &key(mid)).await? { + lo = mid; + } else { + hi = mid; + } + } + Ok(Some(lo)) +} + +/// Whether a generation exists, verifying it so a host cannot fence this +/// enclave out with a record it forged for another filesystem. +async fn generation_exists( + roots: &Arc, + keys: &KeyMaterial, + prefix: &str, + generation: u64, +) -> Result { + let key = generation_key(prefix, generation); + if exists_raw(roots, &key).await? { + verify_generation(&roots.get_retained_blob(&key).await?.body, keys, generation)?; + Ok(true) + } else { + Ok(false) + } +} + +/// Claim the first unused generation for this boot, racing other boots for it. +/// Each win is read back, since a delete marker could otherwise let a second +/// PUT through, as for an anchor. +async fn claim_generation( + roots: &Arc, + keys: &KeyMaterial, + prefix: &str, + retention: Duration, + clock: &Arc, +) -> Result { + for _ in 0..1024 { + let generation = chain_tip(roots, |g| generation_key(prefix, g)) + .await? + .map_or(0, |t| t + 1); + let key = generation_key(prefix, generation); + let body = seal_generation(keys, generation); + let input = PutBlobInput::new(key.clone(), body.clone()).with_object_lock(ObjectLock { + mode: ObjectLockMode::Compliance, + retain_until: retain_until(clock, retention)?, + }); + match roots.put_blob_if_not_exists(input).await { + Ok(_) if roots.get_retained_blob(&key).await?.body == body => { + tracing::info!(generation, "claimed an enclave generation"); + return Ok(generation); + } + Ok(_) | Err(StoreError::AlreadyExists) => continue, // lost the race; try the next + Err(e) => return Err(e.into()), + } + } + bail!("could not claim an enclave generation after 1024 tries; the store is being raced") +} + +fn verify_generation(bytes: &[u8], keys: &KeyMaterial, expected: u64) -> StoreResult<()> { + if bytes.len() != GEN_LEN || &bytes[..8] != GEN_MAGIC { + return Err(StoreError::Integrity("zfs generation: not a generation")); + } + if &bytes[GEN_OFF_PUBKEY..GEN_SIGNED_LEN] != keys.public_key() { + return Err(StoreError::Integrity( + "zfs generation: signed by an unexpected key", + )); + } + let sig: [u8; ED25519_SIGNATURE_LEN] = bytes[GEN_SIGNED_LEN..].try_into().expect("64 bytes"); + sign::verify(keys.public_key(), &bytes[..GEN_SIGNED_LEN], &sig)?; + if &bytes[GEN_OFF_FS_UUID..GEN_OFF_PUBKEY] != keys.fs_uuid() { + return Err(StoreError::Integrity( + "zfs generation: belongs to another filesystem", + )); + } + if u64_at(bytes, GEN_OFF_G) != expected { + return Err(StoreError::Integrity( + "zfs generation: number does not match its key", + )); + } + Ok(()) +} + +/// Where the pool lives. +#[derive(Debug, Clone)] +pub enum Disk { + /// The parent's disk, over vsock: the only one an enclave has. + Parent, + /// A temporary directory standing in for the pool, for tests that cannot + /// load ZFS. Anchors are still published (at txg 0); nothing is synced, + /// so `anchor` does nothing. Clones share the directory, so a test can + /// "restart" onto the same disk. + #[cfg(any(test, feature = "testing"))] + Directory(Arc), +} + +impl Disk { + /// A fresh [`Disk::Directory`]. + #[cfg(any(test, feature = "testing"))] + pub fn scratch() -> Result { + Ok(Disk::Directory(Arc::new(tempfile::tempdir()?))) + } +} + +/// One ZFS pool: its device, its anchor chain, its last-anchor lock. +struct Pool { + /// Held so a [`Disk::Directory`] lives as long as the pool on it. + #[allow(dead_code)] + disk: Disk, + keys: Arc, + roots: Arc, + /// The global bucket prefix, under which the generation chain lives. + prefix: String, + /// This pool's anchor-chain prefix in the bucket: `{prefix}zfs/control/` + /// for the control pool, `{prefix}zfs/pools//` for a tenant's. Anchors + /// hang off it; generations do not, they are global under `{prefix}`. + anchor_prefix: String, + retention: Duration, + /// Trusted time, for Object Lock retain-until (see [`retain_until`]). + clock: Arc, + /// The zpool's name, e.g. `enclave`. + name: String, + /// The dm-crypt device the pool sits on, e.g. `/dev/mapper/zcrypt-control`. + mapped: String, + /// Which pool this is, and its identity, signed into every anchor. + kind: PoolKind, + pool_id: [u8; 16], + tenant_id: [u8; 16], + /// `/` on the pool; a directory for [`Disk::Directory`]. + root: PathBuf, + pool: bool, + pool_guid: u64, + genesis: Hash256, + /// This boot's generation. An anchor refuses to publish once generation + /// `+ 1` exists, so a superseded enclave cannot acknowledge anything. + generation: u64, + /// The newest published anchor, or why anchoring stopped. Held for the + /// whole of an anchor, so they run one at a time. + // ponytail: one lock for every tenant; group waiting callers onto one + // sync and one PUT if throughput matters. + last: Mutex>, +} + +impl std::fmt::Debug for Pool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Pool") + .field("root", &self.root) + .field("pool_guid", &self.pool_guid) + .finish() + } +} + +impl Pool { + /// Genesis for one pool: create it on its blank device and publish anchor + /// 0. Refuses a device that already holds a pool, and a chain that already + /// has anchors. The generation is the manager's, claimed once per boot. + #[allow(clippy::too_many_arguments)] + async fn create( + disk: &Disk, + roots: Arc, + keys: Arc, + prefix: &str, + retention: Duration, + clock: Arc, + spec: PoolSpec, + generation: u64, + ) -> Result> { + let mut zfs = Pool::attach( + disk, roots, keys, prefix, retention, clock, spec, generation, + ); + ensure!( + zfs.find_tip().await?.is_none(), + "this pool's chain already has anchors; genesis would start a second history" + ); + if zfs.pool { + if pool_present(&zfs.mapped).await? { + bail!("the device holds a pool but no anchor names it; refusing to guess"); + } + run( + "zpool", + &[ + "create", + "-f", + "-o", + "ashift=12", + "-o", + "cachefile=none", + "-o", + "failmode=panic", + "-O", + "checksum=sha256", + "-O", + "atime=off", + "-O", + "sync=disabled", + "-O", + "mountpoint=none", + &zfs.name, + &zfs.mapped, + ], + ) + .await?; + for ds in zfs.datasets() { + let mount = zfs.root.join(ds); + run( + "zfs", + &[ + "create", + "-o", + &format!("mountpoint={}", mount.display()), + &format!("{}/{ds}", zfs.name), + ], + ) + .await?; + // Keep a little space for records a guest cannot fill: a full + // `data` dataset must not stop a task or stream being recorded. + if *ds == "runtime" { + run( + "zfs", + &["set", "reservation=16M", &format!("{}/{ds}", zfs.name)], + ) + .await?; + } + } + zfs.pool_guid = pool_guid(&zfs.name).await?; + } else { + for ds in zfs.datasets() { + tokio::fs::create_dir_all(zfs.root.join(ds)).await?; + } + } + let genesis = zfs.seal_and_publish(None).await?; + zfs.genesis = genesis.hash(); + tracing::info!( + name = %zfs.name, + pool_guid = zfs.pool_guid, + txg = genesis.txg, + "zfs genesis: created a pool" + ); + zfs.last = Mutex::new(Ok(genesis)); + Ok(Arc::new(zfs)) + } + + /// Resume one pool: import it as it is, and refuse it unless the newest + /// anchor vouches for what ZFS loaded (see [`admit`]). `min_seq` floors the + /// chain from outside; `expect_genesis` pins which history it must be (a + /// tenant pool against its catalog entry). + #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] + async fn open( + disk: &Disk, + roots: Arc, + keys: Arc, + prefix: &str, + retention: Duration, + clock: Arc, + spec: PoolSpec, + generation: u64, + min_seq: Option, + expect_genesis: Option, + ) -> Result> { + let mut zfs = Pool::attach( + disk, roots, keys, prefix, retention, clock, spec, generation, + ); + let seq = zfs + .find_tip() + .await? + .context("this pool's chain has no anchors; there is nothing to resume")?; + if let Some(min) = min_seq { + ensure!(seq >= min, "the newest anchor is {seq}, below the floor of {min}: refusing a rolled-back store"); + } + let tip = zfs.load(seq).await?; + if seq > 0 && zfs.load(seq - 1).await?.hash() != tip.prev { + bail!("the anchor chain is broken at seq {seq}"); + } + zfs.genesis = if seq == 0 { + tip.hash() + } else { + zfs.load(0).await?.hash() + }; + if let Some(expected) = expect_genesis { + ensure!( + zfs.genesis == expected, + "this pool is a different history than its catalog entry names" + ); + } + if zfs.pool { + // The newest state on the disk, not `-T` the anchored txg: that + // takes the newest uberblock at or below it with no exact match, + // and falls back further when one fails to load. Which txg it got + // comes from the kernel's own notes on this import, so the log is + // emptied first. Unmounted until it is admitted. + tokio::fs::write(DBGMSG, "0") + .await + .context("clearing the ZFS debug log")?; + run( + "zpool", + &[ + "import", + "-f", + "-N", + "-d", + &zfs.mapped, + "-o", + "cachefile=none", + &tip.pool_guid.to_string(), + &zfs.name, + ], + ) + .await + .context("importing the pool")?; + #[cfg(feature = "testing")] + log_loaded_state(&zfs.name).await; + let loaded = loaded_txg(&tokio::fs::read_to_string(DBGMSG).await?, &zfs.name); + let marker = run("zfs", &["get", "-Hp", "-o", "value", PROPERTY, &zfs.name]).await?; + let guid = pool_guid(&zfs.name).await?; + if let Err(why) = admit(&tip, loaded, marker.trim(), guid) { + let _ = run("zpool", &["export", &zfs.name]).await; + bail!("refusing the pool: {why}"); + } + run("zfs", &["mount", "-a"]).await?; + tracing::info!( + name = %zfs.name, + loaded_txg = loaded, + marker = marker.trim(), + "zfs pool admitted" + ); + } + zfs.pool_guid = tip.pool_guid; + tracing::info!( + name = %zfs.name, + seq, + txg = tip.txg, + pool_guid = zfs.pool_guid, + "zfs pool resumed at its anchor" + ); + zfs.last = Mutex::new(Ok(tip)); + Ok(Arc::new(zfs)) + } + + /// Build a pool's handle on a device the manager has already mapped. No + /// device or dm-crypt work and no generation claim: the manager does both + /// once and passes the result in. + #[allow(clippy::too_many_arguments)] + fn attach( + disk: &Disk, + roots: Arc, + keys: Arc, + prefix: &str, + retention: Duration, + clock: Arc, + spec: PoolSpec, + generation: u64, + ) -> Pool { + Pool { + disk: disk.clone(), + keys, + roots, + prefix: prefix.to_string(), + anchor_prefix: spec.anchor_prefix, + retention, + clock, + name: spec.name, + mapped: spec.mapped, + kind: spec.kind, + pool_id: spec.pool_id, + tenant_id: spec.tenant_id, + root: spec.root, + pool: spec.is_pool, + pool_guid: 0, + genesis: Hash256::ZERO, + generation, + last: Mutex::new(Err("not opened".into())), + } + } + + /// The datasets this kind of pool holds, each mounted at `root`/`name`: the + /// control pool keeps the runtime's records, a tenant pool the guest's + /// data — the only thing a guest ever sees. + /// A tenant pool holds `data` (the guest's only preopen) and `runtime` + /// (its tasks and streams, out of the guest's reach). The control pool + /// holds `runtime` alone: the catalog, credentials and devices. + fn datasets(&self) -> &'static [&'static str] { + match self.kind { + PoolKind::Control => &["runtime"], + PoolKind::Tenant => &["data", "runtime"], + } + } + + /// The hash of anchor 0: which history this pool is, for the boot receipt. + pub fn genesis_hash(&self) -> [u8; 32] { + *self.genesis.as_bytes() + } + + /// Make everything written so far survive the host. Call before telling + /// anyone a write happened, and tell nobody if it fails. + /// + /// It runs in a task of its own, so a caller that is dropped — a deadline, + /// an abort, a client that went away — cannot stop it between writing the + /// marker and publishing the anchor, and leave the next one to collide + /// with an anchor it does not know was published. + /// + /// Before the marker is written a failure changes nothing and is only + /// returned. From the marker on it stops anchoring for good, an uncertain + /// publish included: retrying could publish over a lost race, or past a + /// state the host has since rewritten. + pub async fn anchor(self: &Arc) -> Result<()> { + if !self.pool { + return Ok(()); + } + let zfs = self.clone(); + tokio::spawn(async move { zfs.anchor_now().await }) + .await + .context("the anchor's task died")? + } + + async fn anchor_now(&self) -> Result<()> { + #[cfg(feature = "testing")] + if self.last.try_lock().is_err() { + hook("anchor-wait", String::new()).await; + } + let mut last = self.last.lock().await; + let prev = last + .as_ref() + .map_err(|e| anyhow!("zfs anchoring stopped after a failure: {e}"))? + .clone(); + let start = Instant::now(); + run("zpool", &["sync", &self.name]).await?; + let synced = synced_txg(&self.name).await?; + if synced == prev.txg { + return Ok(()); // nothing has reached the disk since + } + let sync_ms = start.elapsed().as_millis(); + match self.seal_and_publish(Some(&prev)).await { + Ok(anchor) => { + tracing::info!( + seq = anchor.seq, + txg = anchor.txg, + written_txgs_since = synced - prev.txg, + sync_ms, + total_ms = start.elapsed().as_millis(), + "zfs anchored" + ); + *last = Ok(anchor); + Ok(()) + } + Err(e) => { + *last = Err(format!("{e:#}")); + Err(e) + } + } + } + + async fn seal_and_publish(&self, prev: Option<&Anchor>) -> Result { + let seq = prev.map_or(0, |p| p.seq + 1); + let mut nonce = [0u8; 32]; + getrandom::fill(&mut nonce).map_err(|e| anyhow!("getrandom: {e}"))?; + let txg = if self.pool { + // Anchor::property, before there is an anchor. A sync task: back + // once the txg holding it, and everything written before it, is + // on the disk. + let value = marker(seq, &nonce, &prev.map_or(Hash256::ZERO, Anchor::hash)); + run("zfs", &["set", &format!("{PROPERTY}={value}"), &self.name]).await?; + #[cfg(feature = "testing")] + hook("after-marker", format!("seq={seq}")).await; + // Its frees are deferred two txgs, and would otherwise reach the + // disk on the next sync: every later call would see a write and + // anchor again, read-only ones included. + run("zpool", &["sync", &self.name]).await?; + let txg = synced_txg(&self.name).await?; + if let Some(p) = prev { + ensure!( + txg > p.txg, + "zfs set reached txg {txg}, not past the anchor at {}", + p.txg + ); + } + txg + } else { + 0 + }; + #[cfg(feature = "testing")] + hook("after-sync", format!("seq={seq} txg={txg}")).await; + // The fence, checked as late as possible before the publish that would + // acknowledge these writes: if a newer enclave has booted, this one is + // superseded and must not publish. The marker is already on the disk, + // which is harmless — nothing was acknowledged, and the next boot's + // admission handles an unpublished marker. Only a real anchor fences; + // genesis is guarded by the sealed-key lease instead. + if prev.is_some() + && generation_exists(&self.roots, &self.keys, &self.prefix, self.generation + 1).await? + { + bail!( + "a newer enclave generation ({}) exists; this one is superseded and will not anchor", + self.generation + 1 + ); + } + let anchor = Anchor::seal( + &self.keys, + prev, + self.kind, + self.pool_id, + self.tenant_id, + self.pool_guid, + txg, + nonce, + ); + self.publish(seq, anchor.encoded()).await?; + #[cfg(feature = "testing")] + hook("after-publish", format!("seq={seq} txg={txg}")).await; + Ok(anchor) + } + + /// Where the guest's files live: the `data` dataset on a tenant pool. The + /// only thing a guest ever sees. + fn data_dir(&self) -> PathBuf { + self.root.join("data") + } + + /// A directory of runtime records on this pool (the control pool), out of + /// every guest's reach — no preopen names anything above a tenant's `data`. + async fn runtime_dir(&self, name: &str) -> Result { + let dir = self.root.join("runtime").join(name); + tokio::fs::create_dir_all(&dir).await?; + Ok(dir) + } + + // ---- the anchor chain, in the roots bucket ----------------------------- + + fn anchor_key(&self, seq: u64) -> String { + format!("{}anchors/{seq:016x}", self.anchor_prefix) + } + + /// The retained version of a key: a delete marker hides an Object-Locked + /// object from a plain GET while leaving it there, so a plain GET would let + /// the host roll a chain back with one legal call. + async fn retained(&self, key: &str) -> StoreResult { + Ok(self.roots.get_retained_blob(key).await?.body) + } + + async fn load(&self, seq: u64) -> Result { + Ok(Anchor::decode_and_verify( + &self.retained(&self.anchor_key(seq)).await?, + &self.keys, + seq, + self.pool_id, + )?) + } + + /// The newest anchor of this pool's chain, or `None` if it has none. + async fn find_tip(&self) -> Result> { + chain_tip(&self.roots, |seq| self.anchor_key(seq)).await + } + + /// Publish `seq`, winning or losing the race for it. `If-None-Match` + /// alone does not decide that race — a delete marker over the winner lets + /// a second conditional PUT through — the retained version does, so it is + /// read back. + async fn publish(&self, seq: u64, body: Bytes) -> Result<()> { + let key = self.anchor_key(seq); + let input = PutBlobInput::new(key.clone(), body.clone()).with_object_lock(ObjectLock { + mode: ObjectLockMode::Compliance, + retain_until: retain_until(&self.clock, self.retention)?, + }); + match self.roots.put_blob_if_not_exists(input).await { + Ok(_) => {} + Err(StoreError::AlreadyExists) => bail!("another writer published anchor {seq}"), + Err(e) => return Err(e.into()), + } + ensure!( + self.retained(&key).await? == body, + "another writer published anchor {seq} first" + ); + Ok(()) + } +} + +/// One tenant's place in the catalog: which pool is theirs, where it sits, and +/// which history it must be. Kept on the control pool, out of every guest's +/// reach, so the host cannot point a tenant at another's pool. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CatalogEntry { + version: u32, + #[serde(with = "hex_bytes")] + pool_id: [u8; 16], + region: u64, + #[serde(with = "hex_bytes")] + genesis: [u8; 32], + /// Whether this tenant has background work (a task or a stream). Boot + /// imports and scans only the pools so flagged, so a scheduled task runs + /// again after a restart without importing every tenant. + #[serde(default)] + background: bool, +} + +mod hex_bytes { + use serde::{Deserialize, Deserializer, Serializer}; + pub fn serialize(b: &[u8], s: S) -> Result { + s.serialize_str(&hex::encode(b)) + } + pub fn deserialize<'de, D: Deserializer<'de>, const N: usize>( + d: D, + ) -> Result<[u8; N], D::Error> { + let s = String::deserialize(d)?; + let v = hex::decode(&s).map_err(serde::de::Error::custom)?; + v.try_into() + .map_err(|_| serde::de::Error::custom("wrong length")) + } +} + +/// The enclave's storage: the control pool, and a tenant's pool imported as it +/// is used. One disk underneath, cut into equal regions (see [`region_bounds`]), +/// each its own dm-crypt device and its own pool with its own anchor chain. +/// +/// The public type the rest of the runtime holds. It routes a tenant's writes +/// to that tenant's pool and the runtime's own records to the control pool, and +/// anchors each independently. +/// A tenant pool, imported once per boot behind its slot. +type PoolSlot = Arc>>; + +pub struct Zfs { + disk: Disk, + roots: Arc, + keys: Arc, + /// Held to derive a region's dm-crypt key as tenants arrive. + master: MasterSecret, + prefix: String, + retention: Duration, + clock: Arc, + /// Real ZFS, or a directory stand-in for tests. + is_pool: bool, + region_sectors: u64, + disk_sectors: u64, + /// The cap on pools, control included: how many regions the disk holds. + max_pools: u64, + /// This boot's generation, claimed once; every pool's fence checks it. + generation: u64, + control: Arc, + /// Tenant pools, imported on demand. The slot (a `OnceCell`) serialises a + /// tenant's first access without serialising everyone; steady state is a + /// lock-free map read. + tenants: std::sync::Mutex>, + /// A tenant's data directory once its pool is known, for the sync + /// `tenant_path`. The disk root, for a path to a tenant not yet seen. + tenant_roots: std::sync::Mutex>, + /// Held while a new tenant pool is allocated and its catalog entry written, + /// so two different new tenants cannot be handed the same free region. + /// Rare (a tenant's first write); imports and steady state never take it. + create_lock: Mutex<()>, + root_base: PathBuf, +} + +impl std::fmt::Debug for Zfs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Zfs") + .field("control", &self.control) + .field("max_pools", &self.max_pools) + .finish() + } +} + +impl Zfs { + /// Genesis: bring up the disk, create the control pool in region 0, publish + /// its anchor 0. Tenant pools are created as tenants arrive. + #[allow(clippy::too_many_arguments)] + pub async fn create( + disk: &Disk, + roots: Arc, + keys: Arc, + master: &MasterSecret, + prefix: &str, + retention: Duration, + clock: Arc, + ) -> Result> { + let (root_base, is_pool, disk_sectors) = bring_up(disk)?; + let region_sectors = DEFAULT_REGION_MIB * SECTORS_PER_MIB; + let generation = claim_generation(&roots, &keys, prefix, retention, &clock).await?; + let (spec, _) = control_spec( + master, + &keys, + prefix, + is_pool, + &root_base, + region_sectors, + disk_sectors, + ) + .await?; + let control = Pool::create( + disk, + roots.clone(), + keys.clone(), + prefix, + retention, + clock.clone(), + spec, + generation, + ) + .await?; + Ok(Self::assemble( + disk, + roots, + keys, + master, + prefix, + retention, + clock, + is_pool, + region_sectors, + disk_sectors, + generation, + control, + root_base, + )) + } + + /// Resume: bring up the disk and import the control pool at its anchor. + /// `min_seq` floors the control chain from outside. + #[allow(clippy::too_many_arguments)] + pub async fn open( + disk: &Disk, + roots: Arc, + keys: Arc, + master: &MasterSecret, + prefix: &str, + retention: Duration, + min_seq: Option, + clock: Arc, + ) -> Result> { + let (root_base, is_pool, disk_sectors) = bring_up(disk)?; + let region_sectors = DEFAULT_REGION_MIB * SECTORS_PER_MIB; + // Refuse a store from before the per-tenant layout (v1: one pool, its + // anchors under `zfs/`) with a clear message, before touching anything + // — not the opaque "no anchors" a v2 import would otherwise give. It is + // migrated offline, never resumed or reformatted in place. + if !exists_raw(&roots, &format!("{prefix}zfs/control/anchors/{:016x}", 0)).await? + && exists_raw(&roots, &format!("{prefix}zfs/anchors/{:016x}", 0)).await? + { + bail!( + "this roots bucket holds a store from before the per-tenant pool layout (v1); \ + it must be migrated offline, not resumed — see docs/COMPATIBILITY.md" + ); + } + let generation = claim_generation(&roots, &keys, prefix, retention, &clock).await?; + let (spec, _) = control_spec( + master, + &keys, + prefix, + is_pool, + &root_base, + region_sectors, + disk_sectors, + ) + .await?; + let control = Pool::open( + disk, + roots.clone(), + keys.clone(), + prefix, + retention, + clock.clone(), + spec, + generation, + min_seq, + None, + ) + .await?; + let zfs = Self::assemble( + disk, + roots, + keys, + master, + prefix, + retention, + clock, + is_pool, + region_sectors, + disk_sectors, + generation, + control, + root_base, + ); + zfs.validate_catalog().await?; + Ok(zfs) + } + + /// In-process test storage: a control pool in a temporary directory. + #[cfg(any(test, feature = "testing"))] + pub async fn scratch() -> Arc { + let master = MasterSecret::from_bytes([7u8; 32]); + let keys = Arc::new(KeyMaterial::derive(&master, [0u8; 16]).expect("deriving keys")); + Zfs::create( + &Disk::scratch().expect("a temporary directory"), + Arc::new(crate::store::backend::memory::MemoryBackend::new()), + keys, + &master, + "", + Duration::from_secs(3600), + Arc::new(crate::clock::HostClock), + ) + .await + .expect("a scratch pool") + } + + #[allow(clippy::too_many_arguments)] + fn assemble( + disk: &Disk, + roots: Arc, + keys: Arc, + master: &MasterSecret, + prefix: &str, + retention: Duration, + clock: Arc, + is_pool: bool, + region_sectors: u64, + disk_sectors: u64, + generation: u64, + control: Arc, + root_base: PathBuf, + ) -> Arc { + Arc::new(Zfs { + disk: disk.clone(), + roots, + keys, + master: master.clone(), + prefix: prefix.to_string(), + retention, + clock, + is_pool, + region_sectors, + disk_sectors, + max_pools: region_count(region_sectors, disk_sectors), + generation, + control, + tenants: std::sync::Mutex::new(HashMap::new()), + tenant_roots: std::sync::Mutex::new(HashMap::new()), + create_lock: Mutex::new(()), + root_base, + }) + } + + /// The hash of the control pool's anchor 0: which history this enclave is, + /// for the boot receipt. + pub fn genesis_hash(&self) -> [u8; 32] { + self.control.genesis_hash() + } + + /// A directory of runtime records on the control pool, out of every guest's + /// reach: credentials, tasks, streams, devices, the tenant catalog. + pub async fn runtime_dir(&self, name: &str) -> Result { + self.control.runtime_dir(name).await + } + + /// A tenant's data directory, its pool created on first use. + pub async fn tenant_dir(&self, tenant_id: [u8; 16]) -> Result<(PathBuf, Arrival)> { + let fresh = !self + .tenants + .lock() + .expect("tenants mutex") + .contains_key(&tenant_id); + let pool = self.tenant_pool(tenant_id, true).await?; + let arrival = if fresh { + Arrival::New + } else { + Arrival::Returning + }; + Ok((pool.data_dir(), arrival)) + } + + /// A tenant's data directory, whose pool must already exist. For work that + /// runs on a tenant's behalf without them, and so must never bring one back. + pub async fn existing_tenant_dir(&self, tenant_id: [u8; 16]) -> Result { + Ok(self.tenant_pool(tenant_id, false).await?.data_dir()) + } + + /// Where a tenant's data directory is. Known exactly once the pool has been + /// opened; otherwise a path under the disk that does not exist, enough for + /// callers only asking whether a tenant's files are there. + pub fn tenant_path(&self, tenant_id: [u8; 16]) -> PathBuf { + self.tenant_roots + .lock() + .expect("tenant_roots mutex") + .get(&tenant_id) + .cloned() + .unwrap_or_else(|| { + self.root_base + .join("pools") + .join(format!("absent-{}", hex::encode(tenant_id))) + .join("data") + }) + } + + /// Anchor a tenant's writes: its own pool, then the control pool for any + /// record a request of theirs left there. Both before anyone is told. + pub async fn anchor_tenant(&self, tenant_id: [u8; 16]) -> Result<()> { + if let Some(pool) = self.imported(tenant_id) { + pool.anchor().await?; + } + self.control.anchor().await + } + + /// Anchor the control pool: a credential, a catalog entry. + pub async fn anchor_control(&self) -> Result<()> { + self.control.anchor().await + } + + /// The tenant's pool if it is already imported this boot. + fn imported(&self, tenant_id: [u8; 16]) -> Option> { + self.tenants + .lock() + .expect("tenants mutex") + .get(&tenant_id) + .and_then(|cell| cell.get().cloned()) + } + + /// Get the tenant's pool, importing it (or, with `create`, genesis-ing it) + /// on first use. A tenant's first access is serialised by its slot; others + /// are not. + async fn tenant_pool(&self, tenant_id: [u8; 16], create: bool) -> Result> { + let cell = { + let mut map = self.tenants.lock().expect("tenants mutex"); + if !map.contains_key(&tenant_id) { + ensure!( + (map.len() as u64) + 1 < self.max_pools, + "no free pool region: the disk holds {} pools and all are taken", + self.max_pools + ); + map.insert(tenant_id, PoolSlot::default()); + } + map.get(&tenant_id).expect("just inserted").clone() + }; + let pool = cell + .get_or_try_init(|| self.open_or_create_tenant(tenant_id, create)) + .await?; + self.tenant_roots + .lock() + .expect("tenant_roots mutex") + .insert(tenant_id, pool.data_dir()); + Ok(pool.clone()) + } + + async fn open_or_create_tenant(&self, tenant_id: [u8; 16], create: bool) -> Result> { + match self.read_catalog(tenant_id).await? { + Some(entry) => self.import_tenant(tenant_id, entry).await, + None if create => self.create_tenant(tenant_id).await, + None => bail!("tenant {} has no storage", hex::encode(tenant_id)), + } + } + + async fn import_tenant(&self, tenant_id: [u8; 16], entry: CatalogEntry) -> Result> { + let spec = self + .tenant_spec(tenant_id, entry.pool_id, entry.region) + .await?; + Pool::open( + &self.disk, + self.roots.clone(), + self.keys.clone(), + &self.prefix, + self.retention, + self.clock.clone(), + spec, + self.generation, + None, + Some(Hash256::from_bytes(entry.genesis)), + ) + .await + } + + async fn create_tenant(&self, tenant_id: [u8; 16]) -> Result> { + let _creating = self.create_lock.lock().await; + let region = self.free_region().await?; + let mut pool_id = [0u8; 16]; + getrandom::fill(&mut pool_id).map_err(|e| anyhow!("pool id entropy: {e}"))?; + let spec = self.tenant_spec(tenant_id, pool_id, region).await?; + let pool = Pool::create( + &self.disk, + self.roots.clone(), + self.keys.clone(), + &self.prefix, + self.retention, + self.clock.clone(), + spec, + self.generation, + ) + .await?; + let entry = CatalogEntry { + version: 1, + pool_id, + region, + genesis: pool.genesis_hash(), + background: false, + }; + self.write_catalog(tenant_id, &entry).await?; + // The catalog entry is durable before the tenant is served: a crash + // before this leaves an unreferenced pool and a free region, nothing + // acknowledged. + self.control.anchor().await?; + tracing::info!( + tenant = %hex::encode(tenant_id), + region, + pool = %tenant_pool_name(&pool_id), + "created a tenant pool" + ); + Ok(pool) + } + + /// Build a tenant pool's spec, mapping its region as a dm-crypt device. + async fn tenant_spec( + &self, + tenant_id: [u8; 16], + pool_id: [u8; 16], + region: u64, + ) -> Result { + let (offset, len) = region_bounds(region, self.region_sectors, self.disk_sectors) + .with_context(|| format!("region {region} does not fit the disk"))?; + let root = self.pool_root(&pool_id); + let mapped = if self.is_pool { + map_region( + &self.master, + self.keys.fs_uuid(), + PoolKind::Tenant, + &pool_id, + &tenant_dev_name(&pool_id), + offset, + len, + ) + .await? + } else { + String::new() + }; + Ok(PoolSpec { + name: tenant_pool_name(&pool_id), + mapped, + kind: PoolKind::Tenant, + pool_id, + tenant_id, + anchor_prefix: format!("{}zfs/pools/{}/", self.prefix, hex::encode(pool_id)), + root, + is_pool: self.is_pool, + }) + } + + fn pool_root(&self, pool_id: &[u8; 16]) -> PathBuf { + self.root_base.join("pools").join(hex::encode(pool_id)) + } + + // ---- the catalog, on the control pool ---------------------------------- + + async fn catalog_dir(&self) -> Result { + self.control.runtime_dir("catalog").await + } + + async fn read_catalog(&self, tenant_id: [u8; 16]) -> Result> { + let path = self.catalog_dir().await?.join(hex::encode(tenant_id)); + match tokio::fs::read(&path).await { + Ok(bytes) => Ok(Some( + serde_json::from_slice(&bytes).context("decoding a catalog entry")?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).context("reading a catalog entry"), + } + } + + async fn write_catalog(&self, tenant_id: [u8; 16], entry: &CatalogEntry) -> Result<()> { + let dir = self.catalog_dir().await?; + let name = hex::encode(tenant_id); + let temp = dir.join(format!("{name}.tmp")); + tokio::fs::write(&temp, serde_json::to_vec(entry)?).await?; + tokio::fs::rename(&temp, dir.join(name)).await?; + Ok(()) + } + + /// Every tenant and its entry, dropping any half-written temporary file. + /// The filename is the tenant id. + async fn catalog(&self) -> Result> { + let mut entries = Vec::new(); + let mut dir = tokio::fs::read_dir(self.catalog_dir().await?).await?; + while let Some(e) = dir.next_entry().await? { + let name = e.file_name().to_string_lossy().into_owned(); + if name.ends_with(".tmp") { + continue; + } + let id: [u8; 16] = hex::decode(&name) + .ok() + .and_then(|v| v.try_into().ok()) + .with_context(|| format!("catalog file {name} is not a tenant id"))?; + let bytes = tokio::fs::read(e.path()).await?; + entries.push(( + id, + serde_json::from_slice(&bytes).context("decoding a catalog entry")?, + )); + } + Ok(entries) + } + + /// The lowest region not already a tenant's, 1 and up (0 is the control + /// pool's). Refused when the disk is full. + async fn free_region(&self) -> Result { + let taken: std::collections::BTreeSet = self + .catalog() + .await? + .iter() + .map(|(_, e)| e.region) + .collect(); + (1..self.max_pools) + .find(|r| !taken.contains(r)) + .context("no free pool region: the disk is full") + } + + /// The tenants with background work, for the boot scan to import and load. + pub async fn background_tenants(&self) -> Result> { + Ok(self + .catalog() + .await? + .into_iter() + .filter(|(_, e)| e.background) + .map(|(id, _)| id) + .collect()) + } + + /// A directory for a tenant's own runtime records (its tasks, its streams), + /// on the tenant's pool, out of the guest's reach. The pool must exist. + pub async fn tenant_runtime_dir(&self, tenant_id: [u8; 16], name: &str) -> Result { + self.tenant_pool(tenant_id, false) + .await? + .runtime_dir(name) + .await + } + + /// Mark a tenant as having background work, so the next boot scans its + /// pool. Durable on the control pool before the record itself, so a crash + /// never leaves a record the boot scan would miss; a flag with no record + /// behind it only costs a spurious scan. A no-op once set. + pub async fn mark_background(&self, tenant_id: [u8; 16]) -> Result<()> { + let mut entry = self + .read_catalog(tenant_id) + .await? + .with_context(|| format!("tenant {} has no catalog entry", hex::encode(tenant_id)))?; + if entry.background { + return Ok(()); + } + entry.background = true; + self.write_catalog(tenant_id, &entry).await?; + self.control.anchor().await + } + + /// At boot: the catalog's regions are distinct, in range, and not the + /// control pool's; its pool ids are distinct. A host that tampered with the + /// (anchored) control pool is caught before any allocation trusts it. + async fn validate_catalog(&self) -> Result<()> { + let entries = self.catalog().await?; + let mut regions = std::collections::BTreeSet::new(); + let mut ids = std::collections::BTreeSet::new(); + for (_, e) in &entries { + ensure!(e.version == 1, "catalog entry from an unknown version"); + ensure!( + e.region >= 1 && e.region < self.max_pools, + "catalog region {} is out of range", + e.region + ); + ensure!( + regions.insert(e.region), + "two tenants share region {}", + e.region + ); + ensure!(ids.insert(e.pool_id), "two tenants share a pool id"); + } + tracing::info!(tenants = entries.len(), "tenant catalog validated"); + Ok(()) + } +} + +/// Bring up the disk once: the modules and `/dev/nbd0` for a real enclave, or a +/// directory for tests. Returns `(disk root, real?, disk sectors)`. +fn bring_up(disk: &Disk) -> Result<(PathBuf, bool, u64)> { + match disk { + Disk::Parent => { + let sectors = bring_up_disk()?; + Ok((PathBuf::from("/"), true, sectors)) + } + #[cfg(any(test, feature = "testing"))] + Disk::Directory(dir) => Ok(( + dir.path().to_path_buf(), + false, + DEFAULT_REGION_MIB * SECTORS_PER_MIB * SCRATCH_REGIONS, + )), + } +} + +/// The control pool's spec: region 0, the fixed control id, anchors under +/// `zfs/control/`. Its root is `/` (its `runtime` dataset mounts at `/runtime`) +/// or the disk directory for tests. +async fn control_spec( + master: &MasterSecret, + keys: &KeyMaterial, + prefix: &str, + is_pool: bool, + root_base: &std::path::Path, + region_sectors: u64, + disk_sectors: u64, +) -> Result<(PoolSpec, ())> { + let (offset, len) = region_bounds(0, region_sectors, disk_sectors) + .context("the disk is too small for even the control region")?; + let mapped = if is_pool { + map_region( + master, + keys.fs_uuid(), + PoolKind::Control, + &CONTROL_POOL_ID, + "zcrypt-control", + offset, + len, + ) + .await? + } else { + String::new() + }; + let root = if is_pool { + PathBuf::from("/") + } else { + root_base.to_path_buf() + }; + Ok(( + PoolSpec { + name: POOL.to_string(), + mapped, + kind: PoolKind::Control, + pool_id: CONTROL_POOL_ID, + tenant_id: [0u8; 16], + anchor_prefix: format!("{prefix}zfs/control/"), + root, + is_pool, + }, + (), + )) +} + +/// `--`, as the pool's `enclave:anchor`. +/// The hash is what lets a boot recognise the marker of an anchor that was +/// never published as the successor of the one that was. +fn marker(seq: u64, nonce: &[u8; 32], prev: &Hash256) -> String { + format!( + "{seq}-{}-{}", + hex::encode(nonce), + hex::encode(prev.as_bytes()) + ) +} + +/// Whether the newest anchor vouches for a loaded pool state: the txg ZFS +/// loaded, the marker it holds and its guid. +/// +/// The marker's nonce first reaches the disk a txg or more before the txg the +/// anchor names, and a write that lands between the two is acknowledged by the +/// anchor. So holding the anchor's marker is not enough; the state must also be +/// no older than the anchored txg. One that is newer descends from it: nothing +/// but the enclave writes the marker, and a nonce is written once. +/// +/// A state may also hold the marker of the next anchor, one the enclave wrote +/// and died before publishing, naming this one as its predecessor. Either way +/// everything the anchor covered is there, and anything after it was never +/// acknowledged, so the boot goes on from it rather than rewinding: a rewind +/// to a txg some way back can find its blocks already reused. +fn admit( + tip: &Anchor, + loaded_txg: Option, + marker: &str, + guid: u64, +) -> std::result::Result<(), String> { + let loaded = loaded_txg.ok_or("the kernel did not say which txg it loaded")?; + if guid != tip.pool_guid { + return Err(format!( + "it is pool {guid}, not the anchored pool {}", + tip.pool_guid + )); + } + if loaded < tip.txg { + return Err(format!( + "it loaded txg {loaded}, older than the anchored txg {}", + tip.txg + )); + } + if marker == tip.property() { + return Ok(()); + } + if let [seq, nonce, prev] = marker.split('-').collect::>()[..] { + if seq == (tip.seq + 1).to_string() + && nonce.len() == 64 + && prev == hex::encode(tip.hash().as_bytes()) + { + return Ok(()); + } + } + Err(format!( + "it holds anchor {marker:?}, but the newest anchor is {:?}", + tip.property() + )) +} + +/// Where the kernel keeps its debug log, `zfs_dbgmsg`. +const DBGMSG: &str = "/proc/spl/kstat/zfs/dbgmsg"; + +/// The txg of the uberblock an import of `pool` loaded, from the kernel's own +/// notes on it in the debug log: the `using uberblock with txg=` of the load +/// that ended `LOADED` with nothing after it. `zpool import` loads the pool +/// more than once — a trial load, a reload with the trusted config, a retry +/// at an older txg — and only the last is the pool as imported. +fn loaded_txg(dbgmsg: &str, pool: &str) -> Option { + let tag = format!("spa_load({pool}, config "); + let (mut using, mut loaded) = (None, None); + for line in dbgmsg.lines() { + let Some((_, rest)) = line.split_once(&tag) else { + continue; + }; + let Some((_, note)) = rest.split_once("): ") else { + continue; + }; + let note = note.trim(); + if let Some(txg) = note.strip_prefix("using uberblock with txg=") { + using = txg.parse().ok(); + } else if note == "LOADED" { + loaded = using; + } else if note == "LOADING" || note == "UNLOADING" || note.starts_with("FAILED") { + using = None; + loaded = None; + } + } + loaded +} + +/// The newest txg that reached the disk: the last committed row of the pool's +/// txg history that wrote anything. A txg that changes nothing writes no +/// uberblock, so "last committed" alone can name one the disk never held. +fn witness(kstat: &str) -> Option { + let mut lines = kstat.lines().skip_while(|l| !l.starts_with("txg")); + let cols: Vec<&str> = lines.next()?.split_whitespace().collect(); + let col = |name| cols.iter().position(|c| *c == name); + let (txg, state, written) = (col("txg")?, col("state")?, col("nwritten")?); + lines + .filter_map(|l| { + let f: Vec<&str> = l.split_whitespace().collect(); + let wrote = f.get(written)?.parse::().ok()? > 0; + (*f.get(state)? == "C" && wrote).then(|| f.get(txg)?.parse().ok())? + }) + .max() +} + +async fn synced_txg(pool: &str) -> Result { + let kstat = tokio::fs::read_to_string(format!("/proc/spl/kstat/zfs/{pool}/txgs")).await?; + witness(&kstat).context("the pool's txg history shows nothing written") +} + +/// Testing only: what the kernel says it loaded, as evidence for the rollback +/// legs of `run-zfs-spike.sh` — the import's own notes from the debug log, and +/// the first txgs the pool opened after it. +#[cfg(feature = "testing")] +async fn log_loaded_state(pool: &str) { + let dbgmsg = tokio::fs::read_to_string("/proc/spl/kstat/zfs/dbgmsg") + .await + .unwrap_or_default(); + for line in dbgmsg.lines().filter(|l| l.contains("spa_load(")) { + tracing::info!(line, "zfs import: dbgmsg"); + } + let txgs = tokio::fs::read_to_string(format!("/proc/spl/kstat/zfs/{pool}/txgs")) + .await + .unwrap_or_default(); + for line in txgs.lines().skip(1).take(4) { + tracing::info!(line, "zfs import: txgs"); + } +} + +/// Testing only: the vsock port `deploy/qemu-nitro/test-hooks.py` answers on. +#[cfg(feature = "testing")] +const TEST_HOOK_PORT: u32 = 9101; + +/// Testing only: stop at a named point in an anchor until the host says to go +/// on, or die there, so a test can copy the disk at an exact moment or kill the +/// enclave between two steps. With nothing listening on the port it returns at +/// once. +#[cfg(feature = "testing")] +async fn hook(point: &'static str, detail: String) { + let line = format!("{point} {detail}\n"); + let reply = tokio::task::spawn_blocking(move || -> io::Result { + let mut v = vsock_connect(PARENT_CID, TEST_HOOK_PORT)?; + v.write_all(line.as_bytes())?; + let mut reply = String::new(); + io::BufRead::read_line(&mut io::BufReader::new(v), &mut reply)?; + Ok(reply) + }) + .await; + if let Ok(Ok(reply)) = reply { + if reply.trim() == "abort" { + tracing::error!(point, detail, "test hook: dying here"); + std::process::abort(); + } + } +} + +async fn pool_guid(pool: &str) -> Result { + let out = run("zpool", &["get", "-Hp", "-o", "value", "guid", pool]).await?; + out.trim().parse().context("parsing the pool guid") +} + +async fn run(program: &str, args: &[&str]) -> Result { + run_with_input(program, args, None).await +} + +/// Runs a ZFS or device-mapper tool. `input` goes on stdin, never argv, which +/// any process can read from /proc. +async fn run_with_input(program: &str, args: &[&str], input: Option<&str>) -> Result { + let mut child = Command::new(program) + .args(args) + // No udev in an enclave: libdevmapper makes its own /dev/mapper node. + .env("DM_DISABLE_UDEV", "1") + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("starting {program}"))?; + if let Some(input) = input { + let mut stdin = child.stdin.take().expect("piped"); + stdin.write_all(input.as_bytes()).await?; + } + let out = child.wait_with_output().await?; + ensure!( + out.status.success(), + "{program} {}: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr).trim() + ); + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +fn insmod(path: &str, params: &str) -> Result<()> { + let module = File::open(path).with_context(|| format!("opening {path}"))?; + match rustix::system::finit_module(&module, &std::ffi::CString::new(params)?, 0) { + Ok(()) | Err(rustix::io::Errno::EXIST) => Ok(()), + Err(e) => Err(e).with_context(|| format!("loading {path}")), + } +} + +/// Load the ZFS modules and attach the parent's disk as `/dev/nbd0`, once per +/// boot. Returns the disk's size in 512-byte sectors. +fn bring_up_disk() -> Result { + insmod("/lib/zfs/spl.ko", "")?; + insmod("/lib/zfs/zfs.ko", ZFS_PARAMS)?; + Ok(nbd_attach(NBD_PORT)? / 512) +} + +/// Map one region of `/dev/nbd0` as its own dm-crypt device, returning the +/// device path. The key is domain-separated by pool kind and id, so one +/// region's key never decrypts another's; offset and length are the region's, +/// in 512-byte sectors. +async fn map_region( + master: &MasterSecret, + fs_uuid: &[u8; 16], + kind: PoolKind, + pool_id: &[u8; 16], + dev_name: &str, + offset_sectors: u64, + len_sectors: u64, +) -> Result { + let mut info = b"zfs/dmcrypt/v2".to_vec(); + info.push(kind.byte()); + info.extend_from_slice(pool_id); + let mut key = [0u8; 64]; // two AES-256 keys, for XTS + hkdf(master, fs_uuid, &info, &mut key)?; + let mapped = format!("/dev/mapper/{dev_name}"); + let mut table = format!( + "0 {len_sectors} crypt aes-xts-plain64 {} 0 {NBD_DEVICE} {offset_sectors} 1 sector_size:4096\n", + hex::encode(key) + ); + key.zeroize(); + let r = run_with_input( + "dmsetup", + &["create", dev_name, "--noudevsync"], + Some(&table), + ) + .await; + table.zeroize(); + r.map(|_| mapped) +} + +/// Whether a device already holds a pool: `zpool import -d ` lists one. +async fn pool_present(mapped: &str) -> Result { + let listed = Command::new("zpool") + .args(["import", "-d", mapped]) + .output() + .await + .context("running zpool import to probe the device")?; + Ok(String::from_utf8_lossy(&listed.stdout).contains("pool:")) +} + +const NBDMAGIC: u64 = 0x4e42_444d_4147_4943; +const IHAVEOPT: u64 = 0x4948_4156_454f_5054; + +/// Attach `/dev/nbd0` to the parent's NBD export on vsock `port`. +/// +/// The kernel takes only TCP and UNIX sockets for NBD ("Unsupported socket: +/// should be TCP or UNIX"), so it is given one end of a socketpair and two +/// threads copy between the other end and the vsock connection. +// ponytail: those copies go through userspace; splice(2) them if disk +// throughput is ever what limits a request. +fn nbd_attach(port: u32) -> Result { + let mut v = vsock_connect(PARENT_CID, port) + .with_context(|| format!("connecting to the parent's NBD server on vsock port {port}"))?; + + // Fixed-newstyle handshake, default export. + let mut hello = [0u8; 18]; + v.read_exact(&mut hello)?; + ensure!( + hello[..8] == NBDMAGIC.to_be_bytes() && hello[8..16] == IHAVEOPT.to_be_bytes(), + "the parent's NBD server does not speak fixed-newstyle" + ); + let no_zeroes = u16::from_be_bytes([hello[16], hello[17]]) & 2; + v.write_all(&(1 | u32::from(no_zeroes)).to_be_bytes())?; + let mut export_name = IHAVEOPT.to_be_bytes().to_vec(); + export_name.extend(1u32.to_be_bytes()); // NBD_OPT_EXPORT_NAME + export_name.extend(0u32.to_be_bytes()); // "", the default + v.write_all(&export_name)?; + let mut reply = [0u8; 10]; + v.read_exact(&mut reply)?; + let size = u64::from_be_bytes(reply[..8].try_into().expect("8 bytes")); + let flags = u16::from_be_bytes([reply[8], reply[9]]); + if no_zeroes == 0 { + v.read_exact(&mut [0u8; 124])?; + } + + let (kernel_end, bridge) = UnixStream::pair()?; + pump(bridge.try_clone()?, v.try_clone()?, "enclave to parent"); + pump(v, bridge, "parent to enclave"); + + let dev = File::options().read(true).write(true).open(NBD_DEVICE)?; + // SAFETY: the NBD ioctls are _IO(0xab, n), each taking the integer below. + unsafe { + ioctl::ioctl( + &dev, + IntegerSetter::<{ opcode::none(0xab, 1) }>::new_usize(4096), + )?; // SET_BLKSIZE + ioctl::ioctl( + &dev, + IntegerSetter::<{ opcode::none(0xab, 7) }>::new_usize((size / 4096) as usize), + )?; // SET_SIZE_BLOCKS + // Without the server's flags the kernel never sends FLUSH, and nothing + // written would be asked to reach the parent's disk. + ioctl::ioctl( + &dev, + IntegerSetter::<{ opcode::none(0xab, 10) }>::new_usize(flags.into()), + )?; // SET_FLAGS + ioctl::ioctl( + &dev, + IntegerSetter::<{ opcode::none(0xab, 0) }>::new_usize(kernel_end.as_raw_fd() as usize), + )?; // SET_SOCK + } + std::thread::spawn(move || { + let _socket = kernel_end; + // SAFETY: NBD_DO_IT takes no argument; it serves until the device goes. + let r = unsafe { ioctl::ioctl(&dev, NoArg::<{ opcode::none(0xab, 3) }>::new()) }; + tracing::error!(?r, "the NBD device stopped; the pool has no disk"); + std::process::exit(71); + }); + // The device starts inside NBD_DO_IT, on that thread. Until then it has + // no size and dm-crypt would map nothing. Started is when this appears. + for _ in 0..100 { + if Path::new("/sys/block/nbd0/pid").exists() { + return Ok(size); + } + std::thread::sleep(Duration::from_millis(50)); + } + bail!("/dev/nbd0 did not start within 5s of NBD_DO_IT") +} + +fn pump( + mut from: impl Read + Send + 'static, + mut to: impl Write + Send + 'static, + way: &'static str, +) { + std::thread::spawn(move || { + let r = io::copy(&mut from, &mut to); + tracing::error!(way, ?r, "the NBD bridge closed; the pool has no disk"); + std::process::exit(71); + }); +} + +/// `struct sockaddr_vm`, which rustix has no type for. +#[repr(C)] +struct VsockAddr { + family: u16, + reserved: u16, + port: u32, + cid: u32, + zero: [u8; 4], +} + +// SAFETY: a complete sockaddr_vm, readable for its whole size for the call. +unsafe impl SocketAddrArg for VsockAddr { + unsafe fn with_sockaddr( + &self, + f: impl FnOnce(*const SocketAddrOpaque, SocketAddrLen) -> R, + ) -> R { + f( + (self as *const Self).cast(), + std::mem::size_of::() as SocketAddrLen, + ) + } +} + +fn vsock_connect(cid: u32, port: u32) -> io::Result { + let fd = rustix::net::socket(AddressFamily::VSOCK, SocketType::STREAM, None)?; + let addr = VsockAddr { + family: AddressFamily::VSOCK.as_raw(), + reserved: 0, + port, + cid, + zero: [0; 4], + }; + rustix::net::connect(&fd, &addr)?; + Ok(File::from(fd)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::crypto::MasterSecret; + + fn keys(master: u8) -> KeyMaterial { + KeyMaterial::derive(&MasterSecret::from_bytes([master; 32]), [1u8; 16]).unwrap() + } + + /// A tenant-pool anchor, for the codec and admission tests. `guid` stands + /// in for the pool guid; the pool id is fixed so chaining can be checked. + const TEST_POOL_ID: [u8; 16] = [0x7c; 16]; + fn tseal( + k: &KeyMaterial, + prev: Option<&Anchor>, + guid: u64, + txg: u64, + nonce: [u8; 32], + ) -> Anchor { + Anchor::seal( + k, + prev, + PoolKind::Tenant, + TEST_POOL_ID, + [0x7a; 16], + guid, + txg, + nonce, + ) + } + + #[test] + fn witness_is_the_newest_committed_txg_that_wrote() { + // As /proc/spl/kstat/zfs//txgs prints it: a raw kstat header, + // the column names, then one row per txg. + let kstat = "\ +13 0 0x01 6 672 1234 5678 +txg birth state ndirty nread nwritten reads writes otime qtime wtime stime +26 100 C 0 0 90112 0 12 10 10 10 10 +27 200 C 0 0 0 0 0 10 10 10 10 +28 300 C 0 0 0 0 0 10 10 10 10 +29 400 C 4096 0 86016 0 11 10 10 10 10 +30 500 S 4096 0 0 0 0 10 10 0 0 +31 600 O 0 0 0 0 0 0 0 0 0 +"; + assert_eq!(witness(kstat), Some(29)); + assert_eq!(witness("txg birth state ndirty nread nwritten\n"), None); + } + + /// Shaped as the emulator's kernel logged a boot: a trial load, then the + /// load with the trusted config. The two name different txgs here, which + /// a host serving two reads differently could make happen; the last is + /// the pool as imported. `$import` is a trial under its own name. + const PLAIN_IMPORT: &str = "\ +timestamp message +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load($import, config untrusted): using uberblock with txg=900 +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): LOADING +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): using uberblock with txg=845 +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): LOADED +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): UNLOADING +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): LOADING +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): using uberblock with txg=846 +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): Read 9 log space maps (9 total blocks - blksz = 131072 bytes) in 1 ms +1791607760 ffff8880052b5140 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): LOADED +"; + + /// And a load that failed at one txg and was retried at an older one. + const RETRIED_IMPORT: &str = "\ +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): LOADING +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): using uberblock with txg=810 +1791607707 ffff888003fe2080 spa_misc.c:417:spa_load_failed(): spa_load(enclave, config untrusted): FAILED: couldn't get 'config' value in MOS directory [error=5] +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): UNLOADING +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): spa_load_retry: rewind, max txg: 809 +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): LOADING +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config untrusted): using uberblock with txg=809 +1791607707 ffff888003fe2080 spa_misc.c:431:spa_load_note(): spa_load(enclave, config trusted): LOADED +"; + + #[test] + fn the_loaded_txg_is_the_last_load_that_finished() { + assert_eq!(loaded_txg(PLAIN_IMPORT, "enclave"), Some(846)); + assert_eq!(loaded_txg(RETRIED_IMPORT, "enclave"), Some(809)); + assert_eq!( + loaded_txg(PLAIN_IMPORT, "encl"), + None, + "another pool's name" + ); + // Anything after the last LOADED means it is not the pool as imported. + for after in ["UNLOADING", "FAILED: no valid uberblock found", "LOADING"] { + let later = format!("{PLAIN_IMPORT}1 x spa_load(enclave, config trusted): {after}\n"); + assert_eq!(loaded_txg(&later, "enclave"), None, "{after} after LOADED"); + } + // LOADED with no uberblock named since the load began. + let unnamed = "1 x spa_load(enclave, config trusted): LOADING\n\ + 1 x spa_load(enclave, config trusted): LOADED\n"; + assert_eq!(loaded_txg(unnamed, "enclave"), None); + assert_eq!(loaded_txg("", "enclave"), None); + } + + #[test] + fn only_the_anchored_state_and_its_descendants_are_admitted() { + let k = keys(7); + let a0 = tseal(&k, None, 42, 100, [9u8; 32]); + let tip = tseal(&k, Some(&a0), 42, 846, [8u8; 32]); + let mine = tip.property(); + assert!( + admit(&tip, Some(846), &mine, 42).is_ok(), + "the anchored txg" + ); + assert!(admit(&tip, Some(900), &mine, 42).is_ok(), "synced past it"); + // The leg-7 disk: the marker's txg, holding the right nonce. + let early = admit(&tip, Some(845), &mine, 42).unwrap_err(); + assert!(early.contains("older than the anchored txg"), "{early}"); + assert!( + admit(&tip, None, &mine, 42).is_err(), + "no word from the kernel" + ); + assert!(admit(&tip, Some(846), &mine, 43).is_err(), "another pool"); + // Rolled back: the anchor before, at an older txg or not. + assert!(admit(&tip, Some(846), &a0.property(), 42).is_err()); + // The marker of an anchor that died unpublished, naming this one. + let next = marker(tip.seq + 1, &[5u8; 32], &tip.hash()); + assert!(admit(&tip, Some(860), &next, 42).is_ok()); + // ... but not one naming another predecessor, or two seqs on. + assert!(admit( + &tip, + Some(860), + &marker(tip.seq + 1, &[5u8; 32], &a0.hash()), + 42 + ) + .is_err()); + assert!(admit( + &tip, + Some(860), + &marker(tip.seq + 2, &[5u8; 32], &tip.hash()), + 42 + ) + .is_err()); + // The 2-field marker shape is not a successor and is refused. + assert!(admit( + &tip, + Some(846), + &format!("{}-{}", tip.seq, hex::encode(tip.nonce)), + 42 + ) + .is_err()); + } + + /// A bucket holding a pre-per-tenant (v1) store — anchors under `zfs/`, + /// none under `zfs/control/` — is refused with a migration message, not + /// resumed, and before a generation is claimed. + #[tokio::test] + async fn a_v1_store_is_refused_with_a_migration_message() { + use crate::store::backend::memory::MemoryBackend; + let backend: Arc = Arc::new(MemoryBackend::new()); + // A v1 anchor 0 at the old prefix, and nothing under zfs/control/. + backend + .put_blob(PutBlobInput::new( + "zfs/anchors/0000000000000000".to_string(), + Bytes::from_static(b"a v1 anchor"), + )) + .await + .unwrap(); + let master = MasterSecret::from_bytes([7; 32]); + let km = Arc::new(KeyMaterial::derive(&master, [1u8; 16]).unwrap()); + let err = Zfs::open( + &Disk::scratch().unwrap(), + backend.clone(), + km, + &master, + "", + Duration::from_secs(86_400), + None, + Arc::new(crate::clock::HostClock), + ) + .await + .expect_err("a v1 store must be refused"); + assert!( + format!("{err:#}").contains("per-tenant pool layout (v1)"), + "wrong error: {err:#}" + ); + // It refused before claiming a generation: the bucket is untouched. + assert!(!exists_raw(&backend, "zfs/generations/0000000000000000") + .await + .unwrap()); + } + + #[test] + fn regions_tile_the_disk_without_overlap_or_overrun() { + let (region, disk) = (400, 1000); // 2 whole regions, 200 sectors spare + assert_eq!(region_count(region, disk), 2); + assert_eq!(region_bounds(0, region, disk), Some((0, 400))); + assert_eq!(region_bounds(1, region, disk), Some((400, 400))); + assert_eq!(region_bounds(2, region, disk), None, "the third overruns"); + let (o0, l0) = region_bounds(0, region, disk).unwrap(); + assert_eq!( + o0 + l0, + region_bounds(1, region, disk).unwrap().0, + "abut, not overlap" + ); + } + + /// Two tenants get two pools, in two regions, with two ids; each returns to + /// its own; a third tenant with no catalog entry is refused existing-only. + #[tokio::test] + async fn tenants_get_independent_pools() { + let fs = Zfs::scratch().await; + let (a, _) = fs.tenant_dir([1; 16]).await.unwrap(); + let (b, _) = fs.tenant_dir([2; 16]).await.unwrap(); + assert_ne!(a, b, "two tenants shared a data directory"); + assert_eq!( + fs.tenant_dir([1; 16]).await.unwrap().0, + a, + "a tenant lost its pool" + ); + + let catalog = fs.catalog().await.unwrap(); + assert_eq!(catalog.len(), 2); + let regions: std::collections::BTreeSet = + catalog.iter().map(|(_, e)| e.region).collect(); + assert_eq!( + regions, + [1, 2].into(), + "tenants did not take regions 1 and 2" + ); + assert_ne!( + catalog[0].1.pool_id, catalog[1].1.pool_id, + "two tenants share a pool id" + ); + + // existing_tenant_dir never creates: an unknown tenant is refused. + assert!(fs.existing_tenant_dir([3; 16]).await.is_err()); + // but a known one resolves. + assert_eq!(fs.existing_tenant_dir([1; 16]).await.unwrap(), a); + fs.validate_catalog().await.unwrap(); + } + + #[test] + fn a_generation_round_trips_and_refuses_tampering_and_strangers() { + let k = keys(7); + let g = seal_generation(&k, 5); + assert_eq!(g.len(), GEN_LEN); + assert!(verify_generation(&g, &k, 5).is_ok()); + assert!( + verify_generation(&g, &k, 6).is_err(), + "number from another key" + ); + assert!( + verify_generation(&g, &keys(8), 5).is_err(), + "another master" + ); + let mut flipped = g.to_vec(); + flipped[GEN_OFF_G + 7] ^= 1; + assert!(verify_generation(&flipped, &k, 5).is_err()); + assert!( + verify_generation(&g[..GEN_LEN - 1], &k, 5).is_err(), + "truncated" + ); + } + + /// Each boot claims the next generation, and the fence it leaves behind is + /// verifiable. Directory-backed, so this is the claim logic, not the pool. + #[tokio::test] + async fn boots_claim_generations_in_order() { + use crate::store::backend::memory::MemoryBackend; + let backend = Arc::new(MemoryBackend::new()); + let master = MasterSecret::from_bytes([7; 32]); + let km = Arc::new(KeyMaterial::derive(&master, [1u8; 16]).unwrap()); + let disk = Disk::scratch().unwrap(); + let day = Duration::from_secs(86_400); + + let first = Zfs::create( + &disk, + backend.clone(), + km.clone(), + &master, + "", + day, + Arc::new(crate::clock::HostClock), + ) + .await + .unwrap(); + assert_eq!(first.control.generation, 0); + for expected in 1..=2 { + let next = Zfs::open( + &disk, + backend.clone(), + km.clone(), + &master, + "", + day, + None, + Arc::new(crate::clock::HostClock), + ) + .await + .unwrap(); + assert_eq!(next.control.generation, expected); + let c = &next.control; + assert!(generation_exists(&c.roots, &c.keys, &c.prefix, expected) + .await + .unwrap()); + assert!( + !generation_exists(&c.roots, &c.keys, &c.prefix, expected + 1) + .await + .unwrap() + ); + } + } + + #[test] + fn anchor_round_trips_and_chains() { + let k = keys(7); + let a0 = tseal(&k, None, 42, 4, [9u8; 32]); + let a1 = tseal(&k, Some(&a0), 42, 9, [8u8; 32]); + assert_eq!(a1.encoded().len(), ANCHOR_LEN); + assert_eq!( + Anchor::decode_and_verify(&a0.encoded(), &k, 0, TEST_POOL_ID).unwrap(), + a0 + ); + let back = Anchor::decode_and_verify(&a1.encoded(), &k, 1, TEST_POOL_ID).unwrap(); + assert_eq!((back.seq, back.txg, back.prev), (1, 9, a0.hash())); + assert_eq!( + (back.kind, back.pool_id, back.tenant_id), + (PoolKind::Tenant, TEST_POOL_ID, [0x7a; 16]) + ); + } + + #[test] + fn anchor_refuses_tampering_replay_and_strangers() { + let k = keys(7); + let a = tseal(&k, None, 42, 4, [9u8; 32]); + let mut flipped = a.encoded().to_vec(); + flipped[OFF_TXG + 7] ^= 1; + assert!(Anchor::decode_and_verify(&flipped, &k, 0, TEST_POOL_ID).is_err()); + assert!( + Anchor::decode_and_verify(&a.encoded(), &k, 1, TEST_POOL_ID).is_err(), + "replayed at another seq" + ); + assert!( + Anchor::decode_and_verify(&a.encoded(), &keys(8), 0, TEST_POOL_ID).is_err(), + "another master" + ); + assert!( + Anchor::decode_and_verify(&a.encoded(), &k, 0, [0x99; 16]).is_err(), + "another pool's id" + ); + } +} diff --git a/runtime/tests/boot_origin.rs b/runtime/tests/boot_origin.rs index da4ad90..1fabd52 100644 --- a/runtime/tests/boot_origin.rs +++ b/runtime/tests/boot_origin.rs @@ -29,15 +29,20 @@ use std::sync::{Arc, Mutex}; use bytes::Bytes; use enclave_runtime::boot::{BootConfig, BootMode, Pair, ReceiptTrust}; -use enclave_runtime::{Backends, MasterKeySource, MountConfig, SealedKey, StaticKey}; -use nitro_attestation::testing::TestChain; -use nitro_attestation::AttestationDocument; -use nitro_nsm::{AttestationRequest, Nsm, Pcr, PCR_GUEST, PCR_ZERO}; -use s3fs_core::backend::{ +use enclave_runtime::store::backend::{ memory::MemoryBackend, Backend, BlobMeta, Capabilities, CompletedPart, CopyBlobInput, GetBlobOutput, ListBlobsInput, ListBlobsOutput, MultipartId, PartUploadOutput, PutBlobInput, }; -use s3fs_core::{FsError, MasterSecret}; +use enclave_runtime::store::{MasterSecret, StoreError}; +use enclave_runtime::{Disk, MasterKeySource, MountConfig, SealedKey, StaticKey}; +use std::sync::Arc as StdArc; + +fn test_clock() -> StdArc { + StdArc::new(enclave_runtime::HostClock) +} +use nitro_attestation::testing::TestChain; +use nitro_attestation::AttestationDocument; +use nitro_nsm::{AttestationRequest, Nsm, Pcr, PCR_GUEST, PCR_ZERO}; const GUEST_A: &[u8] = b"guest component A"; const GUEST_B: &[u8] = b"guest component B"; @@ -147,24 +152,18 @@ impl Nsm for TestNsm { } } -/// One store, reused across boots, so "resume" means what it says. +/// One bucket and one disk, reused across boots, so "resume" means what it +/// says. struct Store { - data: Arc, roots: Arc, + disk: Disk, } impl Store { fn new() -> Self { Store { - data: Arc::new(MemoryBackend::new()), roots: Arc::new(MemoryBackend::new()), - } - } - - fn backends(&self) -> Backends { - Backends { - data: self.data.clone(), - roots: self.roots.clone(), + disk: Disk::scratch().expect("a scratch disk"), } } @@ -217,8 +216,7 @@ impl Store { fn config() -> MountConfig { MountConfig { - bucket: "data".into(), - roots_bucket: Some("roots".into()), + roots_bucket: "roots".into(), region: "us-east-1".into(), endpoint: None, access_key_id: None, @@ -226,17 +224,17 @@ fn config() -> MountConfig { session_token: None, force_path_style: false, bucket_prefix: String::new(), - mount_path: "/".into(), fs_id: FS_ID, min_root_seq: None, skip_bucket_probe: true, request_timeout: std::time::Duration::from_secs(30), - root_retention: s3fs_core::store::config::DEFAULT_ROOT_RETENTION, + root_retention: enclave_runtime::DEFAULT_ROOT_RETENTION, } } -fn boot_config() -> BootConfig { +fn boot_config(store: &Store) -> BootConfig { BootConfig { + disk: store.disk.clone(), trust: ReceiptTrust::UnsignedEmulator, } } @@ -257,15 +255,26 @@ fn record_key(pair: &Pair) -> String { } async fn boot(store: &Store, nsm: &Arc) -> anyhow::Result { - boot_on(store.backends(), nsm).await + boot_on(store, store.roots.clone(), nsm).await } +/// A boot of `store`'s disk through `roots`, which may be the store's bucket +/// seen through something hostile. async fn boot_on( - backends: Backends, + store: &Store, + roots: Arc, nsm: &Arc, ) -> anyhow::Result { let nsm: Arc = nsm.clone(); - enclave_runtime::boot(&backends, &config(), &boot_config(), &nsm, &key()).await + enclave_runtime::boot( + &roots, + &config(), + &boot_config(store), + &nsm, + &key(), + &test_clock(), + ) + .await } #[tokio::test] @@ -513,9 +522,16 @@ async fn an_unmeasured_guest_is_refused_before_any_key_is_asked_for() { asked: AtomicUsize::new(0), }; - let err = enclave_runtime::boot(&store.backends(), &config(), &boot_config(), &nsm, &keys) - .await - .expect_err("must refuse"); + let err = enclave_runtime::boot( + &store.roots, + &config(), + &boot_config(&store), + &nsm, + &keys, + &test_clock(), + ) + .await + .expect_err("must refuse"); assert!( format!("{err:#}").contains("PCR16 is not locked"), "{err:#}" @@ -677,7 +693,7 @@ impl Backend for Interloper { self.inner.capabilities() } - async fn head_blob(&self, key: &str) -> Result { + async fn head_blob(&self, key: &str) -> Result { self.inner.head_blob(key).await } @@ -685,19 +701,19 @@ impl Backend for Interloper { &self, key: &str, range: Option>, - ) -> Result { + ) -> Result { self.inner.get_blob(key, range).await } - async fn get_retained_blob(&self, key: &str) -> Result { + async fn get_retained_blob(&self, key: &str) -> Result { self.inner.get_retained_blob(key).await } - async fn put_blob(&self, input: PutBlobInput) -> Result { + async fn put_blob(&self, input: PutBlobInput) -> Result { self.inner.put_blob(input).await } - async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> Result { + async fn put_blob_if_not_exists(&self, input: PutBlobInput) -> Result { if input.key.contains(".pair.") { let planted = self.plant.lock().unwrap().take(); if let Some(planted) = planted { @@ -713,19 +729,19 @@ impl Backend for Interloper { self.inner.put_blob_if_not_exists(input).await } - async fn delete_blob(&self, key: &str) -> Result<(), FsError> { + async fn delete_blob(&self, key: &str) -> Result<(), StoreError> { self.inner.delete_blob(key).await } - async fn list_blobs(&self, input: ListBlobsInput<'_>) -> Result { + async fn list_blobs(&self, input: ListBlobsInput<'_>) -> Result { self.inner.list_blobs(input).await } - async fn copy_blob(&self, input: CopyBlobInput) -> Result { + async fn copy_blob(&self, input: CopyBlobInput) -> Result { self.inner.copy_blob(input).await } - async fn multipart_begin(&self, input: PutBlobInput) -> Result { + async fn multipart_begin(&self, input: PutBlobInput) -> Result { self.inner.multipart_begin(input).await } @@ -735,7 +751,7 @@ impl Backend for Interloper { upload_id: &MultipartId, part_number: u32, body: Bytes, - ) -> Result { + ) -> Result { self.inner .multipart_upload_part(key, upload_id, part_number, body) .await @@ -748,7 +764,7 @@ impl Backend for Interloper { part_number: u32, source_key: &str, source_range: Range, - ) -> Result { + ) -> Result { self.inner .multipart_upload_part_copy(key, upload_id, part_number, source_key, source_range) .await @@ -759,11 +775,11 @@ impl Backend for Interloper { key: &str, upload_id: &MultipartId, parts: Vec, - ) -> Result { + ) -> Result { self.inner.multipart_complete(key, upload_id, parts).await } - async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> Result<(), FsError> { + async fn multipart_abort(&self, key: &str, upload_id: &MultipartId) -> Result<(), StoreError> { self.inner.multipart_abort(key, upload_id).await } } @@ -778,11 +794,8 @@ async fn a_first_boot_that_loses_the_race_boots_on_the_winners_record() { .await .expect("genesis"); - let backends = Backends { - data: store.data.clone(), - roots: Interloper::planting(store.roots.clone(), Planted::TheSameRecord), - }; - let booted = boot_on(backends, &TestNsm::running(0xaa, GUEST_B)) + let roots = Interloper::planting(store.roots.clone(), Planted::TheSameRecord); + let booted = boot_on(&store, roots, &TestNsm::running(0xaa, GUEST_B)) .await .expect("the loser boots"); assert_eq!( @@ -804,14 +817,11 @@ async fn junk_that_wins_the_race_is_refused() { .await .expect("genesis"); - let backends = Backends { - data: store.data.clone(), - roots: Interloper::planting( - store.roots.clone(), - Planted::Bytes(b"not a record".to_vec()), - ), - }; - let err = boot_on(backends, &TestNsm::running(0xaa, GUEST_B)) + let roots = Interloper::planting( + store.roots.clone(), + Planted::Bytes(b"not a record".to_vec()), + ); + let err = boot_on(&store, roots, &TestNsm::running(0xaa, GUEST_B)) .await .expect_err("must refuse"); assert!(format!("{err:#}").contains("does not describe"), "{err:#}"); diff --git a/runtime/tests/guest_logs.rs b/runtime/tests/guest_logs.rs index f31d50a..35d5bc2 100644 --- a/runtime/tests/guest_logs.rs +++ b/runtime/tests/guest_logs.rs @@ -25,8 +25,6 @@ use bytes::Bytes; use enclave_runtime::guest_io::{GuestLogRecord, GuestLogSink, GuestStream}; use enclave_runtime::{GuestEnvironment, HostClock, ServeHandle}; use http_body_util::{BodyExt, Full}; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; use wasmtime_wasi_http::p2::bindings::http::types::Scheme; use wasmtime_wasi_http::p2::body::HyperOutgoingBody; @@ -65,23 +63,14 @@ impl MemorySink { } async fn handle_with(sink: Arc) -> (ServeHandle, enclave_runtime::GuestLogCollector) { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the memory-backed filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; // Started before the handle exists, so there is no window in which a guest // could write with nothing consuming it. let (logs, collector) = enclave_runtime::guest_io::start(sink); let guest = GuestEnvironment::new( fs, - Box::new(HostClock), + Arc::new(HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), &[], &[], diff --git a/crates/s3fs-core/tests/minio_integration.rs b/runtime/tests/minio_integration.rs similarity index 57% rename from crates/s3fs-core/tests/minio_integration.rs rename to runtime/tests/minio_integration.rs index e636eaa..3a18c09 100644 --- a/crates/s3fs-core/tests/minio_integration.rs +++ b/runtime/tests/minio_integration.rs @@ -5,17 +5,14 @@ //! //! ```bash //! scripts/minio-image.sh -//! cargo test -p s3fs-core --features aws --test minio_integration -- --ignored +//! cargo test -p enclave-runtime --test minio_integration -- --ignored //! ``` //! //! Each test spins up its own MinIO container and creates a fresh bucket so //! tests don't interfere. Containers are cleaned up automatically when the //! returned `ContainerAsync` handle drops at end of scope. -#![cfg(feature = "aws")] - use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; use bytes::Bytes; @@ -23,13 +20,11 @@ use testcontainers::runners::AsyncRunner; use testcontainers::{ContainerAsync, ImageExt}; use testcontainers_modules::minio::MinIO; -use s3fs_core::backend::{ +use enclave_runtime::store::backend::{ AwsS3Backend, AwsS3BackendConfig, Backend, CompletedPart, ListBlobsInput, PutBlobInput, }; -use s3fs_core::backend::{ObjectLock, ObjectLockMode}; -use s3fs_core::errors::FsError; -use s3fs_core::fs::OpenFlags; -use s3fs_core::{Config, Fs, MasterSecret}; +use enclave_runtime::store::backend::{ObjectLock, ObjectLockMode}; +use enclave_runtime::store::error::StoreError; /// Start a MinIO container, build an `AwsS3Backend` pointed at it, and create /// the bucket. The returned `ContainerAsync` MUST stay in scope for the @@ -121,7 +116,7 @@ async fn put_head_get_delete_round_trip() { backend.delete_blob("hello.txt").await.unwrap(); assert!(matches!( backend.head_blob("hello.txt").await, - Err(FsError::NotFound) + Err(StoreError::NotFound) )); } @@ -157,7 +152,7 @@ async fn put_blob_if_not_exists_returns_already_exists_on_collision() { backend.put_blob_if_not_exists(mk()).await.unwrap(); assert!(matches!( backend.put_blob_if_not_exists(mk()).await, - Err(FsError::AlreadyExists) + Err(StoreError::AlreadyExists) )); } @@ -305,7 +300,7 @@ async fn a_retained_root_can_be_hidden_but_not_destroyed() { assert!( matches!( roots.get_blob("roots/0000000000000000", None).await, - Err(FsError::NotFound) + Err(StoreError::NotFound) ), "a delete marker must hide the current version" ); @@ -342,231 +337,3 @@ async fn a_retained_root_can_be_hidden_but_not_destroyed() { "the retained version must outrank anything written over the marker" ); } - -// ---------- Engine-level tests ---------- - -const TEST_MASTER: [u8; 32] = [0x42; 32]; -const TEST_FS_ID: [u8; 16] = [0x11; 16]; - -fn engine_config() -> Config { - Config::builder() - .record_size(64 * 1024) - // Retention is exercised separately; leaving it off keeps these tests - // able to tear their buckets down. - .root_retention(None) - .build() -} - -/// Create on first use, mount thereafter — several tests here remount a bucket -/// to prove the state survived, and `Fs::mount` no longer formats an empty -/// store, so wanting a filesystem has to be said out loud. -async fn mount(data: &AwsS3Backend, roots: &AwsS3Backend) -> Arc { - let data = Arc::new(data.clone()) as Arc; - let roots = Arc::new(roots.clone()) as Arc; - match Fs::mount( - data.clone(), - roots.clone(), - &MasterSecret::from_bytes(TEST_MASTER), - TEST_FS_ID, - Arc::new(engine_config()), - None, - ) - .await - { - Err(s3fs_core::FsError::NoFilesystem) => Fs::create( - data, - roots, - &MasterSecret::from_bytes(TEST_MASTER), - TEST_FS_ID, - Arc::new(engine_config()), - ) - .await - .expect("create"), - other => other.expect("mount"), - } -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires Docker; run with --features aws -- --ignored"] -async fn end_to_end_write_read_and_remount() { - let (_c, data) = fresh_minio_with_bucket("data-e2e").await; - let roots = extra_bucket(&data, "roots-e2e", false).await; - - let payload: Vec = (0..300_000u32).map(|i| (i % 251) as u8).collect(); - { - let fs = mount(&data, &roots).await; - let root = fs.root(); - fs.mkdir(&root, "dir").await.unwrap(); - let h = fs.open("/dir/file", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &payload).await.unwrap(); - fs.close(&h).await.unwrap(); - } - - // A separate mount, reading only what the anchored root records commit. - let fs = mount(&data, &roots).await; - let h = fs.open("/dir/file", OpenFlags::read_only()).await.unwrap(); - let got = fs.pread(&h, 0, payload.len()).await.unwrap(); - assert_eq!(got.len(), payload.len()); - assert_eq!(got.as_ref(), payload.as_slice()); - - let names: Vec<_> = fs - .read_dir(&fs.root()) - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .collect(); - assert_eq!(names, vec!["dir"]); -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires Docker; run with --features aws -- --ignored"] -async fn a_commit_costs_a_handful_of_objects() { - let (_c, data) = fresh_minio_with_bucket("data-cost").await; - let roots = extra_bucket(&data, "roots-cost", false).await; - let fs = mount(&data, &roots).await; - - let before = count_keys(&data, "slabs/").await; - let h = fs.open("/big", OpenFlags::create_new()).await.unwrap(); - // 64 records at the configured record size, committed as one group. - fs.pwrite(&h, 0, &vec![7u8; 64 * 64 * 1024]).await.unwrap(); - fs.close(&h).await.unwrap(); - let after = count_keys(&data, "slabs/").await; - - // The point of packing blocks into slabs: many dirty blocks, few PUTs. - // Addressing blocks by content hash would have cost 64 objects here. - assert!( - after - before <= 4, - "expected a handful of slab objects, got {}", - after - before - ); -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires Docker; run with --features aws -- --ignored"] -async fn tampering_with_a_slab_is_detected() { - let (_c, data) = fresh_minio_with_bucket("data-tamper").await; - let roots = extra_bucket(&data, "roots-tamper", false).await; - - { - let fs = mount(&data, &roots).await; - let h = fs.open("/f", OpenFlags::create_new()).await.unwrap(); - fs.pwrite(&h, 0, &vec![0x5au8; 100_000]).await.unwrap(); - fs.close(&h).await.unwrap(); - } - - // Rewrite every slab with a flipped byte, as a bucket operator could. - for key in list_keys(&data, "slabs/").await { - let body = data.get_blob(&key, None).await.unwrap().body; - let mut body = body.to_vec(); - body[0] ^= 0xff; - data.put_blob(PutBlobInput::new(key, Bytes::from(body))) - .await - .unwrap(); - } - - let fs = mount(&data, &roots).await; - let result = async { - let h = fs.open("/f", OpenFlags::read_only()).await?; - fs.pread(&h, 0, 100_000).await - } - .await; - assert!( - matches!(result, Err(FsError::Integrity(_))), - "expected an integrity failure, got {result:?}" - ); -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires Docker; run with --features aws -- --ignored"] -async fn a_mount_floor_above_the_tip_is_refused() { - let (_c, data) = fresh_minio_with_bucket("data-floor").await; - let roots = extra_bucket(&data, "roots-floor", false).await; - { - let fs = mount(&data, &roots).await; - fs.mkdir(&fs.root(), "a").await.unwrap(); - } - - let result = Fs::mount( - Arc::new(data.clone()) as Arc, - Arc::new(roots.clone()) as Arc, - &MasterSecret::from_bytes(TEST_MASTER), - TEST_FS_ID, - Arc::new(engine_config()), - Some(9999), - ) - .await; - assert!(matches!(result, Err(FsError::Rollback { .. }))); -} - -/// Two mounts at one tip, and a delete marker laid over the first one's claim -/// before the second makes its own. Real S3 accepts the second conditional PUT -/// — the marker is the current version — and without the read-back of the -/// retained version the second mount seals under the first one's transaction -/// group: the same nonces, and the same slab names, so its upload overwrites -/// what the first had committed. It still loses the *next* sequence, so its -/// refusal alone proves little. The first mount's data surviving is the proof. -/// -/// Versioning is what keeps that claim beneath the marker; retention, tested -/// above, is what stops anyone deleting the version itself. -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires Docker; run with --features aws -- --ignored"] -async fn a_delete_marker_does_not_let_a_second_mount_claim_a_sequence() { - let (_c, data) = fresh_minio_with_bucket("data-claim").await; - let roots = extra_bucket(&data, "roots-claim", true).await; - drop(mount(&data, &roots).await); - let a = mount(&data, &roots).await; - let b = mount(&data, &roots).await; - - a.mkdir(&a.root(), "from-a").await.unwrap(); - roots - .delete_blob(&engine_config().store.root_key(1)) - .await - .expect("a delete marker is a legal write"); - - assert!(matches!( - b.mkdir(&b.root(), "from-b").await, - Err(FsError::Conflict) - )); - // Reads back the blocks it committed, which the loser would have - // overwritten. - a.mkdir(&a.root(), "still-a").await.unwrap(); - - // The loser's claim sits above the marker, but it is not the one a mount - // reads: the winner's history is the filesystem. - let c = mount(&data, &roots).await; - let mut names: Vec<_> = c - .read_dir(&c.root()) - .await - .unwrap() - .into_iter() - .map(|e| e.name) - .collect(); - names.sort(); - assert_eq!(names, vec!["from-a", "still-a"]); -} - -async fn list_keys(backend: &AwsS3Backend, prefix: &str) -> Vec { - let mut out = Vec::new(); - let mut token: Option = None; - loop { - let page = backend - .list_blobs(ListBlobsInput { - prefix, - continuation_token: token.as_deref(), - ..Default::default() - }) - .await - .unwrap(); - out.extend(page.items.into_iter().map(|i| i.key)); - match page.next_continuation_token { - Some(t) if page.is_truncated => token = Some(t), - _ => break, - } - } - out -} - -async fn count_keys(backend: &AwsS3Backend, prefix: &str) -> usize { - list_keys(backend, prefix).await.len() -} diff --git a/runtime/tests/serve_auth.rs b/runtime/tests/serve_auth.rs index 76b72f2..073ceb1 100644 --- a/runtime/tests/serve_auth.rs +++ b/runtime/tests/serve_auth.rs @@ -18,8 +18,6 @@ use enclave_runtime::{ AuthEndpoints, ChallengeStore, FilesystemCredentials, Gate, GuestEnvironment, HostClock, PoolLimits, ServeConfig, SoftwareAuthenticator, Tenancy, TlsIdentity, }; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; const RP_ID: &str = "enclave.test"; @@ -45,8 +43,8 @@ struct SigningNsm { chain: nitro_attestation::testing::TestChain, pcrs: std::sync::Mutex>, /// Counted, so successive draws differ. A device that returned the same - /// bytes every time would mint one tenant id for every passkey, which is - /// the isolation property quietly inverted. + /// bytes every time would give every registration, challenge and token the + /// same id. draws: std::sync::atomic::AtomicU64, } @@ -140,16 +138,7 @@ async fn start() -> Harness { } async fn start_with_background(background: bool) -> Harness { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([9u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; let bytes = std::fs::read(component_path()).unwrap_or_else(|e| { panic!( @@ -187,7 +176,7 @@ async fn start_with_background(background: bool) -> Harness { // explicitly instead. let (logs, _collector) = enclave_runtime::guest_io::start(std::sync::Arc::new(enclave_runtime::TracingLogSink)); - let guest = GuestEnvironment::new(fs, Box::new(HostClock), entropy, &[], &[], logs) + let guest = GuestEnvironment::new(fs, Arc::new(HostClock), entropy, &[], &[], logs) .expect("guest environment"); let tls = TlsIdentity::self_signed(&[RP_ID.to_string()]).expect("tls identity"); diff --git a/runtime/tests/serve_grpc.rs b/runtime/tests/serve_grpc.rs index 0152cbb..76bd1e0 100644 --- a/runtime/tests/serve_grpc.rs +++ b/runtime/tests/serve_grpc.rs @@ -23,8 +23,6 @@ use std::time::Duration; use bytes::{BufMut, Bytes, BytesMut}; use enclave_runtime::{GuestEnvironment, HostClock, ServeHandle}; use http_body_util::BodyExt; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; use wasmtime_wasi_http::p2::bindings::http::types::Scheme; /// One gRPC frame on its way to the guest, or the error that ended the body. @@ -142,22 +140,13 @@ impl Deframer { // --- harness ---------------------------------------------------------------- async fn grpc_handle() -> ServeHandle { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([3u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; let (logs, _collector) = enclave_runtime::guest_io::start(Arc::new(enclave_runtime::TracingLogSink)); let guest = GuestEnvironment::new( fs, - Box::new(HostClock), + Arc::new(HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), &[], &[], diff --git a/runtime/tests/serve_grpc_wire.rs b/runtime/tests/serve_grpc_wire.rs index 4b73303..ec6bc3a 100644 --- a/runtime/tests/serve_grpc_wire.rs +++ b/runtime/tests/serve_grpc_wire.rs @@ -21,8 +21,6 @@ use std::sync::{Arc, Mutex}; use enclave_runtime::{GuestEnvironment, HostClock, ServeConfig, TlsIdentity}; use nitro_attestation::testing::TestChain; use nitro_nsm::{AttestationRequest, Nsm}; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; fn component_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -149,16 +147,7 @@ struct Harness { } async fn start() -> Harness { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([11u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; let nsm = Arc::new(SigningNsm::new()); let guest_bytes = std::fs::read(component_path()).unwrap_or_else(|e| { @@ -171,7 +160,7 @@ async fn start() -> Harness { let (logs, _collector) = enclave_runtime::guest_io::start(Arc::new(enclave_runtime::TracingLogSink)); - let guest = GuestEnvironment::new(fs, Box::new(HostClock), nsm.clone(), &[], &[], logs) + let guest = GuestEnvironment::new(fs, Arc::new(HostClock), nsm.clone(), &[], &[], logs) .expect("guest environment"); let identity = diff --git a/runtime/tests/serve_guest.rs b/runtime/tests/serve_guest.rs index f2f0bbe..b545a30 100644 --- a/runtime/tests/serve_guest.rs +++ b/runtime/tests/serve_guest.rs @@ -19,8 +19,6 @@ use std::sync::Arc; use bytes::Bytes; use enclave_runtime::{GuestEnvironment, HostClock, ServeHandle}; use http_body_util::{BodyExt, Full}; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; use wasmtime_wasi_http::p2::bindings::http::types::Scheme; use wasmtime_wasi_http::p2::body::HyperOutgoingBody; @@ -45,7 +43,7 @@ async fn handle_for(env: &[(String, String)]) -> ServeHandle { /// /// Separated so a test can build a *second* handle over the first one's /// storage — the only way to ask what survives a restart. -async fn handle_over_with(fs: Arc, env: &[(String, String)]) -> ServeHandle { +async fn handle_over_with(fs: Arc, env: &[(String, String)]) -> ServeHandle { // Detached deliberately: the collector runs for as long as this // environment can send, which is what a test wants. Production drains it // explicitly instead. @@ -53,7 +51,7 @@ async fn handle_over_with(fs: Arc, env: &[(String, String)]) -> ServeHandle enclave_runtime::guest_io::start(std::sync::Arc::new(enclave_runtime::TracingLogSink)); let guest = GuestEnvironment::new( fs, - Box::new(HostClock), + Arc::new(HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), env, &[], @@ -75,19 +73,10 @@ async fn handle_over_with(fs: Arc, env: &[(String, String)]) -> ServeHandle /// A runtime filesystem plus a handle. async fn handle_for_with(env: &[(String, String)]) -> ServeHandle { - let backend = Arc::new(MemoryBackend::new()); // `create`, not `mount`: mounting stopped formatting an empty store when // the boot machine landed, because a store that answers "nothing" is now a // refusal rather than an invitation to make a fresh filesystem. - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([7u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the memory-backed filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; handle_over_with(fs, env).await } @@ -350,7 +339,7 @@ async fn a_trap_dies_with_its_request() { /// A tenant id, as the gate would have resolved one. /// /// These tests exercise what happens *after* authentication — isolation, -/// warmth, concurrency — so they take the tenant as given. That the tenant can +/// fresh instances, concurrency — so they take the tenant as given. That the tenant can /// only come from a verified assertion is the gate's own business, and /// `auth::gate` tests it directly. fn identity(name: &str) -> [u8; 16] { @@ -437,11 +426,11 @@ async fn tenanted() -> ServeHandle { handle.with_tenancy(Arc::new(tenancy)) } -/// A client's own directory persists between their requests, and the warm -/// instance means their in-memory state does too. +/// A client's own directory persists between their requests; their +/// in-memory state does not, because every request gets a fresh instance. #[tokio::test(flavor = "multi_thread")] #[ignore = "needs examples/guest-http built for wasm32-wasip2"] -async fn a_client_keeps_their_own_directory_and_instance() { +async fn a_client_keeps_their_directory_but_never_an_instance() { let handle = tenanted().await; let client = identity("a-client"); @@ -450,14 +439,11 @@ async fn a_client_keeps_their_own_directory_and_instance() { assert_eq!(status, 200, "body: {body}"); assert_eq!(body.trim(), expected.to_string(), "the directory reset"); } - // And the instance was kept, which a fresh one could never show. - for expected in 1..=3 { + // And no instance was kept: a second request seeing the first one's + // memory would be a call inheriting state it never approved. + for _ in 1..=3 { let (_, body) = get_as(&handle, "/memory", Some(&client)).await; - assert_eq!( - body.trim(), - expected.to_string(), - "the instance was rebuilt" - ); + assert_eq!(body.trim(), "1", "a request reused an instance"); } } @@ -640,8 +626,8 @@ async fn an_anonymous_caller_gets_no_tenant() { } } -/// A trap takes down one client's instance and touches nobody else — the whole -/// argument for the per-client boundary, as a test. +/// A trap takes down one call and touches nobody else — the whole argument +/// for the per-client boundary, as a test. #[tokio::test(flavor = "multi_thread")] #[ignore = "needs examples/guest-http built for wasm32-wasip2"] async fn a_trap_is_contained_to_one_client() { @@ -652,12 +638,7 @@ async fn a_trap_is_contained_to_one_client() { let alice = identity("alice"); let bob = identity("bob"); - // Bob builds up instance state. - for _ in 0..3 { - get_as(&handle, "/memory", Some(&bob)).await; - } - - // Alice wedges hers. + // Alice wedges her call. let req = hyper::Request::builder() .method("GET") .uri("http://enclave.test/hang") @@ -670,17 +651,14 @@ async fn a_trap_is_contained_to_one_client() { .await .expect("the watchdog did not fire"); - // Alice's instance was rebuilt; Bob's was never touched. + // Alice is served again from a fresh instance, and so is Bob. let (status, alices) = get_as(&handle, "/memory", Some(&alice)).await; assert_eq!(status, 200, "alice's tenant did not recover: {alices}"); assert_eq!(alices.trim(), "1", "a trapped instance was reused"); - let (_, bobs) = get_as(&handle, "/memory", Some(&bob)).await; - assert_eq!( - bobs.trim(), - "4", - "another client's trap reset bob's instance" - ); + let (status, bobs) = get_as(&handle, "/memory", Some(&bob)).await; + assert_eq!(status, 200, "another client's trap stopped bob: {bobs}"); + assert_eq!(bobs.trim(), "1", "bob's request reused an instance"); } /// Their data survives too: a trap discards the poisoned instance and nothing @@ -737,8 +715,8 @@ async fn one_client_does_not_wait_for_another() { let alice = identity("alice"); let bob = identity("bob"); - // Warm Bob first, so the assertion below measures contention rather than - // the cost of a cold mount. + // Bob once first, so his dataset exists and the assertion below measures + // contention rather than its creation. assert_eq!(get_as(&handle, "/counter", Some(&bob)).await.0, 200); let hung = { diff --git a/runtime/tests/serve_h2.rs b/runtime/tests/serve_h2.rs index 5803aca..596d563 100644 --- a/runtime/tests/serve_h2.rs +++ b/runtime/tests/serve_h2.rs @@ -16,8 +16,6 @@ use std::sync::Arc; use enclave_runtime::{GuestEnvironment, HostClock, ServeConfig, TlsIdentity}; use http_body_util::{BodyExt, Full}; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn component_path() -> PathBuf { @@ -78,16 +76,7 @@ impl rustls::client::danger::ServerCertVerifier for AcceptAny { /// and a document on every response would only add a signature to each /// assertion below without changing what any of them prove. async fn start() -> std::net::SocketAddr { - let backend = Arc::new(MemoryBackend::new()); - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([5u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; let bytes = std::fs::read(component_path()).unwrap_or_else(|e| { panic!( @@ -101,7 +90,7 @@ async fn start() -> std::net::SocketAddr { enclave_runtime::guest_io::start(Arc::new(enclave_runtime::TracingLogSink)); let guest = GuestEnvironment::new( fs, - Box::new(HostClock), + Arc::new(HostClock), Arc::new(nitro_nsm::fake::FakeNsm::new()), &[], &[], diff --git a/runtime/tests/serve_tls.rs b/runtime/tests/serve_tls.rs index d755948..235732d 100644 --- a/runtime/tests/serve_tls.rs +++ b/runtime/tests/serve_tls.rs @@ -28,8 +28,6 @@ use enclave_runtime::{GuestEnvironment, HostClock, ServeConfig, TlsIdentity}; use nitro_attestation::testing::TestChain; use nitro_attestation::{AttestationHashes, Expectations, VerifyOptions}; use nitro_nsm::{AttestationRequest, Nsm}; -use s3fs_core::backend::memory::MemoryBackend; -use s3fs_core::{Config, Fs, MasterSecret}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn component_path() -> PathBuf { @@ -118,18 +116,9 @@ async fn start() -> Harness { } async fn start_inner(slot: Option) -> Harness { - let backend = Arc::new(MemoryBackend::new()); // `create`, not `mount`: an empty store is a refusal since the boot // machine landed, not an invitation to format one. - let fs = Fs::create( - backend.clone(), - backend, - &MasterSecret::from_bytes([9u8; 32]), - [0u8; 16], - Arc::new(Config::default()), - ) - .await - .expect("creating the filesystem"); + let fs = enclave_runtime::Zfs::scratch().await; let nsm = Arc::new(SigningNsm::new()); let guest_bytes = std::fs::read(component_path()).unwrap_or_else(|e| { @@ -145,7 +134,7 @@ async fn start_inner(slot: Option) -> Harness // explicitly instead. let (logs, _collector) = enclave_runtime::guest_io::start(std::sync::Arc::new(enclave_runtime::TracingLogSink)); - let guest = GuestEnvironment::new(fs, Box::new(HostClock), nsm.clone(), &[], &[], logs) + let guest = GuestEnvironment::new(fs, Arc::new(HostClock), nsm.clone(), &[], &[], logs) .expect("guest environment"); let identity = Arc::new(TlsIdentity::self_signed(&["enclave.test".to_string()]).expect("tls identity")); diff --git a/scripts/ci-bench.sh b/scripts/ci-bench.sh index e79e6c4..c6bcea4 100755 --- a/scripts/ci-bench.sh +++ b/scripts/ci-bench.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash -# The two criterion benches, in the form the tracker can read. +# The criterion bench, in the form the tracker can read. # -# `instance_cost` measures what a warm instance saves over a fresh one, and it -# runs the real component — so the guest has to be built before cargo is asked +# `instance_cost` measures what the fresh instance every request gets costs, +# and it runs the real component — so the guest has to be built before cargo is asked # for a number, or the bench panics with a build hint instead of producing one. -# `fs_hot_paths` runs over the in-memory backend and needs nothing. # # `--output-format bencher` is criterion's libtest-compatible output, which is # what github-action-benchmark reads as `tool: cargo`. Criterion's own format is @@ -25,14 +24,12 @@ say "benchmarks" # after `--` is handed to *every* target cargo runs, and in bench profile that # includes each crate's libtest harness — which does not know `--output-format`, # rejects it, and fails the whole run before a single benchmark executes. Naming -# the two `harness = false` criterion targets keeps the flag with the only -# harnesses that understand it. +# the `harness = false` criterion target keeps the flag with the only harness +# that understands it. # # lib.sh sets `pipefail`, so a failing cargo still fails the script despite tee. : > "$REPO/bench.txt" cargo bench -p enclave-runtime --bench instance_cost -- --output-format bencher \ | tee -a "$REPO/bench.txt" -cargo bench -p s3fs-core --bench fs_hot_paths -- --output-format bencher \ - | tee -a "$REPO/bench.txt" echo "wrote $REPO/bench.txt" diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 8289d29..93d6f76 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -17,6 +17,8 @@ cargo clippy --workspace --all-targets -- -D warnings say "unit tests" cargo test --workspace --lib +# The runtime's settings are parsed in the binary, whose tests `--lib` skips. +cargo test -p enclave-runtime --bin enclave-runtime # Integration suites run only where they are named, and this one ran nowhere # before. It is the boot machine — genesis, resume, upgrades and the refusals — diff --git a/scripts/ci-sqlite.sh b/scripts/ci-sqlite.sh deleted file mode 100755 index 6c46767..0000000 --- a/scripts/ci-sqlite.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# A real C database driving the filesystem, and the benchmark table it prints. -# -# Page-granular random I/O, a rollback journal created and unlinked per -# transaction, VACUUM rewriting the whole file, and PRAGMA integrity_check to -# say whether the bytes came back correct. Nothing else in CI exercises the -# block store this way. -# -# The runtime serves `wasi:http/proxy` and nothing else, so the workload is -# asked for with a request rather than run as a process. TLS off and no relying -# party configured: this is about SQLite over the block store, not about the -# gate, which ci-guests.sh covers. -# -# **Not wired into CI, and it cannot be.** Since `bbbee4d` the runtime measures -# its guest into PCR16 before serving, and that reads the NSM: -# -# runtime failed to start the guest -# error="reading PCR16 before measuring the guest: no PCRs: entropy is -# coming from the kernel, not an NSM" -# -# `measure_guest` is called unconditionally and has no flag to skip it — which -# is the point of it. A hosted runner has no /dev/nsm, so this script only runs -# where an enclave does. Run it by hand there; do not add it back to the -# workflow expecting it to pass. - -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -cd "$REPO" - -MINIO_CONTAINER=ci-minio-sql -MINIO_PORT=9002 - -"$REPO/scripts/wasi-sdk.sh" -"$REPO/scripts/build-guest.sh" sqlite - -say "building enclave-runtime" -cargo build --release -p enclave-runtime - -"$REPO/scripts/minio-up.sh" "$MINIO_CONTAINER" "$MINIO_PORT" sql-data sql-roots -trap '"$REPO/scripts/minio-down.sh" "$MINIO_CONTAINER"' EXIT - -say "running the SQLite workload" -export SQLITE_SCALE="${SQLITE_SCALE:-20000}" -export S3FS_MASTER_KEY="00000000000000000000000000000000000000000000000000000000000000ef" -# Required, with no default: the runtime will not guess where a master key comes -# from. `static` is the development source that reads the key above — the same -# one the QEMU emulator image uses, and for the same reason, since KMS will not -# release a key against an unsigned attestation document. -export S3FS_MASTER_KEY_SOURCE=static -export S3FS_BUCKET=sql-data -export S3FS_ROOTS_BUCKET=sql-roots -export S3FS_ENDPOINT="http://127.0.0.1:$MINIO_PORT" -export S3FS_FORCE_PATH_STYLE=1 -export AWS_ACCESS_KEY_ID=minioadmin -export AWS_SECRET_ACCESS_KEY=minioadmin -export AWS_REGION=us-east-1 -export S3FS_GUEST_PATH=examples/guest-sqlite/target/wasm32-wasip2/release/guest-sqlite.wasm -export S3FS_TLS=off -export S3FS_HTTP_LISTEN=127.0.0.1:8080 - -./target/release/enclave-runtime > runtime.log 2>&1 & -runtime=$! -trap 'kill "$runtime" 2>/dev/null || true; "$REPO/scripts/minio-down.sh" "$MINIO_CONTAINER"' EXIT - -for _ in $(seq 1 60); do - curl -sf -o /dev/null http://127.0.0.1:8080/ && break - kill -0 "$runtime" 2>/dev/null || { echo "the runtime exited:"; cat runtime.log; exit 1; } - sleep 1 -done - -# No --max-time: the workload is a long request by design, and the runtime's own -# --request-timeout is what bounds it. A wedged listener is bounded by the CI -# job's timeout-minutes instead, which covers the cases a client timeout cannot. -out=$(curl -sf http://127.0.0.1:8080/) -echo "$out" -echo "$out" | grep -qx OK - -# The timing table went to the guest's stdout, which the runtime frames into its -# own log records. -echo "--- runtime log ---" -cat runtime.log diff --git a/scripts/ci-storage.sh b/scripts/ci-storage.sh index 6344cbb..ad426e4 100755 --- a/scripts/ci-storage.sh +++ b/scripts/ci-storage.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# The block store against a real S3 implementation. +# The S3 backend against a real S3 implementation: Object Lock, conditional +# writes and retained-version reads, which the anchor chain and the boot +# records are built on. # # These tests start their own MinIO through testcontainers — one per test, which # is why they run single-threaded and take a while. That is also why this script @@ -11,4 +13,4 @@ cd "$REPO" "$REPO/scripts/minio-image.sh" >/dev/null say "MinIO integration" -cargo test -p s3fs-core --features aws --test minio_integration -- --ignored --test-threads=1 +cargo test -p enclave-runtime --test minio_integration -- --ignored --test-threads=1 diff --git a/scripts/lib.sh b/scripts/lib.sh index 3947b74..71a3010 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -36,7 +36,7 @@ fail() { # when it does fail the log says which part was missing. prepare_enclave_host() { local work="${WORK:-$REPO/target/qemu-nitro}" - local image="${QEMU_IMAGE:-s3fs-qemu-nitro:latest}" + local image="${QEMU_IMAGE:-enclave-qemu-nitro:latest}" # Pinned, like the Pebble image beside it. An unpinned build tool makes the # harness's behaviour depend on whatever crates.io served that morning. local vsock_version="${VSOCK_VERSION:-0.3.0}" diff --git a/scripts/minio-up.sh b/scripts/minio-up.sh index 5b15f26..0fea988 100755 --- a/scripts/minio-up.sh +++ b/scripts/minio-up.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Start MinIO and create the two buckets the runtime expects. +# Start MinIO and create the bucket the runtime expects. # -# minio-up.sh [data-dir] +# minio-up.sh [data-dir] # # Without data-dir the store lives inside the container and goes with it, which # is what tests want. With one it lives in that host directory and outlives the @@ -12,16 +12,15 @@ # how the harness and CI end up testing subtly different stores. # # The roots bucket is created `--with-lock` deliberately: object lock is what -# makes a published root record impossible to roll back, so a store without it +# makes a published anchor impossible to roll back, so a store without it # would pass tests that a real deployment could not. set -euo pipefail container="${1:?container name}" port="${2:?host port}" -data="${3:?data bucket}" -roots="${4:?roots bucket}" -data_dir="${5:-}" +roots="${3:?roots bucket}" +data_dir="${4:-}" # Built from source the first time; minio-image.sh says why. image="$("$(dirname "${BASH_SOURCE[0]}")/minio-image.sh")" @@ -68,8 +67,7 @@ fi # restarts a store between runs, and "already there" is success. docker run --rm --network host "${label[@]}" --entrypoint sh "$image" -c " mc alias set m http://127.0.0.1:$port minioadmin minioadmin >/dev/null - mc mb m/$data >/dev/null 2>&1 || true mc mb --with-lock m/$roots >/dev/null 2>&1 || true" >/dev/null \ - || { echo "could not create $data and $roots" >&2; exit 1; } + || { echo "could not create $roots" >&2; exit 1; } -echo "MinIO ready on :$port with $data and $roots" +echo "MinIO ready on :$port with $roots" diff --git a/scripts/wasi-sdk.sh b/scripts/wasi-sdk.sh index aad1933..172399a 100755 --- a/scripts/wasi-sdk.sh +++ b/scripts/wasi-sdk.sh @@ -2,8 +2,8 @@ # Put wasi-sdk where the SQLite guest's C toolchain can find it. # # Idempotent: with the SDK already unpacked this exits immediately, which is -# what makes the CI cache worth having and what lets a developer run -# ci-sqlite.sh repeatedly without re-downloading 110 MB. +# what makes the CI cache worth having and what lets a developer rebuild the +# SQLite guest repeatedly without re-downloading 110 MB. # # The download used to be `curl -sSL` with no `-f`. Without it a non-2xx # response is written to the tarball as HTML and the failure surfaces two steps diff --git a/wit/deps/cli.wit b/wit/deps/cli.wit deleted file mode 100644 index d7a3ca4..0000000 --- a/wit/deps/cli.wit +++ /dev/null @@ -1,261 +0,0 @@ -package wasi:cli@0.2.6; - -@since(version = 0.2.0) -interface environment { - /// Get the POSIX-style environment variables. - /// - /// Each environment variable is provided as a pair of string variable names - /// and string value. - /// - /// Morally, these are a value import, but until value imports are available - /// in the component model, this import function should return the same - /// values each time it is called. - @since(version = 0.2.0) - get-environment: func() -> list>; - - /// Get the POSIX-style arguments to the program. - @since(version = 0.2.0) - get-arguments: func() -> list; - - /// Return a path that programs should use as their initial current working - /// directory, interpreting `.` as shorthand for this. - @since(version = 0.2.0) - initial-cwd: func() -> option; -} - -@since(version = 0.2.0) -interface exit { - /// Exit the current instance and any linked instances. - @since(version = 0.2.0) - exit: func(status: result); - - /// Exit the current instance and any linked instances, reporting the - /// specified status code to the host. - /// - /// The meaning of the code depends on the context, with 0 usually meaning - /// "success", and other values indicating various types of failure. - /// - /// This function does not return; the effect is analogous to a trap, but - /// without the connotation that something bad has happened. - @unstable(feature = cli-exit-with-code) - exit-with-code: func(status-code: u8); -} - -@since(version = 0.2.0) -interface run { - /// Run the program. - @since(version = 0.2.0) - run: func() -> result; -} - -@since(version = 0.2.0) -interface stdin { - @since(version = 0.2.0) - use wasi:io/streams@0.2.6.{input-stream}; - - @since(version = 0.2.0) - get-stdin: func() -> input-stream; -} - -@since(version = 0.2.0) -interface stdout { - @since(version = 0.2.0) - use wasi:io/streams@0.2.6.{output-stream}; - - @since(version = 0.2.0) - get-stdout: func() -> output-stream; -} - -@since(version = 0.2.0) -interface stderr { - @since(version = 0.2.0) - use wasi:io/streams@0.2.6.{output-stream}; - - @since(version = 0.2.0) - get-stderr: func() -> output-stream; -} - -/// Terminal input. -/// -/// In the future, this may include functions for disabling echoing, -/// disabling input buffering so that keyboard events are sent through -/// immediately, querying supported features, and so on. -@since(version = 0.2.0) -interface terminal-input { - /// The input side of a terminal. - @since(version = 0.2.0) - resource terminal-input; -} - -/// Terminal output. -/// -/// In the future, this may include functions for querying the terminal -/// size, being notified of terminal size changes, querying supported -/// features, and so on. -@since(version = 0.2.0) -interface terminal-output { - /// The output side of a terminal. - @since(version = 0.2.0) - resource terminal-output; -} - -/// An interface providing an optional `terminal-input` for stdin as a -/// link-time authority. -@since(version = 0.2.0) -interface terminal-stdin { - @since(version = 0.2.0) - use terminal-input.{terminal-input}; - - /// If stdin is connected to a terminal, return a `terminal-input` handle - /// allowing further interaction with it. - @since(version = 0.2.0) - get-terminal-stdin: func() -> option; -} - -/// An interface providing an optional `terminal-output` for stdout as a -/// link-time authority. -@since(version = 0.2.0) -interface terminal-stdout { - @since(version = 0.2.0) - use terminal-output.{terminal-output}; - - /// If stdout is connected to a terminal, return a `terminal-output` handle - /// allowing further interaction with it. - @since(version = 0.2.0) - get-terminal-stdout: func() -> option; -} - -/// An interface providing an optional `terminal-output` for stderr as a -/// link-time authority. -@since(version = 0.2.0) -interface terminal-stderr { - @since(version = 0.2.0) - use terminal-output.{terminal-output}; - - /// If stderr is connected to a terminal, return a `terminal-output` handle - /// allowing further interaction with it. - @since(version = 0.2.0) - get-terminal-stderr: func() -> option; -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import environment; - @since(version = 0.2.0) - import exit; - @since(version = 0.2.0) - import wasi:io/error@0.2.6; - @since(version = 0.2.0) - import wasi:io/poll@0.2.6; - @since(version = 0.2.0) - import wasi:io/streams@0.2.6; - @since(version = 0.2.0) - import stdin; - @since(version = 0.2.0) - import stdout; - @since(version = 0.2.0) - import stderr; - @since(version = 0.2.0) - import terminal-input; - @since(version = 0.2.0) - import terminal-output; - @since(version = 0.2.0) - import terminal-stdin; - @since(version = 0.2.0) - import terminal-stdout; - @since(version = 0.2.0) - import terminal-stderr; - @since(version = 0.2.0) - import wasi:clocks/monotonic-clock@0.2.6; - @since(version = 0.2.0) - import wasi:clocks/wall-clock@0.2.6; - @unstable(feature = clocks-timezone) - import wasi:clocks/timezone@0.2.6; - @since(version = 0.2.0) - import wasi:filesystem/types@0.2.6; - @since(version = 0.2.0) - import wasi:filesystem/preopens@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/network@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/instance-network@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/udp@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/udp-create-socket@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/tcp@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/tcp-create-socket@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/ip-name-lookup@0.2.6; - @since(version = 0.2.0) - import wasi:random/random@0.2.6; - @since(version = 0.2.0) - import wasi:random/insecure@0.2.6; - @since(version = 0.2.0) - import wasi:random/insecure-seed@0.2.6; -} -@since(version = 0.2.0) -world command { - @since(version = 0.2.0) - import environment; - @since(version = 0.2.0) - import exit; - @since(version = 0.2.0) - import wasi:io/error@0.2.6; - @since(version = 0.2.0) - import wasi:io/poll@0.2.6; - @since(version = 0.2.0) - import wasi:io/streams@0.2.6; - @since(version = 0.2.0) - import stdin; - @since(version = 0.2.0) - import stdout; - @since(version = 0.2.0) - import stderr; - @since(version = 0.2.0) - import terminal-input; - @since(version = 0.2.0) - import terminal-output; - @since(version = 0.2.0) - import terminal-stdin; - @since(version = 0.2.0) - import terminal-stdout; - @since(version = 0.2.0) - import terminal-stderr; - @since(version = 0.2.0) - import wasi:clocks/monotonic-clock@0.2.6; - @since(version = 0.2.0) - import wasi:clocks/wall-clock@0.2.6; - @unstable(feature = clocks-timezone) - import wasi:clocks/timezone@0.2.6; - @since(version = 0.2.0) - import wasi:filesystem/types@0.2.6; - @since(version = 0.2.0) - import wasi:filesystem/preopens@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/network@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/instance-network@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/udp@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/udp-create-socket@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/tcp@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/tcp-create-socket@0.2.6; - @since(version = 0.2.0) - import wasi:sockets/ip-name-lookup@0.2.6; - @since(version = 0.2.0) - import wasi:random/random@0.2.6; - @since(version = 0.2.0) - import wasi:random/insecure@0.2.6; - @since(version = 0.2.0) - import wasi:random/insecure-seed@0.2.6; - - @since(version = 0.2.0) - export run; -} diff --git a/wit/deps/clocks.wit b/wit/deps/clocks.wit deleted file mode 100644 index d638f1a..0000000 --- a/wit/deps/clocks.wit +++ /dev/null @@ -1,157 +0,0 @@ -package wasi:clocks@0.2.6; - -/// WASI Monotonic Clock is a clock API intended to let users measure elapsed -/// time. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -/// -/// A monotonic clock is a clock which has an unspecified initial value, and -/// successive reads of the clock will produce non-decreasing values. -@since(version = 0.2.0) -interface monotonic-clock { - @since(version = 0.2.0) - use wasi:io/poll@0.2.6.{pollable}; - - /// An instant in time, in nanoseconds. An instant is relative to an - /// unspecified initial value, and can only be compared to instances from - /// the same monotonic-clock. - @since(version = 0.2.0) - type instant = u64; - - /// A duration of time, in nanoseconds. - @since(version = 0.2.0) - type duration = u64; - - /// Read the current value of the clock. - /// - /// The clock is monotonic, therefore calling this function repeatedly will - /// produce a sequence of non-decreasing values. - @since(version = 0.2.0) - now: func() -> instant; - - /// Query the resolution of the clock. Returns the duration of time - /// corresponding to a clock tick. - @since(version = 0.2.0) - resolution: func() -> duration; - - /// Create a `pollable` which will resolve once the specified instant - /// has occurred. - @since(version = 0.2.0) - subscribe-instant: func(when: instant) -> pollable; - - /// Create a `pollable` that will resolve after the specified duration has - /// elapsed from the time this function is invoked. - @since(version = 0.2.0) - subscribe-duration: func(when: duration) -> pollable; -} - -/// WASI Wall Clock is a clock API intended to let users query the current -/// time. The name "wall" makes an analogy to a "clock on the wall", which -/// is not necessarily monotonic as it may be reset. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -/// -/// A wall clock is a clock which measures the date and time according to -/// some external reference. -/// -/// External references may be reset, so this clock is not necessarily -/// monotonic, making it unsuitable for measuring elapsed time. -/// -/// It is intended for reporting the current date and time for humans. -@since(version = 0.2.0) -interface wall-clock { - /// A time and date in seconds plus nanoseconds. - @since(version = 0.2.0) - record datetime { - seconds: u64, - nanoseconds: u32, - } - - /// Read the current value of the clock. - /// - /// This clock is not monotonic, therefore calling this function repeatedly - /// will not necessarily produce a sequence of non-decreasing values. - /// - /// The returned timestamps represent the number of seconds since - /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], - /// also known as [Unix Time]. - /// - /// The nanoseconds field of the output is always less than 1000000000. - /// - /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 - /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time - @since(version = 0.2.0) - now: func() -> datetime; - - /// Query the resolution of the clock. - /// - /// The nanoseconds field of the output is always less than 1000000000. - @since(version = 0.2.0) - resolution: func() -> datetime; -} - -@unstable(feature = clocks-timezone) -interface timezone { - @unstable(feature = clocks-timezone) - use wall-clock.{datetime}; - - /// Information useful for displaying the timezone of a specific `datetime`. - /// - /// This information may vary within a single `timezone` to reflect daylight - /// saving time adjustments. - @unstable(feature = clocks-timezone) - record timezone-display { - /// The number of seconds difference between UTC time and the local - /// time of the timezone. - /// - /// The returned value will always be less than 86400 which is the - /// number of seconds in a day (24*60*60). - /// - /// In implementations that do not expose an actual time zone, this - /// should return 0. - utc-offset: s32, - /// The abbreviated name of the timezone to display to a user. The name - /// `UTC` indicates Coordinated Universal Time. Otherwise, this should - /// reference local standards for the name of the time zone. - /// - /// In implementations that do not expose an actual time zone, this - /// should be the string `UTC`. - /// - /// In time zones that do not have an applicable name, a formatted - /// representation of the UTC offset may be returned, such as `-04:00`. - name: string, - /// Whether daylight saving time is active. - /// - /// In implementations that do not expose an actual time zone, this - /// should return false. - in-daylight-saving-time: bool, - } - - /// Return information needed to display the given `datetime`. This includes - /// the UTC offset, the time zone name, and a flag indicating whether - /// daylight saving time is active. - /// - /// If the timezone cannot be determined for the given `datetime`, return a - /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight - /// saving time. - @unstable(feature = clocks-timezone) - display: func(when: datetime) -> timezone-display; - - /// The same as `display`, but only return the UTC offset. - @unstable(feature = clocks-timezone) - utc-offset: func(when: datetime) -> s32; -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import wasi:io/poll@0.2.6; - @since(version = 0.2.0) - import monotonic-clock; - @since(version = 0.2.0) - import wall-clock; - @unstable(feature = clocks-timezone) - import timezone; -} diff --git a/wit/deps/filesystem.wit b/wit/deps/filesystem.wit deleted file mode 100644 index 9f4a828..0000000 --- a/wit/deps/filesystem.wit +++ /dev/null @@ -1,587 +0,0 @@ -package wasi:filesystem@0.2.6; - -/// WASI filesystem is a filesystem API primarily intended to let users run WASI -/// programs that access their files on their existing filesystems, without -/// significant overhead. -/// -/// It is intended to be roughly portable between Unix-family platforms and -/// Windows, though it does not hide many of the major differences. -/// -/// Paths are passed as interface-type `string`s, meaning they must consist of -/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain -/// paths which are not accessible by this API. -/// -/// The directory separator in WASI is always the forward-slash (`/`). -/// -/// All paths in WASI are relative paths, and are interpreted relative to a -/// `descriptor` referring to a base directory. If a `path` argument to any WASI -/// function starts with `/`, or if any step of resolving a `path`, including -/// `..` and symbolic link steps, reaches a directory outside of the base -/// directory, or reaches a symlink to an absolute or rooted path in the -/// underlying filesystem, the function fails with `error-code::not-permitted`. -/// -/// For more information about WASI path resolution and sandboxing, see -/// [WASI filesystem path resolution]. -/// -/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md -@since(version = 0.2.0) -interface types { - @since(version = 0.2.0) - use wasi:io/streams@0.2.6.{input-stream, output-stream, error}; - @since(version = 0.2.0) - use wasi:clocks/wall-clock@0.2.6.{datetime}; - - /// File size or length of a region within a file. - @since(version = 0.2.0) - type filesize = u64; - - /// The type of a filesystem object referenced by a descriptor. - /// - /// Note: This was called `filetype` in earlier versions of WASI. - @since(version = 0.2.0) - enum descriptor-type { - /// The type of the descriptor or file is unknown or is different from - /// any of the other types specified. - unknown, - /// The descriptor refers to a block device inode. - block-device, - /// The descriptor refers to a character device inode. - character-device, - /// The descriptor refers to a directory inode. - directory, - /// The descriptor refers to a named pipe. - fifo, - /// The file refers to a symbolic link inode. - symbolic-link, - /// The descriptor refers to a regular file inode. - regular-file, - /// The descriptor refers to a socket. - socket, - } - - /// Descriptor flags. - /// - /// Note: This was called `fdflags` in earlier versions of WASI. - @since(version = 0.2.0) - flags descriptor-flags { - /// Read mode: Data can be read. - read, - /// Write mode: Data can be written to. - write, - /// Request that writes be performed according to synchronized I/O file - /// integrity completion. The data stored in the file and the file's - /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. - /// - /// The precise semantics of this operation have not yet been defined for - /// WASI. At this time, it should be interpreted as a request, and not a - /// requirement. - file-integrity-sync, - /// Request that writes be performed according to synchronized I/O data - /// integrity completion. Only the data stored in the file is - /// synchronized. This is similar to `O_DSYNC` in POSIX. - /// - /// The precise semantics of this operation have not yet been defined for - /// WASI. At this time, it should be interpreted as a request, and not a - /// requirement. - data-integrity-sync, - /// Requests that reads be performed at the same level of integrity - /// requested for writes. This is similar to `O_RSYNC` in POSIX. - /// - /// The precise semantics of this operation have not yet been defined for - /// WASI. At this time, it should be interpreted as a request, and not a - /// requirement. - requested-write-sync, - /// Mutating directories mode: Directory contents may be mutated. - /// - /// When this flag is unset on a descriptor, operations using the - /// descriptor which would create, rename, delete, modify the data or - /// metadata of filesystem objects, or obtain another handle which - /// would permit any of those, shall fail with `error-code::read-only` if - /// they would otherwise succeed. - /// - /// This may only be set on directories. - mutate-directory, - } - - /// Flags determining the method of how paths are resolved. - @since(version = 0.2.0) - flags path-flags { - /// As long as the resolved path corresponds to a symbolic link, it is - /// expanded. - symlink-follow, - } - - /// Open flags used by `open-at`. - @since(version = 0.2.0) - flags open-flags { - /// Create file if it does not exist, similar to `O_CREAT` in POSIX. - create, - /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. - directory, - /// Fail if file already exists, similar to `O_EXCL` in POSIX. - exclusive, - /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. - truncate, - } - - /// Number of hard links to an inode. - @since(version = 0.2.0) - type link-count = u64; - - /// File attributes. - /// - /// Note: This was called `filestat` in earlier versions of WASI. - @since(version = 0.2.0) - record descriptor-stat { - /// File type. - %type: descriptor-type, - /// Number of hard links to the file. - link-count: link-count, - /// For regular files, the file size in bytes. For symbolic links, the - /// length in bytes of the pathname contained in the symbolic link. - size: filesize, - /// Last data access timestamp. - /// - /// If the `option` is none, the platform doesn't maintain an access - /// timestamp for this file. - data-access-timestamp: option, - /// Last data modification timestamp. - /// - /// If the `option` is none, the platform doesn't maintain a - /// modification timestamp for this file. - data-modification-timestamp: option, - /// Last file status-change timestamp. - /// - /// If the `option` is none, the platform doesn't maintain a - /// status-change timestamp for this file. - status-change-timestamp: option, - } - - /// When setting a timestamp, this gives the value to set it to. - @since(version = 0.2.0) - variant new-timestamp { - /// Leave the timestamp set to its previous value. - no-change, - /// Set the timestamp to the current time of the system clock associated - /// with the filesystem. - now, - /// Set the timestamp to the given value. - timestamp(datetime), - } - - /// A directory entry. - record directory-entry { - /// The type of the file referred to by this directory entry. - %type: descriptor-type, - /// The name of the object. - name: string, - } - - /// Error codes returned by functions, similar to `errno` in POSIX. - /// Not all of these error codes are returned by the functions provided by this - /// API; some are used in higher-level library layers, and others are provided - /// merely for alignment with POSIX. - enum error-code { - /// Permission denied, similar to `EACCES` in POSIX. - access, - /// Resource unavailable, or operation would block, similar to `EAGAIN` and `EWOULDBLOCK` in POSIX. - would-block, - /// Connection already in progress, similar to `EALREADY` in POSIX. - already, - /// Bad descriptor, similar to `EBADF` in POSIX. - bad-descriptor, - /// Device or resource busy, similar to `EBUSY` in POSIX. - busy, - /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. - deadlock, - /// Storage quota exceeded, similar to `EDQUOT` in POSIX. - quota, - /// File exists, similar to `EEXIST` in POSIX. - exist, - /// File too large, similar to `EFBIG` in POSIX. - file-too-large, - /// Illegal byte sequence, similar to `EILSEQ` in POSIX. - illegal-byte-sequence, - /// Operation in progress, similar to `EINPROGRESS` in POSIX. - in-progress, - /// Interrupted function, similar to `EINTR` in POSIX. - interrupted, - /// Invalid argument, similar to `EINVAL` in POSIX. - invalid, - /// I/O error, similar to `EIO` in POSIX. - io, - /// Is a directory, similar to `EISDIR` in POSIX. - is-directory, - /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. - loop, - /// Too many links, similar to `EMLINK` in POSIX. - too-many-links, - /// Message too large, similar to `EMSGSIZE` in POSIX. - message-size, - /// Filename too long, similar to `ENAMETOOLONG` in POSIX. - name-too-long, - /// No such device, similar to `ENODEV` in POSIX. - no-device, - /// No such file or directory, similar to `ENOENT` in POSIX. - no-entry, - /// No locks available, similar to `ENOLCK` in POSIX. - no-lock, - /// Not enough space, similar to `ENOMEM` in POSIX. - insufficient-memory, - /// No space left on device, similar to `ENOSPC` in POSIX. - insufficient-space, - /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. - not-directory, - /// Directory not empty, similar to `ENOTEMPTY` in POSIX. - not-empty, - /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. - not-recoverable, - /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. - unsupported, - /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. - no-tty, - /// No such device or address, similar to `ENXIO` in POSIX. - no-such-device, - /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. - overflow, - /// Operation not permitted, similar to `EPERM` in POSIX. - not-permitted, - /// Broken pipe, similar to `EPIPE` in POSIX. - pipe, - /// Read-only file system, similar to `EROFS` in POSIX. - read-only, - /// Invalid seek, similar to `ESPIPE` in POSIX. - invalid-seek, - /// Text file busy, similar to `ETXTBSY` in POSIX. - text-file-busy, - /// Cross-device link, similar to `EXDEV` in POSIX. - cross-device, - } - - /// File or memory access pattern advisory information. - @since(version = 0.2.0) - enum advice { - /// The application has no advice to give on its behavior with respect - /// to the specified data. - normal, - /// The application expects to access the specified data sequentially - /// from lower offsets to higher offsets. - sequential, - /// The application expects to access the specified data in a random - /// order. - random, - /// The application expects to access the specified data in the near - /// future. - will-need, - /// The application expects that it will not access the specified data - /// in the near future. - dont-need, - /// The application expects to access the specified data once and then - /// not reuse it thereafter. - no-reuse, - } - - /// A 128-bit hash value, split into parts because wasm doesn't have a - /// 128-bit integer type. - @since(version = 0.2.0) - record metadata-hash-value { - /// 64 bits of a 128-bit hash value. - lower: u64, - /// Another 64 bits of a 128-bit hash value. - upper: u64, - } - - /// A descriptor is a reference to a filesystem object, which may be a file, - /// directory, named pipe, special file, or other object on which filesystem - /// calls may be made. - @since(version = 0.2.0) - resource descriptor { - /// Return a stream for reading from a file, if available. - /// - /// May fail with an error-code describing why the file cannot be read. - /// - /// Multiple read, write, and append streams may be active on the same open - /// file and they do not interfere with each other. - /// - /// Note: This allows using `read-stream`, which is similar to `read` in POSIX. - @since(version = 0.2.0) - read-via-stream: func(offset: filesize) -> result; - /// Return a stream for writing to a file, if available. - /// - /// May fail with an error-code describing why the file cannot be written. - /// - /// Note: This allows using `write-stream`, which is similar to `write` in - /// POSIX. - @since(version = 0.2.0) - write-via-stream: func(offset: filesize) -> result; - /// Return a stream for appending to a file, if available. - /// - /// May fail with an error-code describing why the file cannot be appended. - /// - /// Note: This allows using `write-stream`, which is similar to `write` with - /// `O_APPEND` in POSIX. - @since(version = 0.2.0) - append-via-stream: func() -> result; - /// Provide file advisory information on a descriptor. - /// - /// This is similar to `posix_fadvise` in POSIX. - @since(version = 0.2.0) - advise: func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; - /// Synchronize the data of a file to disk. - /// - /// This function succeeds with no effect if the file descriptor is not - /// opened for writing. - /// - /// Note: This is similar to `fdatasync` in POSIX. - @since(version = 0.2.0) - sync-data: func() -> result<_, error-code>; - /// Get flags associated with a descriptor. - /// - /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. - /// - /// Note: This returns the value that was the `fs_flags` value returned - /// from `fdstat_get` in earlier versions of WASI. - @since(version = 0.2.0) - get-flags: func() -> result; - /// Get the dynamic type of a descriptor. - /// - /// Note: This returns the same value as the `type` field of the `fd-stat` - /// returned by `stat`, `stat-at` and similar. - /// - /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided - /// by `fstat` in POSIX. - /// - /// Note: This returns the value that was the `fs_filetype` value returned - /// from `fdstat_get` in earlier versions of WASI. - @since(version = 0.2.0) - get-type: func() -> result; - /// Adjust the size of an open file. If this increases the file's size, the - /// extra bytes are filled with zeros. - /// - /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. - @since(version = 0.2.0) - set-size: func(size: filesize) -> result<_, error-code>; - /// Adjust the timestamps of an open file or directory. - /// - /// Note: This is similar to `futimens` in POSIX. - /// - /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. - @since(version = 0.2.0) - set-times: func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; - /// Read from a descriptor, without using and updating the descriptor's offset. - /// - /// This function returns a list of bytes containing the data that was - /// read, along with a bool which, when true, indicates that the end of the - /// file was reached. The returned list will contain up to `length` bytes; it - /// may return fewer than requested, if the end of the file is reached or - /// if the I/O operation is interrupted. - /// - /// In the future, this may change to return a `stream`. - /// - /// Note: This is similar to `pread` in POSIX. - @since(version = 0.2.0) - read: func(length: filesize, offset: filesize) -> result, bool>, error-code>; - /// Write to a descriptor, without using and updating the descriptor's offset. - /// - /// It is valid to write past the end of a file; the file is extended to the - /// extent of the write, with bytes between the previous end and the start of - /// the write set to zero. - /// - /// In the future, this may change to take a `stream`. - /// - /// Note: This is similar to `pwrite` in POSIX. - @since(version = 0.2.0) - write: func(buffer: list, offset: filesize) -> result; - /// Read directory entries from a directory. - /// - /// On filesystems where directories contain entries referring to themselves - /// and their parents, often named `.` and `..` respectively, these entries - /// are omitted. - /// - /// This always returns a new stream which starts at the beginning of the - /// directory. Multiple streams may be active on the same directory, and they - /// do not interfere with each other. - @since(version = 0.2.0) - read-directory: func() -> result; - /// Synchronize the data and metadata of a file to disk. - /// - /// This function succeeds with no effect if the file descriptor is not - /// opened for writing. - /// - /// Note: This is similar to `fsync` in POSIX. - @since(version = 0.2.0) - sync: func() -> result<_, error-code>; - /// Create a directory. - /// - /// Note: This is similar to `mkdirat` in POSIX. - @since(version = 0.2.0) - create-directory-at: func(path: string) -> result<_, error-code>; - /// Return the attributes of an open file or directory. - /// - /// Note: This is similar to `fstat` in POSIX, except that it does not return - /// device and inode information. For testing whether two descriptors refer to - /// the same underlying filesystem object, use `is-same-object`. To obtain - /// additional data that can be used do determine whether a file has been - /// modified, use `metadata-hash`. - /// - /// Note: This was called `fd_filestat_get` in earlier versions of WASI. - @since(version = 0.2.0) - stat: func() -> result; - /// Return the attributes of a file or directory. - /// - /// Note: This is similar to `fstatat` in POSIX, except that it does not - /// return device and inode information. See the `stat` description for a - /// discussion of alternatives. - /// - /// Note: This was called `path_filestat_get` in earlier versions of WASI. - @since(version = 0.2.0) - stat-at: func(path-flags: path-flags, path: string) -> result; - /// Adjust the timestamps of a file or directory. - /// - /// Note: This is similar to `utimensat` in POSIX. - /// - /// Note: This was called `path_filestat_set_times` in earlier versions of - /// WASI. - @since(version = 0.2.0) - set-times-at: func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; - /// Create a hard link. - /// - /// Fails with `error-code::no-entry` if the old path does not exist, - /// with `error-code::exist` if the new path already exists, and - /// `error-code::not-permitted` if the old path is not a file. - /// - /// Note: This is similar to `linkat` in POSIX. - @since(version = 0.2.0) - link-at: func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; - /// Open a file or directory. - /// - /// If `flags` contains `descriptor-flags::mutate-directory`, and the base - /// descriptor doesn't have `descriptor-flags::mutate-directory` set, - /// `open-at` fails with `error-code::read-only`. - /// - /// If `flags` contains `write` or `mutate-directory`, or `open-flags` - /// contains `truncate` or `create`, and the base descriptor doesn't have - /// `descriptor-flags::mutate-directory` set, `open-at` fails with - /// `error-code::read-only`. - /// - /// Note: This is similar to `openat` in POSIX. - @since(version = 0.2.0) - open-at: func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; - /// Read the contents of a symbolic link. - /// - /// If the contents contain an absolute or rooted path in the underlying - /// filesystem, this function fails with `error-code::not-permitted`. - /// - /// Note: This is similar to `readlinkat` in POSIX. - @since(version = 0.2.0) - readlink-at: func(path: string) -> result; - /// Remove a directory. - /// - /// Return `error-code::not-empty` if the directory is not empty. - /// - /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. - @since(version = 0.2.0) - remove-directory-at: func(path: string) -> result<_, error-code>; - /// Rename a filesystem object. - /// - /// Note: This is similar to `renameat` in POSIX. - @since(version = 0.2.0) - rename-at: func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; - /// Create a symbolic link (also known as a "symlink"). - /// - /// If `old-path` starts with `/`, the function fails with - /// `error-code::not-permitted`. - /// - /// Note: This is similar to `symlinkat` in POSIX. - @since(version = 0.2.0) - symlink-at: func(old-path: string, new-path: string) -> result<_, error-code>; - /// Unlink a filesystem object that is not a directory. - /// - /// Return `error-code::is-directory` if the path refers to a directory. - /// Note: This is similar to `unlinkat(fd, path, 0)` in POSIX. - @since(version = 0.2.0) - unlink-file-at: func(path: string) -> result<_, error-code>; - /// Test whether two descriptors refer to the same filesystem object. - /// - /// In POSIX, this corresponds to testing whether the two descriptors have the - /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. - /// wasi-filesystem does not expose device and inode numbers, so this function - /// may be used instead. - @since(version = 0.2.0) - is-same-object: func(other: borrow) -> bool; - /// Return a hash of the metadata associated with a filesystem object referred - /// to by a descriptor. - /// - /// This returns a hash of the last-modification timestamp and file size, and - /// may also include the inode number, device number, birth timestamp, and - /// other metadata fields that may change when the file is modified or - /// replaced. It may also include a secret value chosen by the - /// implementation and not otherwise exposed. - /// - /// Implementations are encouraged to provide the following properties: - /// - /// - If the file is not modified or replaced, the computed hash value should - /// usually not change. - /// - If the object is modified or replaced, the computed hash value should - /// usually change. - /// - The inputs to the hash should not be easily computable from the - /// computed hash. - /// - /// However, none of these is required. - @since(version = 0.2.0) - metadata-hash: func() -> result; - /// Return a hash of the metadata associated with a filesystem object referred - /// to by a directory descriptor and a relative path. - /// - /// This performs the same hash computation as `metadata-hash`. - @since(version = 0.2.0) - metadata-hash-at: func(path-flags: path-flags, path: string) -> result; - } - - /// A stream of directory entries. - @since(version = 0.2.0) - resource directory-entry-stream { - /// Read a single directory entry from a `directory-entry-stream`. - @since(version = 0.2.0) - read-directory-entry: func() -> result, error-code>; - } - - /// Attempts to extract a filesystem-related `error-code` from the stream - /// `error` provided. - /// - /// Stream operations which return `stream-error::last-operation-failed` - /// have a payload with more information about the operation that failed. - /// This payload can be passed through to this function to see if there's - /// filesystem-related information about the error to return. - /// - /// Note that this function is fallible because not all stream-related - /// errors are filesystem-related errors. - @since(version = 0.2.0) - filesystem-error-code: func(err: borrow) -> option; -} - -@since(version = 0.2.0) -interface preopens { - @since(version = 0.2.0) - use types.{descriptor}; - - /// Return the set of preopened directories, and their paths. - @since(version = 0.2.0) - get-directories: func() -> list>; -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import wasi:io/error@0.2.6; - @since(version = 0.2.0) - import wasi:io/poll@0.2.6; - @since(version = 0.2.0) - import wasi:io/streams@0.2.6; - @since(version = 0.2.0) - import wasi:clocks/wall-clock@0.2.6; - @since(version = 0.2.0) - import types; - @since(version = 0.2.0) - import preopens; -} diff --git a/wit/deps/io.wit b/wit/deps/io.wit deleted file mode 100644 index 08ad78e..0000000 --- a/wit/deps/io.wit +++ /dev/null @@ -1,331 +0,0 @@ -package wasi:io@0.2.6; - -@since(version = 0.2.0) -interface error { - /// A resource which represents some error information. - /// - /// The only method provided by this resource is `to-debug-string`, - /// which provides some human-readable information about the error. - /// - /// In the `wasi:io` package, this resource is returned through the - /// `wasi:io/streams/stream-error` type. - /// - /// To provide more specific error information, other interfaces may - /// offer functions to "downcast" this error into more specific types. For example, - /// errors returned from streams derived from filesystem types can be described using - /// the filesystem's own error-code type. This is done using the function - /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` - /// parameter and returns an `option`. - /// - /// The set of functions which can "downcast" an `error` into a more - /// concrete type is open. - @since(version = 0.2.0) - resource error { - /// Returns a string that is suitable to assist humans in debugging - /// this error. - /// - /// WARNING: The returned string should not be consumed mechanically! - /// It may change across platforms, hosts, or other implementation - /// details. Parsing this string is a major platform-compatibility - /// hazard. - @since(version = 0.2.0) - to-debug-string: func() -> string; - } -} - -/// A poll API intended to let users wait for I/O events on multiple handles -/// at once. -@since(version = 0.2.0) -interface poll { - /// `pollable` represents a single I/O event which may be ready, or not. - @since(version = 0.2.0) - resource pollable { - /// Return the readiness of a pollable. This function never blocks. - /// - /// Returns `true` when the pollable is ready, and `false` otherwise. - @since(version = 0.2.0) - ready: func() -> bool; - /// `block` returns immediately if the pollable is ready, and otherwise - /// blocks until ready. - /// - /// This function is equivalent to calling `poll.poll` on a list - /// containing only this pollable. - @since(version = 0.2.0) - block: func(); - } - - /// Poll for completion on a set of pollables. - /// - /// This function takes a list of pollables, which identify I/O sources of - /// interest, and waits until one or more of the events is ready for I/O. - /// - /// The result `list` contains one or more indices of handles in the - /// argument list that is ready for I/O. - /// - /// This function traps if either: - /// - the list is empty, or: - /// - the list contains more elements than can be indexed with a `u32` value. - /// - /// A timeout can be implemented by adding a pollable from the - /// wasi-clocks API to the list. - /// - /// This function does not return a `result`; polling in itself does not - /// do any I/O so it doesn't fail. If any of the I/O sources identified by - /// the pollables has an error, it is indicated by marking the source as - /// being ready for I/O. - @since(version = 0.2.0) - poll: func(in: list>) -> list; -} - -/// WASI I/O is an I/O abstraction API which is currently focused on providing -/// stream types. -/// -/// In the future, the component model is expected to add built-in stream types; -/// when it does, they are expected to subsume this API. -@since(version = 0.2.0) -interface streams { - @since(version = 0.2.0) - use error.{error}; - @since(version = 0.2.0) - use poll.{pollable}; - - /// An error for input-stream and output-stream operations. - @since(version = 0.2.0) - variant stream-error { - /// The last operation (a write or flush) failed before completion. - /// - /// More information is available in the `error` payload. - /// - /// After this, the stream will be closed. All future operations return - /// `stream-error::closed`. - last-operation-failed(error), - /// The stream is closed: no more input will be accepted by the - /// stream. A closed output-stream will return this error on all - /// future operations. - closed, - } - - /// An input bytestream. - /// - /// `input-stream`s are *non-blocking* to the extent practical on underlying - /// platforms. I/O operations always return promptly; if fewer bytes are - /// promptly available than requested, they return the number of bytes promptly - /// available, which could even be zero. To wait for data to be available, - /// use the `subscribe` function to obtain a `pollable` which can be polled - /// for using `wasi:io/poll`. - @since(version = 0.2.0) - resource input-stream { - /// Perform a non-blocking read from the stream. - /// - /// When the source of a `read` is binary data, the bytes from the source - /// are returned verbatim. When the source of a `read` is known to the - /// implementation to be text, bytes containing the UTF-8 encoding of the - /// text are returned. - /// - /// This function returns a list of bytes containing the read data, - /// when successful. The returned list will contain up to `len` bytes; - /// it may return fewer than requested, but not more. The list is - /// empty when no bytes are available for reading at this time. The - /// pollable given by `subscribe` will be ready when more bytes are - /// available. - /// - /// This function fails with a `stream-error` when the operation - /// encounters an error, giving `last-operation-failed`, or when the - /// stream is closed, giving `closed`. - /// - /// When the caller gives a `len` of 0, it represents a request to - /// read 0 bytes. If the stream is still open, this call should - /// succeed and return an empty list, or otherwise fail with `closed`. - /// - /// The `len` parameter is a `u64`, which could represent a list of u8 which - /// is not possible to allocate in wasm32, or not desirable to allocate as - /// as a return value by the callee. The callee may return a list of bytes - /// less than `len` in size while more bytes are available for reading. - @since(version = 0.2.0) - read: func(len: u64) -> result, stream-error>; - /// Read bytes from a stream, after blocking until at least one byte can - /// be read. Except for blocking, behavior is identical to `read`. - @since(version = 0.2.0) - blocking-read: func(len: u64) -> result, stream-error>; - /// Skip bytes from a stream. Returns number of bytes skipped. - /// - /// Behaves identical to `read`, except instead of returning a list - /// of bytes, returns the number of bytes consumed from the stream. - @since(version = 0.2.0) - skip: func(len: u64) -> result; - /// Skip bytes from a stream, after blocking until at least one byte - /// can be skipped. Except for blocking behavior, identical to `skip`. - @since(version = 0.2.0) - blocking-skip: func(len: u64) -> result; - /// Create a `pollable` which will resolve once either the specified stream - /// has bytes available to read or the other end of the stream has been - /// closed. - /// The created `pollable` is a child resource of the `input-stream`. - /// Implementations may trap if the `input-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - /// An output bytestream. - /// - /// `output-stream`s are *non-blocking* to the extent practical on - /// underlying platforms. Except where specified otherwise, I/O operations also - /// always return promptly, after the number of bytes that can be written - /// promptly, which could even be zero. To wait for the stream to be ready to - /// accept data, the `subscribe` function to obtain a `pollable` which can be - /// polled for using `wasi:io/poll`. - /// - /// Dropping an `output-stream` while there's still an active write in - /// progress may result in the data being lost. Before dropping the stream, - /// be sure to fully flush your writes. - @since(version = 0.2.0) - resource output-stream { - /// Check readiness for writing. This function never blocks. - /// - /// Returns the number of bytes permitted for the next call to `write`, - /// or an error. Calling `write` with more bytes than this function has - /// permitted will trap. - /// - /// When this function returns 0 bytes, the `subscribe` pollable will - /// become ready when this function will report at least 1 byte, or an - /// error. - @since(version = 0.2.0) - check-write: func() -> result; - /// Perform a write. This function never blocks. - /// - /// When the destination of a `write` is binary data, the bytes from - /// `contents` are written verbatim. When the destination of a `write` is - /// known to the implementation to be text, the bytes of `contents` are - /// transcoded from UTF-8 into the encoding of the destination and then - /// written. - /// - /// Precondition: check-write gave permit of Ok(n) and contents has a - /// length of less than or equal to n. Otherwise, this function will trap. - /// - /// returns Err(closed) without writing if the stream has closed since - /// the last call to check-write provided a permit. - @since(version = 0.2.0) - write: func(contents: list) -> result<_, stream-error>; - /// Perform a write of up to 4096 bytes, and then flush the stream. Block - /// until all of these operations are complete, or an error occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write`, and `flush`, and is implemented with the - /// following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while !contents.is_empty() { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, contents.len()); - /// let (chunk, rest) = contents.split_at(len); - /// this.write(chunk ); // eliding error handling - /// contents = rest; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; - /// Request to flush buffered output. This function never blocks. - /// - /// This tells the output-stream that the caller intends any buffered - /// output to be flushed. the output which is expected to be flushed - /// is all that has been passed to `write` prior to this call. - /// - /// Upon calling this function, the `output-stream` will not accept any - /// writes (`check-write` will return `ok(0)`) until the flush has - /// completed. The `subscribe` pollable will become ready when the - /// flush has completed and the stream can accept more writes. - @since(version = 0.2.0) - flush: func() -> result<_, stream-error>; - /// Request to flush buffered output, and block until flush completes - /// and stream is ready for writing again. - @since(version = 0.2.0) - blocking-flush: func() -> result<_, stream-error>; - /// Create a `pollable` which will resolve once the output-stream - /// is ready for more writing, or an error has occurred. When this - /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an - /// error. - /// - /// If the stream is closed, this pollable is always ready immediately. - /// - /// The created `pollable` is a child resource of the `output-stream`. - /// Implementations may trap if the `output-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - /// Write zeroes to a stream. - /// - /// This should be used precisely like `write` with the exact same - /// preconditions (must use check-write first), but instead of - /// passing a list of bytes, you simply pass the number of zero-bytes - /// that should be written. - @since(version = 0.2.0) - write-zeroes: func(len: u64) -> result<_, stream-error>; - /// Perform a write of up to 4096 zeroes, and then flush the stream. - /// Block until all of these operations are complete, or an error - /// occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with - /// the following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while num_zeroes != 0 { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, num_zeroes); - /// this.write-zeroes(len); // eliding error handling - /// num_zeroes -= len; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; - /// Read from one stream and write to another. - /// - /// The behavior of splice is equivalent to: - /// 1. calling `check-write` on the `output-stream` - /// 2. calling `read` on the `input-stream` with the smaller of the - /// `check-write` permitted length and the `len` provided to `splice` - /// 3. calling `write` on the `output-stream` with that read data. - /// - /// Any error reported by the call to `check-write`, `read`, or - /// `write` ends the splice and reports that error. - /// - /// This function returns the number of bytes transferred; it may be less - /// than `len`. - @since(version = 0.2.0) - splice: func(src: borrow, len: u64) -> result; - /// Read from one stream and write to another, with blocking. - /// - /// This is similar to `splice`, except that it blocks until the - /// `output-stream` is ready for writing, and the `input-stream` - /// is ready for reading, before performing the `splice`. - @since(version = 0.2.0) - blocking-splice: func(src: borrow, len: u64) -> result; - } -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import error; - @since(version = 0.2.0) - import poll; - @since(version = 0.2.0) - import streams; -} diff --git a/wit/deps/random.wit b/wit/deps/random.wit deleted file mode 100644 index 73edf5b..0000000 --- a/wit/deps/random.wit +++ /dev/null @@ -1,92 +0,0 @@ -package wasi:random@0.2.6; - -/// The insecure-seed interface for seeding hash-map DoS resistance. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -@since(version = 0.2.0) -interface insecure-seed { - /// Return a 128-bit value that may contain a pseudo-random value. - /// - /// The returned value is not required to be computed from a CSPRNG, and may - /// even be entirely deterministic. Host implementations are encouraged to - /// provide pseudo-random values to any program exposed to - /// attacker-controlled content, to enable DoS protection built into many - /// languages' hash-map implementations. - /// - /// This function is intended to only be called once, by a source language - /// to initialize Denial Of Service (DoS) protection in its hash-map - /// implementation. - /// - /// # Expected future evolution - /// - /// This will likely be changed to a value import, to prevent it from being - /// called multiple times and potentially used for purposes other than DoS - /// protection. - @since(version = 0.2.0) - insecure-seed: func() -> tuple; -} - -/// The insecure interface for insecure pseudo-random numbers. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -@since(version = 0.2.0) -interface insecure { - /// Return `len` insecure pseudo-random bytes. - /// - /// This function is not cryptographically secure. Do not use it for - /// anything related to security. - /// - /// There are no requirements on the values of the returned bytes, however - /// implementations are encouraged to return evenly distributed values with - /// a long period. - @since(version = 0.2.0) - get-insecure-random-bytes: func(len: u64) -> list; - - /// Return an insecure pseudo-random `u64` value. - /// - /// This function returns the same type of pseudo-random data as - /// `get-insecure-random-bytes`, represented as a `u64`. - @since(version = 0.2.0) - get-insecure-random-u64: func() -> u64; -} - -/// WASI Random is a random data API. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -@since(version = 0.2.0) -interface random { - /// Return `len` cryptographically-secure random or pseudo-random bytes. - /// - /// This function must produce data at least as cryptographically secure and - /// fast as an adequately seeded cryptographically-secure pseudo-random - /// number generator (CSPRNG). It must not block, from the perspective of - /// the calling program, under any circumstances, including on the first - /// request and on requests for numbers of bytes. The returned data must - /// always be unpredictable. - /// - /// This function must always return fresh data. Deterministic environments - /// must omit this function, rather than implementing it with deterministic - /// data. - @since(version = 0.2.0) - get-random-bytes: func(len: u64) -> list; - - /// Return a cryptographically-secure random or pseudo-random `u64` value. - /// - /// This function returns the same type of data as `get-random-bytes`, - /// represented as a `u64`. - @since(version = 0.2.0) - get-random-u64: func() -> u64; -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import random; - @since(version = 0.2.0) - import insecure; - @since(version = 0.2.0) - import insecure-seed; -} diff --git a/wit/deps/sockets.wit b/wit/deps/sockets.wit deleted file mode 100644 index db6d1a2..0000000 --- a/wit/deps/sockets.wit +++ /dev/null @@ -1,949 +0,0 @@ -package wasi:sockets@0.2.6; - -@since(version = 0.2.0) -interface network { - @unstable(feature = network-error-code) - use wasi:io/error@0.2.6.{error}; - - /// An opaque resource that represents access to (a subset of) the network. - /// This enables context-based security for networking. - /// There is no need for this to map 1:1 to a physical network interface. - @since(version = 0.2.0) - resource network; - - /// Error codes. - /// - /// In theory, every API can return any error code. - /// In practice, API's typically only return the errors documented per API - /// combined with a couple of errors that are always possible: - /// - `unknown` - /// - `access-denied` - /// - `not-supported` - /// - `out-of-memory` - /// - `concurrency-conflict` - /// - /// See each individual API for what the POSIX equivalents are. They sometimes differ per API. - @since(version = 0.2.0) - enum error-code { - /// Unknown error - unknown, - /// Access denied. - /// - /// POSIX equivalent: EACCES, EPERM - access-denied, - /// The operation is not supported. - /// - /// POSIX equivalent: EOPNOTSUPP - not-supported, - /// One of the arguments is invalid. - /// - /// POSIX equivalent: EINVAL - invalid-argument, - /// Not enough memory to complete the operation. - /// - /// POSIX equivalent: ENOMEM, ENOBUFS, EAI_MEMORY - out-of-memory, - /// The operation timed out before it could finish completely. - timeout, - /// This operation is incompatible with another asynchronous operation that is already in progress. - /// - /// POSIX equivalent: EALREADY - concurrency-conflict, - /// Trying to finish an asynchronous operation that: - /// - has not been started yet, or: - /// - was already finished by a previous `finish-*` call. - /// - /// Note: this is scheduled to be removed when `future`s are natively supported. - not-in-progress, - /// The operation has been aborted because it could not be completed immediately. - /// - /// Note: this is scheduled to be removed when `future`s are natively supported. - would-block, - /// The operation is not valid in the socket's current state. - invalid-state, - /// A new socket resource could not be created because of a system limit. - new-socket-limit, - /// A bind operation failed because the provided address is not an address that the `network` can bind to. - address-not-bindable, - /// A bind operation failed because the provided address is already in use or because there are no ephemeral ports available. - address-in-use, - /// The remote address is not reachable - remote-unreachable, - /// The TCP connection was forcefully rejected - connection-refused, - /// The TCP connection was reset. - connection-reset, - /// A TCP connection was aborted. - connection-aborted, - /// The size of a datagram sent to a UDP socket exceeded the maximum - /// supported size. - datagram-too-large, - /// Name does not exist or has no suitable associated IP addresses. - name-unresolvable, - /// A temporary failure in name resolution occurred. - temporary-resolver-failure, - /// A permanent failure in name resolution occurred. - permanent-resolver-failure, - } - - @since(version = 0.2.0) - enum ip-address-family { - /// Similar to `AF_INET` in POSIX. - ipv4, - /// Similar to `AF_INET6` in POSIX. - ipv6, - } - - @since(version = 0.2.0) - type ipv4-address = tuple; - - @since(version = 0.2.0) - type ipv6-address = tuple; - - @since(version = 0.2.0) - variant ip-address { - ipv4(ipv4-address), - ipv6(ipv6-address), - } - - @since(version = 0.2.0) - record ipv4-socket-address { - /// sin_port - port: u16, - /// sin_addr - address: ipv4-address, - } - - @since(version = 0.2.0) - record ipv6-socket-address { - /// sin6_port - port: u16, - /// sin6_flowinfo - flow-info: u32, - /// sin6_addr - address: ipv6-address, - /// sin6_scope_id - scope-id: u32, - } - - @since(version = 0.2.0) - variant ip-socket-address { - ipv4(ipv4-socket-address), - ipv6(ipv6-socket-address), - } - - /// Attempts to extract a network-related `error-code` from the stream - /// `error` provided. - /// - /// Stream operations which return `stream-error::last-operation-failed` - /// have a payload with more information about the operation that failed. - /// This payload can be passed through to this function to see if there's - /// network-related information about the error to return. - /// - /// Note that this function is fallible because not all stream-related - /// errors are network-related errors. - @unstable(feature = network-error-code) - network-error-code: func(err: borrow) -> option; -} - -/// This interface provides a value-export of the default network handle.. -@since(version = 0.2.0) -interface instance-network { - @since(version = 0.2.0) - use network.{network}; - - /// Get a handle to the default network. - @since(version = 0.2.0) - instance-network: func() -> network; -} - -@since(version = 0.2.0) -interface ip-name-lookup { - @since(version = 0.2.0) - use wasi:io/poll@0.2.6.{pollable}; - @since(version = 0.2.0) - use network.{network, error-code, ip-address}; - - @since(version = 0.2.0) - resource resolve-address-stream { - /// Returns the next address from the resolver. - /// - /// This function should be called multiple times. On each call, it will - /// return the next address in connection order preference. If all - /// addresses have been exhausted, this function returns `none`. - /// - /// This function never returns IPv4-mapped IPv6 addresses. - /// - /// # Typical errors - /// - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) - /// - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) - /// - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) - /// - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) - @since(version = 0.2.0) - resolve-next-address: func() -> result, error-code>; - /// Create a `pollable` which will resolve once the stream is ready for I/O. - /// - /// Note: this function is here for WASI 0.2 only. - /// It's planned to be removed when `future` is natively supported in Preview3. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - /// Resolve an internet host name to a list of IP addresses. - /// - /// Unicode domain names are automatically converted to ASCII using IDNA encoding. - /// If the input is an IP address string, the address is parsed and returned - /// as-is without making any external requests. - /// - /// See the wasi-socket proposal README.md for a comparison with getaddrinfo. - /// - /// This function never blocks. It either immediately fails or immediately - /// returns successfully with a `resolve-address-stream` that can be used - /// to (asynchronously) fetch the results. - /// - /// # Typical errors - /// - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. - /// - /// # References: - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - resolve-addresses: func(network: borrow, name: string) -> result; -} - -@since(version = 0.2.0) -interface tcp { - @since(version = 0.2.0) - use wasi:io/streams@0.2.6.{input-stream, output-stream}; - @since(version = 0.2.0) - use wasi:io/poll@0.2.6.{pollable}; - @since(version = 0.2.0) - use wasi:clocks/monotonic-clock@0.2.6.{duration}; - @since(version = 0.2.0) - use network.{network, error-code, ip-socket-address, ip-address-family}; - - @since(version = 0.2.0) - enum shutdown-type { - /// Similar to `SHUT_RD` in POSIX. - receive, - /// Similar to `SHUT_WR` in POSIX. - send, - /// Similar to `SHUT_RDWR` in POSIX. - both, - } - - /// A TCP socket resource. - /// - /// The socket can be in one of the following states: - /// - `unbound` - /// - `bind-in-progress` - /// - `bound` (See note below) - /// - `listen-in-progress` - /// - `listening` - /// - `connect-in-progress` - /// - `connected` - /// - `closed` - /// See - /// for more information. - /// - /// Note: Except where explicitly mentioned, whenever this documentation uses - /// the term "bound" without backticks it actually means: in the `bound` state *or higher*. - /// (i.e. `bound`, `listen-in-progress`, `listening`, `connect-in-progress` or `connected`) - /// - /// In addition to the general error codes documented on the - /// `network::error-code` type, TCP socket methods may always return - /// `error(invalid-state)` when in the `closed` state. - @since(version = 0.2.0) - resource tcp-socket { - /// Bind the socket to a specific network on the provided IP address and port. - /// - /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which - /// network interface(s) to bind to. - /// If the TCP/UDP port is zero, the socket will be bound to a random free port. - /// - /// Bind can be attempted multiple times on the same socket, even with - /// different arguments on each iteration. But never concurrently and - /// only as long as the previous bind failed. Once a bind succeeds, the - /// binding can't be changed anymore. - /// - /// # Typical errors - /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) - /// - `invalid-argument`: `local-address` is not a unicast address. (EINVAL) - /// - `invalid-argument`: `local-address` is an IPv4-mapped IPv6 address. (EINVAL) - /// - `invalid-state`: The socket is already bound. (EINVAL) - /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) - /// - `address-in-use`: Address is already in use. (EADDRINUSE) - /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) - /// - `not-in-progress`: A `bind` operation is not in progress. - /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) - /// - /// # Implementors note - /// When binding to a non-zero port, this bind operation shouldn't be affected by the TIME_WAIT - /// state of a recently closed socket on the same local address. In practice this means that the SO_REUSEADDR - /// socket option should be set implicitly on all platforms, except on Windows where this is the default behavior - /// and SO_REUSEADDR performs something different entirely. - /// - /// Unlike in POSIX, in WASI the bind operation is async. This enables - /// interactive WASI hosts to inject permission prompts. Runtimes that - /// don't want to make use of this ability can simply call the native - /// `bind` as part of either `start-bind` or `finish-bind`. - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; - @since(version = 0.2.0) - finish-bind: func() -> result<_, error-code>; - /// Connect to a remote endpoint. - /// - /// On success: - /// - the socket is transitioned into the `connected` state. - /// - a pair of streams is returned that can be used to read & write to the connection - /// - /// After a failed connection attempt, the socket will be in the `closed` - /// state and the only valid action left is to `drop` the socket. A single - /// socket can not be used to connect more than once. - /// - /// # Typical errors - /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) - /// - `invalid-argument`: `remote-address` is not a unicast address. (EINVAL, ENETUNREACH on Linux, EAFNOSUPPORT on MacOS) - /// - `invalid-argument`: `remote-address` is an IPv4-mapped IPv6 address. (EINVAL, EADDRNOTAVAIL on Illumos) - /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EADDRNOTAVAIL on Windows) - /// - `invalid-argument`: The port in `remote-address` is set to 0. (EADDRNOTAVAIL on Windows) - /// - `invalid-argument`: The socket is already attached to a different network. The `network` passed to `connect` must be identical to the one passed to `bind`. - /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN) - /// - `invalid-state`: The socket is already in the `listening` state. (EOPNOTSUPP, EINVAL on Windows) - /// - `timeout`: Connection timed out. (ETIMEDOUT) - /// - `connection-refused`: The connection was forcefully rejected. (ECONNREFUSED) - /// - `connection-reset`: The connection was reset. (ECONNRESET) - /// - `connection-aborted`: The connection was aborted. (ECONNABORTED) - /// - `remote-unreachable`: The remote address is not reachable. (EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) - /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) - /// - `not-in-progress`: A connect operation is not in progress. - /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) - /// - /// # Implementors note - /// The POSIX equivalent of `start-connect` is the regular `connect` syscall. - /// Because all WASI sockets are non-blocking this is expected to return - /// EINPROGRESS, which should be translated to `ok()` in WASI. - /// - /// The POSIX equivalent of `finish-connect` is a `poll` for event `POLLOUT` - /// with a timeout of 0 on the socket descriptor. Followed by a check for - /// the `SO_ERROR` socket option, in case the poll signaled readiness. - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - start-connect: func(network: borrow, remote-address: ip-socket-address) -> result<_, error-code>; - @since(version = 0.2.0) - finish-connect: func() -> result, error-code>; - /// Start listening for new connections. - /// - /// Transitions the socket into the `listening` state. - /// - /// Unlike POSIX, the socket must already be explicitly bound. - /// - /// # Typical errors - /// - `invalid-state`: The socket is not bound to any local address. (EDESTADDRREQ) - /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN, EINVAL on BSD) - /// - `invalid-state`: The socket is already in the `listening` state. - /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE) - /// - `not-in-progress`: A listen operation is not in progress. - /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) - /// - /// # Implementors note - /// Unlike in POSIX, in WASI the listen operation is async. This enables - /// interactive WASI hosts to inject permission prompts. Runtimes that - /// don't want to make use of this ability can simply call the native - /// `listen` as part of either `start-listen` or `finish-listen`. - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - start-listen: func() -> result<_, error-code>; - @since(version = 0.2.0) - finish-listen: func() -> result<_, error-code>; - /// Accept a new client socket. - /// - /// The returned socket is bound and in the `connected` state. The following properties are inherited from the listener socket: - /// - `address-family` - /// - `keep-alive-enabled` - /// - `keep-alive-idle-time` - /// - `keep-alive-interval` - /// - `keep-alive-count` - /// - `hop-limit` - /// - `receive-buffer-size` - /// - `send-buffer-size` - /// - /// On success, this function returns the newly accepted client socket along with - /// a pair of streams that can be used to read & write to the connection. - /// - /// # Typical errors - /// - `invalid-state`: Socket is not in the `listening` state. (EINVAL) - /// - `would-block`: No pending connections at the moment. (EWOULDBLOCK, EAGAIN) - /// - `connection-aborted`: An incoming connection was pending, but was terminated by the client before this listener could accept it. (ECONNABORTED) - /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - accept: func() -> result, error-code>; - /// Get the bound local address. - /// - /// POSIX mentions: - /// > If the socket has not been bound to a local name, the value - /// > stored in the object pointed to by `address` is unspecified. - /// - /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. - /// - /// # Typical errors - /// - `invalid-state`: The socket is not bound to any local address. - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - local-address: func() -> result; - /// Get the remote address. - /// - /// # Typical errors - /// - `invalid-state`: The socket is not connected to a remote address. (ENOTCONN) - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - remote-address: func() -> result; - /// Whether the socket is in the `listening` state. - /// - /// Equivalent to the SO_ACCEPTCONN socket option. - @since(version = 0.2.0) - is-listening: func() -> bool; - /// Whether this is a IPv4 or IPv6 socket. - /// - /// Equivalent to the SO_DOMAIN socket option. - @since(version = 0.2.0) - address-family: func() -> ip-address-family; - /// Hints the desired listen queue size. Implementations are free to ignore this. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// Any other value will never cause an error, but it might be silently clamped and/or rounded. - /// - /// # Typical errors - /// - `not-supported`: (set) The platform does not support changing the backlog size after the initial listen. - /// - `invalid-argument`: (set) The provided value was 0. - /// - `invalid-state`: (set) The socket is in the `connect-in-progress` or `connected` state. - @since(version = 0.2.0) - set-listen-backlog-size: func(value: u64) -> result<_, error-code>; - /// Enables or disables keepalive. - /// - /// The keepalive behavior can be adjusted using: - /// - `keep-alive-idle-time` - /// - `keep-alive-interval` - /// - `keep-alive-count` - /// These properties can be configured while `keep-alive-enabled` is false, but only come into effect when `keep-alive-enabled` is true. - /// - /// Equivalent to the SO_KEEPALIVE socket option. - @since(version = 0.2.0) - keep-alive-enabled: func() -> result; - @since(version = 0.2.0) - set-keep-alive-enabled: func(value: bool) -> result<_, error-code>; - /// Amount of time the connection has to be idle before TCP starts sending keepalive packets. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// Any other value will never cause an error, but it might be silently clamped and/or rounded. - /// I.e. after setting a value, reading the same setting back may return a different value. - /// - /// Equivalent to the TCP_KEEPIDLE socket option. (TCP_KEEPALIVE on MacOS) - /// - /// # Typical errors - /// - `invalid-argument`: (set) The provided value was 0. - @since(version = 0.2.0) - keep-alive-idle-time: func() -> result; - @since(version = 0.2.0) - set-keep-alive-idle-time: func(value: duration) -> result<_, error-code>; - /// The time between keepalive packets. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// Any other value will never cause an error, but it might be silently clamped and/or rounded. - /// I.e. after setting a value, reading the same setting back may return a different value. - /// - /// Equivalent to the TCP_KEEPINTVL socket option. - /// - /// # Typical errors - /// - `invalid-argument`: (set) The provided value was 0. - @since(version = 0.2.0) - keep-alive-interval: func() -> result; - @since(version = 0.2.0) - set-keep-alive-interval: func(value: duration) -> result<_, error-code>; - /// The maximum amount of keepalive packets TCP should send before aborting the connection. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// Any other value will never cause an error, but it might be silently clamped and/or rounded. - /// I.e. after setting a value, reading the same setting back may return a different value. - /// - /// Equivalent to the TCP_KEEPCNT socket option. - /// - /// # Typical errors - /// - `invalid-argument`: (set) The provided value was 0. - @since(version = 0.2.0) - keep-alive-count: func() -> result; - @since(version = 0.2.0) - set-keep-alive-count: func(value: u32) -> result<_, error-code>; - /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// - /// # Typical errors - /// - `invalid-argument`: (set) The TTL value must be 1 or higher. - @since(version = 0.2.0) - hop-limit: func() -> result; - @since(version = 0.2.0) - set-hop-limit: func(value: u8) -> result<_, error-code>; - /// The kernel buffer space reserved for sends/receives on this socket. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// Any other value will never cause an error, but it might be silently clamped and/or rounded. - /// I.e. after setting a value, reading the same setting back may return a different value. - /// - /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. - /// - /// # Typical errors - /// - `invalid-argument`: (set) The provided value was 0. - @since(version = 0.2.0) - receive-buffer-size: func() -> result; - @since(version = 0.2.0) - set-receive-buffer-size: func(value: u64) -> result<_, error-code>; - @since(version = 0.2.0) - send-buffer-size: func() -> result; - @since(version = 0.2.0) - set-send-buffer-size: func(value: u64) -> result<_, error-code>; - /// Create a `pollable` which can be used to poll for, or block on, - /// completion of any of the asynchronous operations of this socket. - /// - /// When `finish-bind`, `finish-listen`, `finish-connect` or `accept` - /// return `error(would-block)`, this pollable can be used to wait for - /// their success or failure, after which the method can be retried. - /// - /// The pollable is not limited to the async operation that happens to be - /// in progress at the time of calling `subscribe` (if any). Theoretically, - /// `subscribe` only has to be called once per socket and can then be - /// (re)used for the remainder of the socket's lifetime. - /// - /// See - /// for more information. - /// - /// Note: this function is here for WASI 0.2 only. - /// It's planned to be removed when `future` is natively supported in Preview3. - @since(version = 0.2.0) - subscribe: func() -> pollable; - /// Initiate a graceful shutdown. - /// - /// - `receive`: The socket is not expecting to receive any data from - /// the peer. The `input-stream` associated with this socket will be - /// closed. Any data still in the receive queue at time of calling - /// this method will be discarded. - /// - `send`: The socket has no more data to send to the peer. The `output-stream` - /// associated with this socket will be closed and a FIN packet will be sent. - /// - `both`: Same effect as `receive` & `send` combined. - /// - /// This function is idempotent; shutting down a direction more than once - /// has no effect and returns `ok`. - /// - /// The shutdown function does not close (drop) the socket. - /// - /// # Typical errors - /// - `invalid-state`: The socket is not in the `connected` state. (ENOTCONN) - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - shutdown: func(shutdown-type: shutdown-type) -> result<_, error-code>; - } -} - -@since(version = 0.2.0) -interface tcp-create-socket { - @since(version = 0.2.0) - use network.{network, error-code, ip-address-family}; - @since(version = 0.2.0) - use tcp.{tcp-socket}; - - /// Create a new TCP socket. - /// - /// Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. - /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. - /// - /// This function does not require a network capability handle. This is considered to be safe because - /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` - /// is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. - /// - /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. - /// - /// # Typical errors - /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) - /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - create-tcp-socket: func(address-family: ip-address-family) -> result; -} - -@since(version = 0.2.0) -interface udp { - @since(version = 0.2.0) - use wasi:io/poll@0.2.6.{pollable}; - @since(version = 0.2.0) - use network.{network, error-code, ip-socket-address, ip-address-family}; - - /// A received datagram. - @since(version = 0.2.0) - record incoming-datagram { - /// The payload. - /// - /// Theoretical max size: ~64 KiB. In practice, typically less than 1500 bytes. - data: list, - /// The source address. - /// - /// This field is guaranteed to match the remote address the stream was initialized with, if any. - /// - /// Equivalent to the `src_addr` out parameter of `recvfrom`. - remote-address: ip-socket-address, - } - - /// A datagram to be sent out. - @since(version = 0.2.0) - record outgoing-datagram { - /// The payload. - data: list, - /// The destination address. - /// - /// The requirements on this field depend on how the stream was initialized: - /// - with a remote address: this field must be None or match the stream's remote address exactly. - /// - without a remote address: this field is required. - /// - /// If this value is None, the send operation is equivalent to `send` in POSIX. Otherwise it is equivalent to `sendto`. - remote-address: option, - } - - /// A UDP socket handle. - @since(version = 0.2.0) - resource udp-socket { - /// Bind the socket to a specific network on the provided IP address and port. - /// - /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which - /// network interface(s) to bind to. - /// If the port is zero, the socket will be bound to a random free port. - /// - /// # Typical errors - /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) - /// - `invalid-state`: The socket is already bound. (EINVAL) - /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) - /// - `address-in-use`: Address is already in use. (EADDRINUSE) - /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) - /// - `not-in-progress`: A `bind` operation is not in progress. - /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) - /// - /// # Implementors note - /// Unlike in POSIX, in WASI the bind operation is async. This enables - /// interactive WASI hosts to inject permission prompts. Runtimes that - /// don't want to make use of this ability can simply call the native - /// `bind` as part of either `start-bind` or `finish-bind`. - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; - @since(version = 0.2.0) - finish-bind: func() -> result<_, error-code>; - /// Set up inbound & outbound communication channels, optionally to a specific peer. - /// - /// This function only changes the local socket configuration and does not generate any network traffic. - /// On success, the `remote-address` of the socket is updated. The `local-address` may be updated as well, - /// based on the best network path to `remote-address`. - /// - /// When a `remote-address` is provided, the returned streams are limited to communicating with that specific peer: - /// - `send` can only be used to send to this destination. - /// - `receive` will only return datagrams sent from the provided `remote-address`. - /// - /// This method may be called multiple times on the same socket to change its association, but - /// only the most recently returned pair of streams will be operational. Implementations may trap if - /// the streams returned by a previous invocation haven't been dropped yet before calling `stream` again. - /// - /// The POSIX equivalent in pseudo-code is: - /// ```text - /// if (was previously connected) { - /// connect(s, AF_UNSPEC) - /// } - /// if (remote_address is Some) { - /// connect(s, remote_address) - /// } - /// ``` - /// - /// Unlike in POSIX, the socket must already be explicitly bound. - /// - /// # Typical errors - /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) - /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) - /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) - /// - `invalid-state`: The socket is not bound. - /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) - /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) - /// - `connection-refused`: The connection was refused. (ECONNREFUSED) - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - %stream: func(remote-address: option) -> result, error-code>; - /// Get the current bound address. - /// - /// POSIX mentions: - /// > If the socket has not been bound to a local name, the value - /// > stored in the object pointed to by `address` is unspecified. - /// - /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. - /// - /// # Typical errors - /// - `invalid-state`: The socket is not bound to any local address. - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - local-address: func() -> result; - /// Get the address the socket is currently streaming to. - /// - /// # Typical errors - /// - `invalid-state`: The socket is not streaming to a specific remote address. (ENOTCONN) - /// - /// # References - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - remote-address: func() -> result; - /// Whether this is a IPv4 or IPv6 socket. - /// - /// Equivalent to the SO_DOMAIN socket option. - @since(version = 0.2.0) - address-family: func() -> ip-address-family; - /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// - /// # Typical errors - /// - `invalid-argument`: (set) The TTL value must be 1 or higher. - @since(version = 0.2.0) - unicast-hop-limit: func() -> result; - @since(version = 0.2.0) - set-unicast-hop-limit: func(value: u8) -> result<_, error-code>; - /// The kernel buffer space reserved for sends/receives on this socket. - /// - /// If the provided value is 0, an `invalid-argument` error is returned. - /// Any other value will never cause an error, but it might be silently clamped and/or rounded. - /// I.e. after setting a value, reading the same setting back may return a different value. - /// - /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. - /// - /// # Typical errors - /// - `invalid-argument`: (set) The provided value was 0. - @since(version = 0.2.0) - receive-buffer-size: func() -> result; - @since(version = 0.2.0) - set-receive-buffer-size: func(value: u64) -> result<_, error-code>; - @since(version = 0.2.0) - send-buffer-size: func() -> result; - @since(version = 0.2.0) - set-send-buffer-size: func(value: u64) -> result<_, error-code>; - /// Create a `pollable` which will resolve once the socket is ready for I/O. - /// - /// Note: this function is here for WASI 0.2 only. - /// It's planned to be removed when `future` is natively supported in Preview3. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - @since(version = 0.2.0) - resource incoming-datagram-stream { - /// Receive messages on the socket. - /// - /// This function attempts to receive up to `max-results` datagrams on the socket without blocking. - /// The returned list may contain fewer elements than requested, but never more. - /// - /// This function returns successfully with an empty list when either: - /// - `max-results` is 0, or: - /// - `max-results` is greater than 0, but no results are immediately available. - /// This function never returns `error(would-block)`. - /// - /// # Typical errors - /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) - /// - `connection-refused`: The connection was refused. (ECONNREFUSED) - /// - /// # References - /// - - /// - - /// - - /// - - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - receive: func(max-results: u64) -> result, error-code>; - /// Create a `pollable` which will resolve once the stream is ready to receive again. - /// - /// Note: this function is here for WASI 0.2 only. - /// It's planned to be removed when `future` is natively supported in Preview3. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - @since(version = 0.2.0) - resource outgoing-datagram-stream { - /// Check readiness for sending. This function never blocks. - /// - /// Returns the number of datagrams permitted for the next call to `send`, - /// or an error. Calling `send` with more datagrams than this function has - /// permitted will trap. - /// - /// When this function returns ok(0), the `subscribe` pollable will - /// become ready when this function will report at least ok(1), or an - /// error. - /// - /// Never returns `would-block`. - check-send: func() -> result; - /// Send messages on the socket. - /// - /// This function attempts to send all provided `datagrams` on the socket without blocking and - /// returns how many messages were actually sent (or queued for sending). This function never - /// returns `error(would-block)`. If none of the datagrams were able to be sent, `ok(0)` is returned. - /// - /// This function semantically behaves the same as iterating the `datagrams` list and sequentially - /// sending each individual datagram until either the end of the list has been reached or the first error occurred. - /// If at least one datagram has been sent successfully, this function never returns an error. - /// - /// If the input list is empty, the function returns `ok(0)`. - /// - /// Each call to `send` must be permitted by a preceding `check-send`. Implementations must trap if - /// either `check-send` was not called or `datagrams` contains more items than `check-send` permitted. - /// - /// # Typical errors - /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) - /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) - /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) - /// - `invalid-argument`: The socket is in "connected" mode and `remote-address` is `some` value that does not match the address passed to `stream`. (EISCONN) - /// - `invalid-argument`: The socket is not "connected" and no value for `remote-address` was provided. (EDESTADDRREQ) - /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) - /// - `connection-refused`: The connection was refused. (ECONNREFUSED) - /// - `datagram-too-large`: The datagram is too large. (EMSGSIZE) - /// - /// # References - /// - - /// - - /// - - /// - - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - send: func(datagrams: list) -> result; - /// Create a `pollable` which will resolve once the stream is ready to send again. - /// - /// Note: this function is here for WASI 0.2 only. - /// It's planned to be removed when `future` is natively supported in Preview3. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } -} - -@since(version = 0.2.0) -interface udp-create-socket { - @since(version = 0.2.0) - use network.{network, error-code, ip-address-family}; - @since(version = 0.2.0) - use udp.{udp-socket}; - - /// Create a new UDP socket. - /// - /// Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. - /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. - /// - /// This function does not require a network capability handle. This is considered to be safe because - /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, - /// the socket is effectively an in-memory configuration object, unable to communicate with the outside world. - /// - /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. - /// - /// # Typical errors - /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) - /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) - /// - /// # References: - /// - - /// - - /// - - /// - - @since(version = 0.2.0) - create-udp-socket: func(address-family: ip-address-family) -> result; -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import wasi:io/error@0.2.6; - @since(version = 0.2.0) - import network; - @since(version = 0.2.0) - import instance-network; - @since(version = 0.2.0) - import wasi:io/poll@0.2.6; - @since(version = 0.2.0) - import udp; - @since(version = 0.2.0) - import udp-create-socket; - @since(version = 0.2.0) - import wasi:io/streams@0.2.6; - @since(version = 0.2.0) - import wasi:clocks/monotonic-clock@0.2.6; - @since(version = 0.2.0) - import tcp; - @since(version = 0.2.0) - import tcp-create-socket; - @since(version = 0.2.0) - import ip-name-lookup; -} diff --git a/wit/world.wit b/wit/world.wit deleted file mode 100644 index 3ff8ff5..0000000 --- a/wit/world.wit +++ /dev/null @@ -1,8 +0,0 @@ -// World we generate host bindings for. We only need the filesystem -// interfaces — wasi:io, wasi:clocks, etc. are reused from `wasmtime-wasi`. -package s3fs:host; - -world enclave-runtime { - import wasi:filesystem/types@0.2.6; - import wasi:filesystem/preopens@0.2.6; -}