diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc5c653..f26c48b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,8 +58,12 @@ jobs: with: toolchain: stable targets: wasm32-unknown-unknown + components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo check -p multistore-cf-workers --target wasm32-unknown-unknown + # The Workers crates are excluded from `default-members`, so the native + # clippy job never sees them. Lint them on their real target. + - run: cargo clippy -p multistore-cf-workers -p multistore-cf-workers-example --target wasm32-unknown-unknown -- -D warnings audit: name: Audit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ab17e3..5a0ac52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,9 @@ cargo check # Check (WASM) cargo check -p multistore-cf-workers --target wasm32-unknown-unknown +# Lint (WASM) — the Workers crates are not in `default-members`, so lint them explicitly +cargo clippy -p multistore-cf-workers -p multistore-cf-workers-example --target wasm32-unknown-unknown -- -D warnings + # Unit + doc tests cargo test @@ -24,6 +27,12 @@ cargo test make test-integration ``` +### Lints and MSRV + +Lint levels live in `[workspace.lints]` in the root `Cargo.toml`; every crate opts in with `[lints] workspace = true`. Beyond clippy's defaults the workspace warns on `missing_docs` (document every `pub` item) and `clippy::unwrap_used` (return an error, or use `expect` with a message that states the invariant). Tests are exempt from the unwrap lint via `clippy.toml`. CI runs clippy with `-D warnings`, so a new warning fails the build. + +The minimum supported Rust version is declared as `rust-version` in the root `Cargo.toml`. It is set by the dependency tree; raise it only when a dependency forces it. + ## Release Process ### Publishable Crates diff --git a/Cargo.toml b/Cargo.toml index 590dd99..8229533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,17 @@ resolver = "2" version = "0.7.2" edition = "2021" license = "MIT" +# Floor set by the dependency tree (crc-fast via object_store); verified with `cargo +1.89 check`. +rust-version = "1.89" + +# Opted into per crate with `[lints] workspace = true`. +[workspace.lints.rust] +missing_docs = "warn" + +[workspace.lints.clippy] +# Production code returns errors; `expect` with a message documents a real invariant. +# Tests are exempt via `allow-unwrap-in-tests` in clippy.toml. +unwrap_used = "warn" [workspace.dependencies] # Core diff --git a/Makefile b/Makefile index 3e110f0..1ca5deb 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ fmt-fix: clippy: cargo clippy -- -D warnings +clippy-wasm: + cargo clippy -p multistore-cf-workers -p multistore-cf-workers-example --target wasm32-unknown-unknown -- -D warnings clippy-fix: cargo clippy --fix --allow-dirty --allow-staged @@ -35,7 +37,7 @@ run-server: run-workers: npx wrangler dev --cwd examples/cf-workers -ci-fast: fmt clippy check-wasm +ci-fast: fmt clippy clippy-wasm check-wasm ci: ci-fast test docs: diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..154626e --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/crates/cf-workers/Cargo.toml b/crates/cf-workers/Cargo.toml index a678a37..7620c24 100644 --- a/crates/cf-workers/Cargo.toml +++ b/crates/cf-workers/Cargo.toml @@ -55,3 +55,6 @@ features = ["wasm_js"] [dependencies.ring] version = "0.17" features = ["wasm32_unknown_unknown_js"] + +[lints] +workspace = true diff --git a/crates/cf-workers/src/headers.rs b/crates/cf-workers/src/headers.rs index cbc2018..926c963 100644 --- a/crates/cf-workers/src/headers.rs +++ b/crates/cf-workers/src/headers.rs @@ -27,7 +27,7 @@ impl WsHeaders { impl From<&HeaderMap> for WsHeaders { fn from(headers: &HeaderMap) -> Self { - let ws = web_sys::Headers::new().unwrap(); + let ws = web_sys::Headers::new().expect("Headers::new() cannot fail"); for (key, value) in headers.iter() { if let Ok(v) = value.to_str() { let _ = ws.set(key.as_str(), v); diff --git a/crates/cf-workers/src/response.rs b/crates/cf-workers/src/response.rs index e179a49..93be71f 100644 --- a/crates/cf-workers/src/response.rs +++ b/crates/cf-workers/src/response.rs @@ -17,13 +17,12 @@ pub(crate) fn response_from_proxy_result(result: ProxyResult) -> web_sys::Respon resp_init.set_headers(&WsHeaders::from(&result.headers).into_inner().into()); match result.body { - ProxyResponseBody::Empty => { - web_sys::Response::new_with_opt_str_and_init(None, &resp_init).unwrap() - } + ProxyResponseBody::Empty => web_sys::Response::new_with_opt_str_and_init(None, &resp_init) + .unwrap_or_else(|_| error_response(500, "Internal Server Error")), ProxyResponseBody::Bytes(bytes) => { let uint8 = js_sys::Uint8Array::from(bytes.as_ref()); web_sys::Response::new_with_opt_buffer_source_and_init(Some(&uint8), &resp_init) - .unwrap() + .unwrap_or_else(|_| error_response(500, "Internal Server Error")) } } } @@ -69,7 +68,7 @@ pub(crate) fn error_response(status: u16, message: &str) -> web_sys::Response { let init = web_sys::ResponseInit::new(); init.set_status(status); web_sys::Response::new_with_opt_str_and_init(Some(message), &init) - .unwrap_or_else(|_| web_sys::Response::new().unwrap()) + .unwrap_or_else(|_| web_sys::Response::new().expect("Response::new() cannot fail")) } /// Extension trait for converting a [`GatewayResponse`] into a `web_sys::Response`. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ed6117a..792e1b8 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -35,3 +35,6 @@ object_store.workspace = true reqwest.workspace = true futures.workspace = true matchit.workspace = true + +[lints] +workspace = true diff --git a/crates/core/src/api/request.rs b/crates/core/src/api/request.rs index a6ce3f9..5faba2b 100644 --- a/crates/core/src/api/request.rs +++ b/crates/core/src/api/request.rs @@ -273,12 +273,16 @@ pub fn validate_key(key: &str) -> Result<(), ProxyError> { Ok(()) } +/// How the bucket name was addressed in the incoming request. #[derive(Debug, Clone)] pub enum HostStyle { /// Path-style: `/{bucket}/{key}` Path, /// Virtual-hosted-style: bucket extracted from Host header. - VirtualHosted { bucket: String }, + VirtualHosted { + /// The bucket name taken from the `Host` header. + bucket: String, + }, } fn parse_path_style(path: &str) -> Result<(String, String), ProxyError> { diff --git a/crates/core/src/api/response.rs b/crates/core/src/api/response.rs index 56b7071..e86259d 100644 --- a/crates/core/src/api/response.rs +++ b/crates/core/src/api/response.rs @@ -10,12 +10,16 @@ pub use crate::types::BucketOwner; #[derive(Debug, Serialize)] #[serde(rename = "Error")] pub struct ErrorResponse { + /// S3 error code (e.g. `NoSuchKey`). #[serde(rename = "Code")] pub code: String, + /// Human-readable description of the error. #[serde(rename = "Message")] pub message: String, + /// The request path the error relates to. #[serde(rename = "Resource")] pub resource: String, + /// The proxy request identifier, for correlating with logs. #[serde(rename = "RequestId")] pub request_id: String, } @@ -69,10 +73,13 @@ impl ErrorResponse { #[derive(Debug, Serialize)] #[serde(rename = "InitiateMultipartUploadResult")] pub struct InitiateMultipartUploadResult { + /// The virtual bucket the upload targets. #[serde(rename = "Bucket")] pub bucket: String, + /// The object key being uploaded. #[serde(rename = "Key")] pub key: String, + /// Backend-issued identifier for the multipart upload. #[serde(rename = "UploadId")] pub upload_id: String, } @@ -91,12 +98,16 @@ impl InitiateMultipartUploadResult { #[derive(Debug, Serialize)] #[serde(rename = "CompleteMultipartUploadResult")] pub struct CompleteMultipartUploadResult { + /// URL of the completed object. #[serde(rename = "Location")] pub location: String, + /// The virtual bucket the object was written to. #[serde(rename = "Bucket")] pub bucket: String, + /// The object key. #[serde(rename = "Key")] pub key: String, + /// ETag of the assembled object. #[serde(rename = "ETag")] pub etag: String, } @@ -115,6 +126,7 @@ impl CompleteMultipartUploadResult { #[derive(Debug, serde::Deserialize)] #[serde(rename = "CompleteMultipartUpload")] pub struct CompleteMultipartUploadRequest { + /// The uploaded parts, in ascending part-number order. #[serde(rename = "Part")] pub parts: Vec, } @@ -134,8 +146,10 @@ pub struct CompletePart { #[derive(Debug, Serialize)] #[serde(rename = "ListAllMyBucketsResult")] pub struct ListAllMyBucketsResult { + /// The owner reported for every listed bucket. #[serde(rename = "Owner")] pub owner: BucketOwner, + /// The buckets visible to the caller. #[serde(rename = "Buckets")] pub buckets: BucketList, } @@ -143,6 +157,7 @@ pub struct ListAllMyBucketsResult { /// Wrapper for the `` element in a ListAllMyBucketsResult response. #[derive(Debug, Serialize)] pub struct BucketList { + /// One `` element per virtual bucket. #[serde(rename = "Bucket")] pub buckets: Vec, } diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index 6561acd..ad0f8c0 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -22,6 +22,10 @@ use crate::types::TemporaryCredentials; /// Implementations handle token decryption/lookup. The core proxy calls this /// during identity resolution without knowing the token format. pub trait TemporaryCredentialResolver: MaybeSend + MaybeSync { + /// Resolve a session token into its temporary credentials. + /// + /// Returns `Ok(None)` when the token is well-formed but unknown or + /// expired, and an error when it cannot be decoded at all. fn resolve(&self, token: &str) -> Result, ProxyError>; } diff --git a/crates/core/src/auth/tests.rs b/crates/core/src/auth/tests.rs index 5bc1982..bd16519 100644 --- a/crates/core/src/auth/tests.rs +++ b/crates/core/src/auth/tests.rs @@ -57,6 +57,7 @@ impl crate::registry::CredentialRegistry for MockConfig { // ── Test signing helper ─────────────────────────────────────────── /// Build a valid SigV4 Authorization header value for testing. +#[allow(clippy::too_many_arguments)] // test helper mirrors the SigV4 parameter list fn sign_request( method: &http::Method, uri_path: &str, diff --git a/crates/core/src/backend/mod.rs b/crates/core/src/backend/mod.rs index 99beeca..ceb9975 100644 --- a/crates/core/src/backend/mod.rs +++ b/crates/core/src/backend/mod.rs @@ -86,8 +86,11 @@ pub trait ProxyBackend: Clone + MaybeSend + MaybeSync + 'static { /// Response from a raw HTTP request to a backend. pub struct RawResponse { + /// HTTP status code from the backend. pub status: u16, + /// Response headers from the backend. pub headers: HeaderMap, + /// The fully buffered response body. pub body: Bytes, } @@ -113,9 +116,12 @@ pub struct ForwardResponse { /// connector), then call [`build`](Self::build) or /// [`build_signer`](Self::build_signer). pub enum StoreBuilder { + /// Amazon S3 or an S3-compatible store. S3(AmazonS3Builder), + /// Azure Blob Storage. #[cfg(feature = "azure")] Azure(MicrosoftAzureBuilder), + /// Google Cloud Storage. #[cfg(feature = "gcp")] Gcs(GoogleCloudStorageBuilder), } diff --git a/crates/core/src/maybe_send.rs b/crates/core/src/maybe_send.rs index 9aac22b..93d81e8 100644 --- a/crates/core/src/maybe_send.rs +++ b/crates/core/src/maybe_send.rs @@ -14,11 +14,13 @@ // --- Native targets: MaybeSend = Send, MaybeSync = Sync --- +/// `Send` on native targets, no bound on wasm32. #[cfg(not(target_arch = "wasm32"))] pub trait MaybeSend: Send {} #[cfg(not(target_arch = "wasm32"))] impl MaybeSend for T {} +/// `Sync` on native targets, no bound on wasm32. #[cfg(not(target_arch = "wasm32"))] pub trait MaybeSync: Sync {} #[cfg(not(target_arch = "wasm32"))] @@ -26,11 +28,13 @@ impl MaybeSync for T {} // --- WASM targets: MaybeSend and MaybeSync are no-ops --- +/// `Send` on native targets, no bound on wasm32. #[cfg(target_arch = "wasm32")] pub trait MaybeSend {} #[cfg(target_arch = "wasm32")] impl MaybeSend for T {} +/// `Sync` on native targets, no bound on wasm32. #[cfg(target_arch = "wasm32")] pub trait MaybeSync {} #[cfg(target_arch = "wasm32")] diff --git a/crates/core/src/router.rs b/crates/core/src/router.rs index 970b644..4f612db 100644 --- a/crates/core/src/router.rs +++ b/crates/core/src/router.rs @@ -31,6 +31,7 @@ pub struct Router { } impl Router { + /// Create an empty router with no registered routes. pub fn new() -> Self { Self { inner: matchit::Router::new(), @@ -40,7 +41,12 @@ impl Router { /// Register a handler for a path pattern. /// /// Supports matchit syntax: `/exact`, `/prefix/{param}`, `/{*catch_all}`. - /// Panics if the path conflicts with an already-registered route. + /// + /// # Panics + /// + /// Panics if `path` conflicts with an already-registered route. Routes are + /// registered at startup from static configuration, so a conflict is a + /// programming error rather than a runtime condition to recover from. pub fn route(mut self, path: &str, handler: impl RouteHandler + 'static) -> Self { self.inner .insert(path, Box::new(handler)) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 1c82624..7b897a1 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -8,8 +8,10 @@ use std::fmt; /// Owner identity for S3 ListBuckets responses. #[derive(Debug, Clone, Serialize)] pub struct BucketOwner { + /// Canonical owner identifier. #[serde(rename = "ID")] pub id: String, + /// Human-readable owner name. #[serde(rename = "DisplayName")] pub display_name: String, } @@ -188,6 +190,7 @@ pub struct AccessScope { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Action { + /// Read the current version of an object. GetObject, /// Reading a *specific* object version, as opposed to the current one. /// @@ -198,13 +201,21 @@ pub enum Action { /// action explicitly — a grant of `GetObject` alone denies version-scoped /// reads rather than silently permitting them. GetObjectVersion, + /// Read object metadata without the body. HeadObject, + /// Write an object, including server-side copy destinations. PutObject, + /// List keys in a bucket. ListBucket, + /// Start a multipart upload. CreateMultipartUpload, + /// Upload one part of a multipart upload. UploadPart, + /// Assemble a multipart upload into an object. CompleteMultipartUpload, + /// Discard an in-progress multipart upload. AbortMultipartUpload, + /// Delete an object, including via batch delete. DeleteObject, } @@ -339,22 +350,29 @@ impl fmt::Debug for BackendCredentials { /// used during signature verification. #[derive(Debug, Clone)] pub struct AuthenticatedIdentity { + /// Human-readable name of the credential owner or assumed role session. pub principal_name: String, + /// The buckets, prefixes, and actions this identity may access. pub allowed_scopes: Vec, } /// Represents the resolved identity after authentication. #[derive(Debug, Clone)] pub enum ResolvedIdentity { + /// No credentials were presented; only buckets with anonymous access apply. Anonymous, + /// Credentials were verified successfully. Authenticated(AuthenticatedIdentity), } /// The parsed S3 operation extracted from an incoming request. #[derive(Debug, Clone)] pub enum S3Operation { + /// `GET /{bucket}/{key}`. GetObject { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, /// The object version this read will actually return, or `None` for the /// current version. @@ -375,36 +393,61 @@ pub enum S3Operation { /// otherwise unreachable through the proxy. version: Option, }, + /// `HEAD /{bucket}/{key}`. HeadObject { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, }, + /// `PUT /{bucket}/{key}` without `x-amz-copy-source`. PutObject { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, }, + /// `POST /{bucket}/{key}?uploads`. CreateMultipartUpload { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, }, + /// `PUT /{bucket}/{key}?partNumber=N&uploadId=ID`. UploadPart { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, + /// Backend-issued multipart upload identifier. upload_id: String, + /// 1-based part number. part_number: u32, }, + /// `POST /{bucket}/{key}?uploadId=ID` with a part list in the body. CompleteMultipartUpload { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, + /// Backend-issued multipart upload identifier. upload_id: String, }, + /// `DELETE /{bucket}/{key}?uploadId=ID`. AbortMultipartUpload { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, + /// Backend-issued multipart upload identifier. upload_id: String, }, + /// `DELETE /{bucket}/{key}`. DeleteObject { + /// Virtual bucket name. bucket: String, + /// Object key, percent-decoded. key: String, }, /// Server-side copy (`PUT /{bucket}/{key}` carrying `x-amz-copy-source`). @@ -430,9 +473,12 @@ pub enum S3Operation { /// request body, so this operation carries only the bucket — the body is /// parsed and each key authorized individually once it arrives. DeleteObjects { + /// Virtual bucket name. bucket: String, }, + /// `GET /{bucket}` (ListObjects / ListObjectsV2). ListBucket { + /// Virtual bucket name. bucket: String, /// Raw query string from the incoming request, forwarded to the backend. /// The proxy may modify `prefix` (prepend backend_prefix) and inject diff --git a/crates/core/tests/conditional_writes.rs b/crates/core/tests/conditional_writes.rs index b02fba3..d59c076 100644 --- a/crates/core/tests/conditional_writes.rs +++ b/crates/core/tests/conditional_writes.rs @@ -10,6 +10,10 @@ //! 2. The backend's `412 Precondition Failed` is surfaced to the client //! unchanged. +// Integration tests: `allow-unwrap-in-tests` only covers `#[test]` fns and +// `#[cfg(test)]` modules, not helper fns in a test crate. +#![allow(clippy::unwrap_used)] + use bytes::Bytes; use http::{HeaderMap, Method}; use std::collections::HashMap; diff --git a/crates/core/tests/key_encoding_contract.rs b/crates/core/tests/key_encoding_contract.rs index e9779d9..8adc08d 100644 --- a/crates/core/tests/key_encoding_contract.rs +++ b/crates/core/tests/key_encoding_contract.rs @@ -12,6 +12,10 @@ //! If an object_store upgrade changes its path encoding, these tests are //! the loud alarm. +// Integration tests: `allow-unwrap-in-tests` only covers `#[test]` fns and +// `#[cfg(test)]` modules, not helper fns in a test crate. +#![allow(clippy::unwrap_used)] + use std::collections::HashMap; use std::time::Duration; diff --git a/crates/metering/Cargo.toml b/crates/metering/Cargo.toml index e6e6834..6eeb0db 100644 --- a/crates/metering/Cargo.toml +++ b/crates/metering/Cargo.toml @@ -14,3 +14,6 @@ tracing.workspace = true [dev-dependencies] futures.workspace = true tokio = { workspace = true, features = ["rt", "macros"] } + +[lints] +workspace = true diff --git a/crates/oidc-provider/Cargo.toml b/crates/oidc-provider/Cargo.toml index 3eeec9f..683bb0a 100644 --- a/crates/oidc-provider/Cargo.toml +++ b/crates/oidc-provider/Cargo.toml @@ -26,3 +26,6 @@ uuid.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros"] } rand = "0.8" + +[lints] +workspace = true diff --git a/crates/oidc-provider/src/backend_auth.rs b/crates/oidc-provider/src/backend_auth.rs index 387a1d7..a4d52c0 100644 --- a/crates/oidc-provider/src/backend_auth.rs +++ b/crates/oidc-provider/src/backend_auth.rs @@ -97,14 +97,14 @@ impl Middleware for AwsBackendAuth { mut ctx: DispatchContext<'a>, next: Next<'a>, ) -> Result { - if let Some(ref bucket_config) = ctx.bucket_config { + if let Some(bucket_config) = ctx.bucket_config.take() { if bucket_config.option("auth_type") == Some("oidc") { match bucket_config.backend_type.as_str() { "s3" => { - let options = self.resolve_aws(bucket_config).await?; + let options = self.resolve_aws(&bucket_config).await?; ctx.bucket_config = Some(Cow::Owned(BucketConfig { backend_options: options, - ..ctx.bucket_config.unwrap().into_owned() + ..bucket_config.into_owned() })); } other => { @@ -113,6 +113,8 @@ impl Middleware for AwsBackendAuth { ))); } } + } else { + ctx.bucket_config = Some(bucket_config); } } next.run(ctx).await diff --git a/crates/oidc-provider/src/lib.rs b/crates/oidc-provider/src/lib.rs index d531f6f..bc19883 100644 --- a/crates/oidc-provider/src/lib.rs +++ b/crates/oidc-provider/src/lib.rs @@ -126,12 +126,15 @@ impl OidcCredentialProvider { /// Errors produced by this crate. #[derive(Debug, thiserror::Error)] pub enum OidcProviderError { + /// The RSA private key could not be parsed or used. #[error("RSA key error: {0}")] KeyError(String), + /// Signing a JWT with the configured key failed. #[error("JWT signing error: {0}")] SigningError(String), + /// The backend cloud did not return usable credentials for the minted JWT. #[error("credential exchange failed: {0}")] ExchangeError(String), @@ -147,6 +150,7 @@ pub enum OidcProviderError { message: String, }, + /// The HTTP call to the backend cloud's token endpoint failed. #[error("HTTP error: {0}")] HttpError(String), } diff --git a/crates/path-mapping/Cargo.toml b/crates/path-mapping/Cargo.toml index 165e10f..4f52888 100644 --- a/crates/path-mapping/Cargo.toml +++ b/crates/path-mapping/Cargo.toml @@ -9,3 +9,6 @@ description = "Hierarchical path mapping for the multistore S3 proxy gateway" multistore = { workspace = true } percent-encoding = { workspace = true } tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/path-mapping/tests/mapping_tests.rs b/crates/path-mapping/tests/mapping_tests.rs index 68b4c46..01b1ae3 100644 --- a/crates/path-mapping/tests/mapping_tests.rs +++ b/crates/path-mapping/tests/mapping_tests.rs @@ -1,3 +1,9 @@ +//! Integration tests for `PathMapping` rewrite rules. + +// Integration tests: `allow-unwrap-in-tests` only covers `#[test]` fns and +// `#[cfg(test)]` modules, not helper fns in a test crate. +#![allow(clippy::unwrap_used)] + use multistore_path_mapping::PathMapping; fn default_mapping() -> PathMapping { diff --git a/crates/static-config/Cargo.toml b/crates/static-config/Cargo.toml index 52a36bc..a2b74af 100644 --- a/crates/static-config/Cargo.toml +++ b/crates/static-config/Cargo.toml @@ -13,3 +13,6 @@ serde_json.workspace = true toml.workspace = true tracing.workspace = true chrono.workspace = true + +[lints] +workspace = true diff --git a/crates/sts/Cargo.toml b/crates/sts/Cargo.toml index 061b97b..cb21100 100644 --- a/crates/sts/Cargo.toml +++ b/crates/sts/Cargo.toml @@ -23,3 +23,6 @@ url.workspace = true [dev-dependencies] http.workspace = true tokio = { workspace = true, features = ["rt", "macros"] } + +[lints] +workspace = true diff --git a/crates/sts/src/jwks.rs b/crates/sts/src/jwks.rs index 0bce3c7..1cda7ea 100644 --- a/crates/sts/src/jwks.rs +++ b/crates/sts/src/jwks.rs @@ -18,6 +18,7 @@ use sha2::Sha256; /// A JSON Web Key Set (JWKS) containing one or more public keys. #[derive(Debug, Clone, Deserialize)] pub struct JwksResponse { + /// The keys published by the issuer. pub keys: Vec, } @@ -238,6 +239,10 @@ pub fn verify_token( /// (`Instant` panics on `wasm32-unknown-unknown`). type JwksEntries = Arc, JwksResponse)>>>; +/// TTL cache of issuer JWKS documents with negative caching of fetch failures. +/// +/// Successful fetches are reused for `ttl`; a failed fetch is remembered for a +/// short back-off window so a flapping issuer is not hammered on every request. #[derive(Clone)] pub struct JwksCache { client: reqwest::Client, @@ -247,6 +252,13 @@ pub struct JwksCache { failures: Arc>>>, } +/// Lock a cache mutex, recovering the guard if a previous holder panicked. +/// +/// The maps only hold cached data, so a poisoned lock is safe to keep using. +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + impl JwksCache { /// Create a new cache with the given TTL and HTTP client. /// @@ -272,7 +284,7 @@ impl JwksCache { // Check if we recently failed for this issuer { - let failures = self.failures.lock().unwrap(); + let failures = lock(&self.failures); if let Some(failed_at) = failures.get(issuer) { let elapsed = Utc::now().signed_duration_since(*failed_at).num_seconds(); if elapsed >= 0 && (elapsed as u64) < self.failure_ttl.as_secs() { @@ -287,26 +299,23 @@ impl JwksCache { // Cache miss — fetch from the network match fetch_jwks(&self.client, issuer).await { Ok(jwks) => { - let mut entries = self.entries.lock().unwrap(); + let mut entries = lock(&self.entries); entries.insert(issuer.to_string(), (Utc::now(), jwks.clone())); // Clear any failure state on success drop(entries); - self.failures.lock().unwrap().remove(issuer); + lock(&self.failures).remove(issuer); Ok(jwks) } Err(e) => { tracing::warn!(issuer = %issuer, error = %e, "JWKS fetch failed, backing off"); - self.failures - .lock() - .unwrap() - .insert(issuer.to_string(), Utc::now()); + lock(&self.failures).insert(issuer.to_string(), Utc::now()); Err(e) } } } fn get_cached(&self, issuer: &str) -> Option { - let entries = self.entries.lock().unwrap(); + let entries = lock(&self.entries); if let Some((fetched_at, jwks)) = entries.get(issuer) { let elapsed = Utc::now().signed_duration_since(*fetched_at).num_seconds(); if elapsed >= 0 && (elapsed as u64) < self.ttl.as_secs() { diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index d18c205..02f7625 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -220,7 +220,8 @@ fn subject_matches(subject: &str, pattern: &str) -> bool { } // Last part must be a suffix - let last = parts.last().unwrap(); + // `split` always yields at least one element. + let last = parts.last().copied().unwrap_or(""); if !last.is_empty() { return remaining.ends_with(last); } diff --git a/crates/sts/src/responses.rs b/crates/sts/src/responses.rs index afe35c4..4bce2c0 100644 --- a/crates/sts/src/responses.rs +++ b/crates/sts/src/responses.rs @@ -9,6 +9,7 @@ use serde::Serialize; #[derive(Debug, Serialize)] #[serde(rename = "AssumeRoleWithWebIdentityResponse")] pub struct AssumeRoleWithWebIdentityResponse { + /// The result element wrapping credentials and the assumed-role identity. #[serde(rename = "AssumeRoleWithWebIdentityResult")] pub result: AssumeRoleWithWebIdentityResult, } @@ -16,8 +17,10 @@ pub struct AssumeRoleWithWebIdentityResponse { /// The result payload nested inside an `AssumeRoleWithWebIdentityResponse`. #[derive(Debug, Serialize)] pub struct AssumeRoleWithWebIdentityResult { + /// The temporary credentials issued for this session. #[serde(rename = "Credentials")] pub credentials: StsCredentials, + /// The role and session the credentials belong to. #[serde(rename = "AssumedRoleUser")] pub assumed_role_user: AssumedRoleUser, } @@ -25,12 +28,16 @@ pub struct AssumeRoleWithWebIdentityResult { /// Temporary AWS credentials returned by an STS assume-role call. #[derive(Debug, Serialize)] pub struct StsCredentials { + /// Temporary access key ID. #[serde(rename = "AccessKeyId")] pub access_key_id: String, + /// Temporary secret access key. #[serde(rename = "SecretAccessKey")] pub secret_access_key: String, + /// Session token that must accompany requests signed with these credentials. #[serde(rename = "SessionToken")] pub session_token: String, + /// Expiration time as an RFC 3339 timestamp. #[serde(rename = "Expiration")] pub expiration: String, } @@ -38,8 +45,10 @@ pub struct StsCredentials { /// Identity information for the assumed role session. #[derive(Debug, Serialize)] pub struct AssumedRoleUser { + /// `{role_id}:{session_name}`, mirroring AWS's format. #[serde(rename = "AssumedRoleId")] pub assumed_role_id: String, + /// ARN of the assumed-role session. #[serde(rename = "Arn")] pub arn: String, } diff --git a/examples/cf-workers/Cargo.toml b/examples/cf-workers/Cargo.toml index 656ba0e..a828193 100644 --- a/examples/cf-workers/Cargo.toml +++ b/examples/cf-workers/Cargo.toml @@ -43,3 +43,6 @@ features = ["wasm_js"] package = "getrandom" version = "0.4" features = ["wasm_js"] + +[lints] +workspace = true diff --git a/examples/cf-workers/src/bandwidth.rs b/examples/cf-workers/src/bandwidth.rs index ab2e368..444d673 100644 --- a/examples/cf-workers/src/bandwidth.rs +++ b/examples/cf-workers/src/bandwidth.rs @@ -15,6 +15,10 @@ //! Entries are stored as a single `"entries"` key in DO storage, serialized as //! `Vec`. An alarm is set to prune expired entries after the window elapses. +// `#[durable_object]` expands to undocumented wasm-bindgen glue that a +// struct-level `#[allow]` does not reach. +#![allow(missing_docs)] + use serde::{Deserialize, Serialize}; use std::cell::RefCell; use worker::*; diff --git a/examples/lambda/Cargo.toml b/examples/lambda/Cargo.toml index 586217a..955f470 100644 --- a/examples/lambda/Cargo.toml +++ b/examples/lambda/Cargo.toml @@ -21,3 +21,6 @@ serde.workspace = true toml.workspace = true reqwest = { workspace = true, features = ["stream", "form"] } object_store.workspace = true + +[lints] +workspace = true diff --git a/examples/lambda/src/main.rs b/examples/lambda/src/main.rs index fb01971..030d3ac 100644 --- a/examples/lambda/src/main.rs +++ b/examples/lambda/src/main.rs @@ -153,7 +153,9 @@ async fn request_handler(req: Request) -> Result, Error> { for (k, v) in resp.headers.iter() { builder = builder.header(k, v); } - builder.body(resp.body).unwrap() + builder + .body(resp.body) + .expect("backend status and headers are already valid HTTP") } }, ) @@ -171,5 +173,7 @@ fn build_lambda_response(result: ProxyResult) -> Response { builder = builder.header(key, value); } - builder.body(body).unwrap() + builder + .body(body) + .expect("gateway status and headers are already valid HTTP") } diff --git a/examples/server/Cargo.toml b/examples/server/Cargo.toml index 2abe48b..1182727 100644 --- a/examples/server/Cargo.toml +++ b/examples/server/Cargo.toml @@ -23,3 +23,6 @@ reqwest = { workspace = true, features = ["stream", "form"] } object_store.workspace = true futures.workspace = true http-body-util.workspace = true + +[lints] +workspace = true diff --git a/examples/server/src/axum_helpers.rs b/examples/server/src/axum_helpers.rs index 68b191d..37d5cc4 100644 --- a/examples/server/src/axum_helpers.rs +++ b/examples/server/src/axum_helpers.rs @@ -17,5 +17,7 @@ pub fn build_proxy_response(result: ProxyResult) -> Response { builder = builder.header(key, value); } - builder.body(body).unwrap() + builder + .body(body) + .expect("gateway status and headers are already valid HTTP") } diff --git a/examples/server/src/client.rs b/examples/server/src/client.rs index 5419f40..315b1cd 100644 --- a/examples/server/src/client.rs +++ b/examples/server/src/client.rs @@ -24,6 +24,7 @@ pub struct ServerBackend { } impl ServerBackend { + /// Create a backend with a pooled `reqwest` client. pub fn new() -> Self { Self { client: reqwest::Client::builder() @@ -148,6 +149,7 @@ pub struct ReqwestHttpExchange { } impl ReqwestHttpExchange { + /// Wrap an existing `reqwest` client. pub fn new(client: reqwest::Client) -> Self { Self { client } } diff --git a/examples/server/src/server.rs b/examples/server/src/server.rs index cb08780..c4cede3 100644 --- a/examples/server/src/server.rs +++ b/examples/server/src/server.rs @@ -25,6 +25,7 @@ use tokio::net::TcpListener; /// Server configuration. pub struct ServerConfig { + /// Address and port to bind the HTTP listener to. pub listen_addr: SocketAddr, /// The base domain for virtual-hosted-style requests (e.g., "s3.example.com"). /// If set, requests to `{bucket}.s3.example.com` use virtual-hosted style. @@ -178,7 +179,7 @@ async fn request_handler( return Response::builder() .status(400) .body(Body::from("form body too large or unreadable")) - .unwrap(); + .expect("static 400 response is valid"); } } } else { @@ -207,7 +208,9 @@ async fn request_handler( builder = builder.header(k, v); } - builder.body(body).unwrap() + builder + .body(body) + .expect("backend status and headers are already valid HTTP") } } }