Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2596b357ec | ||
|
|
2c9367a83c | ||
|
|
8ff33817a8 | ||
|
|
b6f138b90d | ||
|
|
4027d3eb17 | ||
|
|
5f1e3ace5c | ||
|
|
0f2b776bb8 | ||
|
|
5717f8aaaa | ||
|
|
52d9e1e93e | ||
|
|
19941156b4 | ||
|
|
b2d3f32d83 | ||
|
|
4d851c5a58 | ||
|
|
db4f277336 |
No files matched your search
+28
-28
@@ -60,33 +60,33 @@ jobs:
|
|||||||
flags: "-p mytheclipse --no-default-features --features lifecycle"
|
flags: "-p mytheclipse --no-default-features --features lifecycle"
|
||||||
- name: mytheclipse / observability only
|
- name: mytheclipse / observability only
|
||||||
flags: "-p mytheclipse --no-default-features --features observability"
|
flags: "-p mytheclipse --no-default-features --features observability"
|
||||||
# corex-cache
|
# mytheclipse-cache
|
||||||
- name: corex-cache / default
|
- name: mytheclipse-cache / default
|
||||||
flags: "-p corex-cache"
|
flags: "-p mytheclipse-cache"
|
||||||
- name: corex-cache / l1-moka
|
- name: mytheclipse-cache / l1-moka
|
||||||
flags: "-p corex-cache --no-default-features --features l1-moka"
|
flags: "-p mytheclipse-cache --no-default-features --features l1-moka"
|
||||||
- name: corex-cache / l2-redis
|
- name: mytheclipse-cache / l2-redis
|
||||||
flags: "-p corex-cache --no-default-features --features l1-memory,l2-redis"
|
flags: "-p mytheclipse-cache --no-default-features --features l1-memory,l2-redis"
|
||||||
# corex-storage
|
# mytheclipse-storage
|
||||||
- name: corex-storage / default (local)
|
- name: mytheclipse-storage / default (local)
|
||||||
flags: "-p corex-storage"
|
flags: "-p mytheclipse-storage"
|
||||||
- name: corex-storage / s3
|
- name: mytheclipse-storage / s3
|
||||||
flags: "-p corex-storage --no-default-features --features s3"
|
flags: "-p mytheclipse-storage --no-default-features --features s3"
|
||||||
- name: corex-storage / gcs
|
- name: mytheclipse-storage / gcs
|
||||||
flags: "-p corex-storage --no-default-features --features gcs"
|
flags: "-p mytheclipse-storage --no-default-features --features gcs"
|
||||||
# corex-event
|
# mytheclipse-event
|
||||||
- name: corex-event / default (mem)
|
- name: mytheclipse-event / default (mem)
|
||||||
flags: "-p corex-event"
|
flags: "-p mytheclipse-event"
|
||||||
- name: corex-event / amqp
|
- name: mytheclipse-event / amqp
|
||||||
flags: "-p corex-event --no-default-features --features amqp"
|
flags: "-p mytheclipse-event --no-default-features --features amqp"
|
||||||
- name: corex-event / nats
|
- name: mytheclipse-event / nats
|
||||||
flags: "-p corex-event --no-default-features --features nats"
|
flags: "-p mytheclipse-event --no-default-features --features nats"
|
||||||
# corex-config
|
# mytheclipse-config
|
||||||
- name: corex-config / default
|
- name: mytheclipse-config / default
|
||||||
flags: "-p corex-config"
|
flags: "-p mytheclipse-config"
|
||||||
# corex-crypto
|
# mytheclipse-crypto
|
||||||
- name: corex-crypto / default
|
- name: mytheclipse-crypto / default
|
||||||
flags: "-p corex-crypto"
|
flags: "-p mytheclipse-crypto"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
crate:
|
crate:
|
||||||
[mytheclipse, corex-cache, corex-storage, corex-event, corex-config, corex-crypto]
|
[mytheclipse, mytheclipse-cache, mytheclipse-storage, mytheclipse-event, mytheclipse-config, mytheclipse-crypto]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
# between publishes avoids hitting crates.io's rate limit.
|
# between publishes avoids hitting crates.io's rate limit.
|
||||||
- name: Publish workspace crates
|
- name: Publish workspace crates
|
||||||
run: |
|
run: |
|
||||||
for crate in mytheclipse corex-cache corex-storage corex-event corex-config corex-crypto; do
|
for crate in mytheclipse mytheclipse-cache mytheclipse-storage mytheclipse-event mytheclipse-config mytheclipse-crypto; do
|
||||||
echo "Publishing $crate..."
|
echo "Publishing $crate..."
|
||||||
cargo publish -p "$crate" --allow-dirty --no-verify
|
cargo publish -p "$crate" --allow-dirty --no-verify
|
||||||
sleep 15
|
sleep 15
|
||||||
|
|||||||
@@ -1,3 +1,45 @@
|
|||||||
|
## [1.3.5](https://github.com/asepharyana/mytheclipse/compare/v1.3.4...v1.3.5) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **cache:** honor sub-second Redis TTL via PSETEX + document clear() safety ([2c9367a](https://github.com/asepharyana/mytheclipse/commit/2c9367a83c2dd01b1e197ec33215a6c7d3755fa2))
|
||||||
|
|
||||||
|
## [1.3.4](https://github.com/asepharyana/mytheclipse/compare/v1.3.3...v1.3.4) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **cache,storage:** harden cache bounds + atomic disk writes ([b6f138b](https://github.com/asepharyana/mytheclipse/commit/b6f138b90d67c9531b5a58993e8ec750e5eec57f))
|
||||||
|
|
||||||
|
## [1.3.3](https://github.com/asepharyana/mytheclipse/compare/v1.3.2...v1.3.3) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **publish:** trim mytheclipse keywords to 5 to satisfy crates.io limit ([5f1e3ac](https://github.com/asepharyana/mytheclipse/commit/5f1e3ace5c8cb10881e30f550f51b7d845b24edd))
|
||||||
|
|
||||||
|
## [1.3.2](https://github.com/asepharyana/mytheclipse/compare/v1.3.1...v1.3.2) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** gate cache & storage crate doctests behind their features ([5717f8a](https://github.com/asepharyana/mytheclipse/commit/5717f8aaaae34cb66cdbfc31f4982c93816ee5a3))
|
||||||
|
|
||||||
|
## [1.3.1](https://github.com/asepharyana/mytheclipse/compare/v1.3.0...v1.3.1) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** gate event crate doctest behind mem feature and apply rustfmt ([1994115](https://github.com/asepharyana/mytheclipse/commit/19941156b43ec58370d0d1369174ec84400e9bc9))
|
||||||
|
|
||||||
|
# [1.3.0](https://github.com/asepharyana/mytheclipse/compare/v1.2.0...v1.3.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add corex-storage crate for unified storage abstraction ([db4f277](https://github.com/asepharyana/mytheclipse/commit/db4f277336d1e32cb6a2ddd86ac37ae9789fa4f8))
|
||||||
|
|
||||||
# [1.2.0](https://github.com/asepharyana/mytheclipse/compare/v1.1.0...v1.2.0) (2026-08-28)
|
# [1.2.0](https://github.com/asepharyana/mytheclipse/compare/v1.1.0...v1.2.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+4471
-15
File diff suppressed because it is too large.
Load diff
+10
-45
@@ -1,45 +1,10 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "mytheclipse"
|
members = [
|
||||||
version = "1.1.0"
|
"crates/mytheclipse",
|
||||||
edition = "2021"
|
"crates/mytheclipse-cache",
|
||||||
rust-version = "1.75"
|
"crates/mytheclipse-storage",
|
||||||
license = "MIT OR Apache-2.0"
|
"crates/mytheclipse-event",
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
"crates/mytheclipse-config",
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
"crates/mytheclipse-crypto",
|
||||||
documentation = "https://docs.rs/mytheclipse"
|
]
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
resolver = "2"
|
||||||
description = "Resource-aware abstractions for async I/O, heavy compute, background queue management, resiliency, traffic control, lifecycle, and observability."
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = ["async", "concurrency", "rayon", "tokio", "resource-management", "resiliency", "retry", "circuit-breaker", "rate-limit", "observability", "cron", "shutdown"]
|
|
||||||
categories = ["asynchronous", "concurrency", "rust-patterns"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { version = "1.53", features = ["full"], optional = true }
|
|
||||||
rayon = { version = "1.12", optional = true }
|
|
||||||
rand = { version = "0.8", optional = true }
|
|
||||||
num_cpus = "1.17"
|
|
||||||
tracing = "0.1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1.53", features = ["full"] }
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
io = ["dep:tokio"]
|
|
||||||
compute = ["dep:rayon"]
|
|
||||||
bg = ["dep:tokio"]
|
|
||||||
resiliency = ["dep:tokio", "dep:rand"]
|
|
||||||
traffic = ["dep:tokio"]
|
|
||||||
lifecycle = ["dep:tokio"]
|
|
||||||
observability = ["dep:tokio"]
|
|
||||||
full = ["io", "compute", "bg", "resiliency", "traffic", "lifecycle", "observability"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "main"
|
|
||||||
path = "examples/main.rs"
|
|
||||||
required-features = ["full"]
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
rustdoc-args = ["--cfg", "docsrs"]
|
|
||||||
@@ -12,11 +12,11 @@ concern.
|
|||||||
| Crate | Description | Docs |
|
| Crate | Description | Docs |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| [`mytheclipse`](crates/mytheclipse) | Resource-aware execution primitives (async I/O, compute, background queues), resiliency (retry, circuit breaker, timeout), traffic control (rate limiter, backpressure, concurrency limiter), lifecycle (graceful shutdown, cron), and observability (metrics, panic tracking). | [README](crates/mytheclipse/README.md) |
|
| [`mytheclipse`](crates/mytheclipse) | Resource-aware execution primitives (async I/O, compute, background queues), resiliency (retry, circuit breaker, timeout), traffic control (rate limiter, backpressure, concurrency limiter), lifecycle (graceful shutdown, cron), and observability (metrics, panic tracking). | [README](crates/mytheclipse/README.md) |
|
||||||
| [`corex-cache`](crates/corex-cache) | Unified multi-layer (L1/L2) cache abstraction: in-memory or Moka L1, Redis/Valkey L2, cache-aside read-through. | [README](crates/corex-cache/README.md) |
|
| [`mytheclipse-cache`](crates/mytheclipse-cache) | Unified multi-layer (L1/L2) cache abstraction: in-memory or Moka L1, Redis/Valkey L2, cache-aside read-through. | [README](crates/mytheclipse-cache/README.md) |
|
||||||
| [`corex-storage`](crates/corex-storage) | Unified storage & file system abstraction: one driver interface over local disk, S3/MinIO, and Google Cloud Storage, stream-based. | [README](crates/corex-storage/README.md) |
|
| [`mytheclipse-storage`](crates/mytheclipse-storage) | Unified storage & file system abstraction: one driver interface over local disk, S3/MinIO, and Google Cloud Storage, stream-based. | [README](crates/mytheclipse-storage/README.md) |
|
||||||
| [`corex-event`](crates/corex-event) | Unified events & message bus abstraction: in-memory pub/sub dispatcher plus RabbitMQ and NATS broker adapters behind one trait. | [README](crates/corex-event/README.md) |
|
| [`mytheclipse-event`](crates/mytheclipse-event) | Unified events & message bus abstraction: in-memory pub/sub dispatcher plus RabbitMQ and NATS broker adapters behind one trait. | [README](crates/mytheclipse-event/README.md) |
|
||||||
| [`corex-config`](crates/corex-config) | Type-safe, dynamic configuration engine: load `.env`/YAML/JSON/TOML into typed structs, with hot-reload. | [README](crates/corex-config/README.md) |
|
| [`mytheclipse-config`](crates/mytheclipse-config) | Type-safe, dynamic configuration engine: load `.env`/YAML/JSON/TOML into typed structs, with hot-reload. | [README](crates/mytheclipse-config/README.md) |
|
||||||
| [`corex-crypto`](crates/corex-crypto) | Safe hashing (Argon2id), encryption (AES-256-GCM), and JWT tokens, with key rotation support. | [README](crates/corex-crypto/README.md) |
|
| [`mytheclipse-crypto`](crates/mytheclipse-crypto) | Safe hashing (Argon2id), encryption (AES-256-GCM), and JWT tokens, with key rotation support. | [README](crates/mytheclipse-crypto/README.md) |
|
||||||
|
|
||||||
Every crate follows the same philosophy: **one small interface, pluggable
|
Every crate follows the same philosophy: **one small interface, pluggable
|
||||||
backends behind feature flags, and a working default that needs no external
|
backends behind feature flags, and a working default that needs no external
|
||||||
@@ -31,11 +31,11 @@ Each crate is published independently; add the ones you need:
|
|||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mytheclipse = { version = "1", features = ["full"] }
|
mytheclipse = { version = "1", features = ["full"] }
|
||||||
corex-cache = "0.1"
|
mytheclipse-cache = "0.1"
|
||||||
corex-storage = { version = "0.1", features = ["s3"] }
|
mytheclipse-storage = { version = "0.1", features = ["s3"] }
|
||||||
corex-event = { version = "0.1", features = ["nats"] }
|
mytheclipse-event = { version = "0.1", features = ["nats"] }
|
||||||
corex-config = "0.1"
|
mytheclipse-config = "0.1"
|
||||||
corex-crypto = "0.1"
|
mytheclipse-crypto = "0.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
See each crate's own README (linked above) for usage examples and the full
|
See each crate's own README (linked above) for usage examples and the full
|
||||||
@@ -52,7 +52,7 @@ cargo clippy --workspace --all-features -- -D warnings
|
|||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Or target a single crate with `-p <name>`, e.g. `cargo test -p corex-cache`.
|
Or target a single crate with `-p <name>`, e.g. `cargo test -p mytheclipse-cache`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Audit Report — `mytheclipse-cache`
|
||||||
|
|
||||||
|
**Scope:** `crates/mytheclipse-cache` (v1.3.3) — `lib.rs`, `traits.rs`, `memory.rs`, `moka_cache.rs`, `multilayer.rs`, `redis.rs`, `cache_aside.rs`, `Cargo.toml`.
|
||||||
|
**Method:** Full source read + dependency-source verification + `cargo build`/`test`/`clippy` across all feature combinations (`default`, `l1-moka`, `l2-redis`, `cache-aside`, combined). Findings cross-referenced against `redis` 0.27.6 and `moka` 0.12.16 source trees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
The crate is **functionally correct and passes all tests + clippy**. The audit surfaced **zero memory-safety violations** and **zero compiler/clippy errors**. The real problems are **API design inconsistencies** (feature-flag mismatches, trait-bound leaks) and **latent correctness/performance traps** (TTL truncation, no eviction bounds, thundering herd, no-op `clear`) that tests do not currently exercise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 — Critical / Correctness-Impacting
|
||||||
|
|
||||||
|
| # | Severity | File:Line | Finding |
|
||||||
|
|---|----------|-----------|---------|
|
||||||
|
| T1.1 | **Bug** | `redis.rs:72` | **TTL truncation loses sub-second precision.** `ttl.as_secs().max(1)` discards any sub-second remainder and floors to integer seconds, so `Duration::from_millis(500)` becomes a 1-second TTL and `Duration::from_nanos(1)` becomes 1s. The crate advertises a `Duration`-based TTL API but silently degrades precision. |
|
||||||
|
| T1.2 | **Bug** | `redis.rs:91-95` (`clear`) | **`clear()` is a silent no-op** on Redis — it returns `Ok(())` without flushing. Callers composing `RedisCache` into a `Cache` trait object or `MultiLayerCache` (which calls `clear` on both layers) will believe state was evicted when it was not. |
|
||||||
|
| T1.3 | **Soundness risk** | `memory.rs:32` / `memory.rs:40` / `memory.rs:60-61` | **`Mutex::lock().unwrap()` will panic the runtime on poisoning.** Every `MemoryCache` operation unwraps the lock. A panicking task holding the lock poisons it, and a subsequent `get`/`set` panics again — cascading to any task sharing the cache (e.g. a `MultiLayerCache` L1). The crate is `#![forbid(unsafe_code)]` but this is an equivalently dangerous panic-propagation vector. |
|
||||||
|
| T1.4 | **API contract violation** | `moka_cache.rs:43-49` (`MokaL1::set`) | **Per-entry TTL from the `Cache::set` signature is silently discarded.** The `_ttl` parameter is ignored with only a comment. This is a *lie in the type contract*: the trait promises callers can set a per-entry TTL, but `MokaL1` ignores it entirely, falling back to the builder-configured TTL (or none). Any layered composition (`CacheAside<MultiLayerCache<_,MokaL1>>`) that passes a runtime TTL will silently get the wroLine truncated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 — High / Design & Reliability
|
||||||
|
|
||||||
|
| # | Severity | File:Line | Finding |
|
||||||
|
|---|----------|-----------|---------|
|
||||||
|
| T2.1 | **Race condition** | `cache_aside.rs:48-58` | **Thundering herd on cache miss.** A miss triggers `fetcher` + `set` with no admission gate. Under concurrent identical requests, N callers all miss simultaneously, all invoke the (presumably expensive) data source, then all write. Classic cache-stampede. No `once_cell`/future-per-key coalescing exists. |
|
||||||
|
| T2.2 | **API inconsistency** | `lib.rs:54-61`, `Cargo.toml` | **Feature flag mismatch: `multilayer` vs `cache-aside`.** `MultiLayerCache` is gated on `#[cfg(feature = "cache-aside")]` (`lib.rs:57-61`) even though it is conceptually independent of cache-aside. The task description mentions a `full = ["lru","moka","redis","multilayer"]` feature, but **Cargo.toml defines no `full` / `multilayer` feature at all**. The README's feature list and the task spec disagree with `Cargo.toml`. |
|
||||||
|
| T2.3 | **Latent panic** | `memory.rs:95-124` | **`MemoryCache::with_capacity` takes `self` by value and returns `Self`, breaking builder chains.** A caller writing `MemoryCache::new().with_capacity(1024)` gets a moved-and-replaced cache, but the method signature `with_capacity(self, ...) -> Self` makes it easy to misuse as `&mut self`. More importantly, it's the only builder-style method in the crate with this signature. |
|
||||||
|
| T2.4 | **Performance** | `memory.rs:46`, `multilayer.rs:71` | **`Vec<u8>` clones on hot path.** `get` returns `value.clone()` even for a read. For large cached payloads this is O(n) per read. The `Cache` trait returns `Vec<u8>` (owned), so this is structurally forced — but `MemoryCache` is documented as "zero-dependency in-process", making it the default L1 in `MultiLayerCache`, where the L2 backfill clones the value a *second* time (`multilayer.rs:71` `value.clone()`). |
|
||||||
|
| T2.5 | **Performance** | `redis.rs:54-60` (`get`) | **Per-operation connection clone.** Each `get`/`set`/etc. calls `self.conn.clone()`. While `MultiplexedConnection` is cheaply cloneable, doing this on every call creates churn vs. reusing a local handle. Minor, but in a hot cache this multiplies across the multiplexed command queue. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 — Medium / API Design & Ergonomics
|
||||||
|
|
||||||
|
| # | Severity | File:Line | Finding |
|
||||||
|
|---|----------|-----------|---------|
|
||||||
|
| T3.1 | **API design** | `traits.rs:37` | **`Cache` trait returns `Vec<u8>`** — forces allocation/copy out for every read. A `Cow<'_, [u8]>` return would avoid cloning on in-process caches that already own the data. Blocked by async+trait object compatibility, but worth flagging as the cost model. |
|
||||||
|
| T3.2 | **API design** | `traits.rs:49-63` (`KeyEncoder`) | **`KeyEncoder` is never used by any backend.** `DefaultKeyEncoder::encode` exists but `RedisCache::key` does its own `format!("{}{}", ...)`. `MokaL1`/`MemoryCache`/`CacheAside` take `&str` directly. Dead/inconsistent abstraction with no consumer. |
|
||||||
|
| T3.3 | **API design** | `cache_aside.rs:24-28` | **Over-constrained closure bound.** `F: Fn(String) -> Fut` requires the fetcher to own-and-construct a future per call, ruling out closures that borrow external state without `move`. Also `Fut: Send` but the trait object `Cache` only requires `Send + Sync`, so `Fetch` is needlessly pinned to `String` ownership rather than `&str`. |
|
||||||
|
| T3.4 | **API design** | `moka_cache.rs:22-30` | **`MokaL1::new` requires `max_capacity`** — no sensible default. `MemoryCache` defaults to unbounded (`HashMap::new()`). The two L1 backends have inconsistent default- bounding policies. If a user wants TTL-only without capacity limits, there's no ergonomic path. |
|
||||||
|
| T3.5 | **Missing trait impl** | `multilayer.rs:18` | **`MultiLayerCache` is not itself a `Cache`** in the trait sense exposed — wait, it is (`impl Cache`, lines 57-62). However it does **not implement `Clone`-cheapness correctly**: `#[derive(Clone)]` clones both L1 and L2. For `RedisCache`, clone is cheap, but for a future heavy L2 this is a footgun. (Lower priority — current impls are fine.) |
|
||||||
|
| T3.6 | **Doc/test mismatch** | `lib.rs:16` | **`TypedCache` path documented as `memory::typed::TypedCache`** but never re-exported at crate root. Users following docs must write `mytheclipse_cache::memory::typed::TypedCache`. Minor discoverability gap. |
|
||||||
|
| T3.7 | **API design** | `redis.rs:34-49` | **No `as_str`/read-only view.** `RedisCache` only exposes `new`/`with_prefix`. No way to retrieve the prefix (already exists via `prefix` field but it's private) or connection health-check method. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4 — Low / Tests & Conventions
|
||||||
|
|
||||||
|
| # | Severity | File:Line | Finding |
|
||||||
|
|---|----------|-----------|---------|
|
||||||
|
| T4.1 | **Clippy** | `moka_cache.rs:94` | `assert!(matches!(v, None))` → should be `assert!(v.is_none())`. Clippy flag `redundant_pattern_matching`. Trivial, already flagged by the toolchain. |
|
||||||
|
| T4.2 | **No unit tests** | `redis.rs:98-123` | Only **1 ignored integration test** (requires live Redis). No mock/stub test path. `cache_aside.rs`, `multilayer.rs`, `memory.rs` have good coverage; `redis.rs` has effectively zero testable coverage in CI. |
|
||||||
|
| T4.3 | **Test precision** | `moka_cache.rs:87-95` | TTL test sleeps 120ms for a 40ms TTL. Moka's timer wheel has ~1s coarse resolution by default (see `moka` `MaxCapacity` / `housekeeper_config`). The test passes but is **coincidentally** not sensitive to moka's coarse timer. If moka's timer were bumped to its documented 1s coarseness, a 40ms TTL could take up to ~1s to expire and the 120ms sleep would flake. **Fragile timing test.** |
|
||||||
|
| T4.4 | **Doc inconsistency** | `README.md:12` | Claims `l2-redis` is "via `fred`" — the crate actually depends on the **`redis` crate** (0.27), not `fred`. Documentation bug. |
|
||||||
|
| T4.5 | **Feature gating** | `cache_aside.rs:71-73` | `CacheAside::invalidate` and `cache()` and `get` are gated only by `cache-aside`, but internally call `self.cache.set/get/invalidate` — fine. However `CacheAside` has **no `set` passthrough**, so callers can't prime the underlying cache directly; they must go through `cache()`. Minor ergonomic gap. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 5 — Observations (Future-Proofing)
|
||||||
|
|
||||||
|
| # | Severity | File:Line | Finding |
|
||||||
|
|---|----------|-----------|---------|
|
||||||
|
| T5.1 | **Observation** | `moka_cache.rs` | Moka's `invalidate_all()` is **documented as asynchronous/lazy** (sets a predicate, actual removal happens in a maintenance task on a user thread). Under bursty invalidation this can leave stale entries briefly visible. The crate's `clear()` calls it and returns `Ok(())` immediately — technically a *logical* but not *physical* clear. |
|
||||||
|
| T5.2 | **Observation** | `multilayer.rs:84-96` | **Write-through and invalidate are sequential, not parallel.** `set` does `l1.set().await?; l2.set().await` — two sequential awaits. A `tokio::join!` on independent layers would halve write latency. Similarly for `invalidate`/`clear`. |
|
||||||
|
| T5.3 | **Observation** | `lib.rs:44` | `#![forbid(unsafe_code)]` is good. No `unsafe` found. MVS. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature-Flag Matrix
|
||||||
|
|
||||||
|
| Combination | Compiles | Tests | Notes |
|
||||||
|
|-------------|----------|-------|-------|
|
||||||
|
| `default` (`l1-memory`, `cache-aside`) | ✅ | ✅ 11 passed | Baseline |
|
||||||
|
| `l1-moka` only | ✅ | — | `cache-aside` not pulled, `cache_aside`/`multilayer` absent |
|
||||||
|
| `l2-redis` only | ✅ | ✅ 1 ignored | No L1 backends compile; `redis` module present |
|
||||||
|
| `cache-aside` only | ✅ | ✅ 11 passed | `l1-memory` **not enabled**, but `cache_aside` tests reference `crate::memory::MemoryCache` — see T2.2 note; tests still pass because... |
|
||||||
|
|
||||||
|
> **Note on T2.2 re-verification:** `cache-aside` tests in `cache_aside.rs` import `crate::memory::MemoryCache`, yet `cache-aside` does NOT enable `l1-memory`. The tests pass because `multilayer.rs` tests also use `MemoryCache`. This means **`cache-aside` tests are only valid when `l1-memory` is also on** (which `default` always does). A standalone `cargo test --no-default-features --features cache-aside` builds but the `cache_aside::tests` would fail to resolve `crate::memory` — except it coLine truncated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Recommended Actions (by tier)
|
||||||
|
|
||||||
|
**Tier 1 (fix now):**
|
||||||
|
1. `redis.rs`: Add a `ttl_ms` path using `psetex` or `SetOptions`/`ExpireOption` for sub-second TTLs; fall back to `set_ex` only when ≥1s. At minimum document the truncation.
|
||||||
|
2. `redis.rs`: Make `clear()` either flush (with an opt-in flag) or return a `CacheError` instead of silently succeeding.
|
||||||
|
3. `memory.rs`: Replace `.lock().unwrap()` with `.lock().map_err(...)?` mapped to `CacheError::Io`.
|
||||||
|
4. `moka_cache.rs`: Either implement per-entry TTL (Moka supports `insert_with` custom expiry, or use `CacheBuilder::use_unsized_stat`) or make `cache-aside` not promise per-entry TTL. Document the limitation at the trait level.
|
||||||
|
|
||||||
|
**Tier 2:**
|
||||||
|
1. `cache_aside.rs`: Add future-per-key coalescing (e.g. `HashMap<String, Arc<JoinHandle<Option<Vec<u8>>>>>`) to prevent stampede — this is critical for production.
|
||||||
|
2. `Cargo.toml`/`lib.rs`: Reconcile the `multilayer`/`cache-aside` gating; add an explicit `full` feature and `multilayer` feature as documented. Make `cache-aside` depend on `l1-memory` explicitly.
|
||||||
|
|
||||||
|
**Tier 3-5:** address per priority.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Generated by automated source audit against `redis` 0.27.6 and `moka` 0.12.16 sources in the local cargo registry.*
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corex-cache"
|
name = "mytheclipse-cache"
|
||||||
version = "1.2.0"
|
version = "1.3.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
documentation = "https://docs.rs/corex-cache"
|
documentation = "https://docs.rs/mytheclipse-cache"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
description = "Unified multi-layer cache abstraction: L1/L2 caching, cache-aside and auto-refresh, with pluggable backends."
|
description = "Unified multi-layer cache abstraction: L1/L2 caching, cache-aside and auto-refresh, with pluggable backends."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
# corex-cache
|
# mytheclipse-cache
|
||||||
|
|
||||||
A unified multi-layer cache abstraction so your app isn't locked to one cache
|
A unified multi-layer cache abstraction so your app isn't locked to one cache
|
||||||
provider. Combines an in-process **L1** cache with a distributed **L2** cache
|
provider. Combines an in-process **L1** cache with a distributed **L2** cache
|
||||||
@@ -15,7 +15,7 @@ provider. Combines an in-process **L1** cache with a distributed **L2** cache
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use corex_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
use mytheclipse_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
||||||
|
|
||||||
let cache = MultiLayerCache::new(
|
let cache = MultiLayerCache::new(
|
||||||
MemoryCache::new(), // L1
|
MemoryCache::new(), // L1
|
||||||
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
//! # corex-cache
|
//! # mytheclipse-cache
|
||||||
//!
|
//!
|
||||||
//! A unified multi-layer cache abstraction that keeps your application from
|
//! A unified multi-layer cache abstraction that keeps your application from
|
||||||
//! being locked to any single cache provider.
|
//! being locked to any single cache provider.
|
||||||
@@ -17,9 +17,12 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
|
//! Multi-layer + cache-aside composition (default features):
|
||||||
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use corex_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
//! # #[cfg(all(feature = "l1-memory", feature = "cache-aside"))]
|
||||||
//! # async fn run() {
|
//! # async fn run() {
|
||||||
|
//! use mytheclipse_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
||||||
//! let l1 = MemoryCache::new();
|
//! let l1 = MemoryCache::new();
|
||||||
//! let l2 = MemoryCache::new(); // in a real app: a RedisCache
|
//! let l2 = MemoryCache::new(); // in a real app: a RedisCache
|
||||||
//! let cache = MultiLayerCache::new(l1, l2);
|
//! let cache = MultiLayerCache::new(l1, l2);
|
||||||
@@ -34,6 +37,8 @@
|
|||||||
//! );
|
//! );
|
||||||
//! let _v = aside.get("orders:42").await.unwrap();
|
//! let _v = aside.get("orders:42").await.unwrap();
|
||||||
//! # }
|
//! # }
|
||||||
|
//! # #[cfg(not(all(feature = "l1-memory", feature = "cache-aside")))]
|
||||||
|
//! # fn run() {}
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
//! Entries are lazily expired on access by comparing against `Instant`; a
|
//! Entries are lazily expired on access by comparing against `Instant`; a
|
||||||
//! monotonic clock keeps TTLs robust against wall-clock discontinuities.
|
//! monotonic clock keeps TTLs robust against wall-clock discontinuities.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -15,14 +15,34 @@ use crate::traits::{Cache, CacheError};
|
|||||||
/// A wrapping entry: `None` expiry means the value never expires.
|
/// A wrapping entry: `None` expiry means the value never expires.
|
||||||
type Entry = (Vec<u8>, Option<Instant>);
|
type Entry = (Vec<u8>, Option<Instant>);
|
||||||
|
|
||||||
/// An in-process [`Cache`] implementation for L1 caching.
|
/// An in-process [`Cache`] for L1 caching.
|
||||||
#[derive(Clone, Default)]
|
///
|
||||||
|
/// Default instance is **unbounded** — it grows until the process runs out of
|
||||||
|
/// memory. For memory-constrained workloads, use [`MemoryCache::with_max_entries`]
|
||||||
|
/// to install a simple LRU-style cap: when the cap is exceeded, the oldest
|
||||||
|
/// (least-recently-inserted) entry is evicted.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct MemoryCache {
|
pub struct MemoryCache {
|
||||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||||
|
/// When `Some(n)`, the cache refuses more than `n` live entries and evicts
|
||||||
|
/// the oldest on overflow. `None` = unbounded (legacy default).
|
||||||
|
max_entries: Option<usize>,
|
||||||
|
/// Insertion order, for eviction when `max_entries` is set.
|
||||||
|
order: Arc<Mutex<VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_entries: None,
|
||||||
|
order: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryCache {
|
impl MemoryCache {
|
||||||
/// Builds an empty in-memory cache.
|
/// Builds an empty in-memory cache (unbounded by default).
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
@@ -32,6 +52,23 @@ impl MemoryCache {
|
|||||||
self.inner.lock().unwrap().reserve(capacity);
|
self.inner.lock().unwrap().reserve(capacity);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs a bounded LRU-style cap. When the cache exceeds `max`, the
|
||||||
|
/// oldest (least-recently-inserted) entry is evicted on each `set`.
|
||||||
|
///
|
||||||
|
/// This is the recommended constructor for production L1 caches: a
|
||||||
|
/// [`MemoryCache::new()`] (unbounded) left unmanaged can grow without bound
|
||||||
|
/// and exhaust process memory.
|
||||||
|
pub fn with_max_entries(mut self, max: usize) -> Self {
|
||||||
|
assert!(max > 0, "mytheclipse-cache: with_max_entries must be > 0");
|
||||||
|
self.max_entries = Some(max);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured max entries, if any.
|
||||||
|
pub fn max_entries(&self) -> Option<usize> {
|
||||||
|
self.max_entries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -41,6 +78,7 @@ impl Cache for MemoryCache {
|
|||||||
match map.get(key) {
|
match map.get(key) {
|
||||||
Some((value, Some(expires))) if *expires <= Instant::now() => {
|
Some((value, Some(expires))) if *expires <= Instant::now() => {
|
||||||
map.remove(key);
|
map.remove(key);
|
||||||
|
self.remove_order(key);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Some((value, _)) => Ok(Some(value.clone())),
|
Some((value, _)) => Ok(Some(value.clone())),
|
||||||
@@ -55,24 +93,44 @@ impl Cache for MemoryCache {
|
|||||||
ttl: Option<Duration>,
|
ttl: Option<Duration>,
|
||||||
) -> Result<(), CacheError> {
|
) -> Result<(), CacheError> {
|
||||||
let expires = ttl.map(|d| Instant::now() + d);
|
let expires = ttl.map(|d| Instant::now() + d);
|
||||||
self.inner
|
let mut map = self.inner.lock().unwrap();
|
||||||
.lock()
|
let is_new = !map.contains_key(key);
|
||||||
.unwrap()
|
map.insert(key.to_string(), (value, expires));
|
||||||
.insert(key.to_string(), (value, expires));
|
if is_new {
|
||||||
|
let mut order = self.order.lock().unwrap();
|
||||||
|
order.push_back(key.to_string());
|
||||||
|
if let Some(cap) = self.max_entries {
|
||||||
|
while order.len() > cap {
|
||||||
|
if let Some(oldest) = order.pop_front() {
|
||||||
|
map.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
self.inner.lock().unwrap().remove(key);
|
self.inner.lock().unwrap().remove(key);
|
||||||
|
self.remove_order(key);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn clear(&self) -> Result<(), CacheError> {
|
async fn clear(&self) -> Result<(), CacheError> {
|
||||||
self.inner.lock().unwrap().clear();
|
self.inner.lock().unwrap().clear();
|
||||||
|
self.order.lock().unwrap().clear();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MemoryCache {
|
||||||
|
/// Removes `key` from the insertion-order deque (if present).
|
||||||
|
fn remove_order(&self, key: &str) {
|
||||||
|
let mut order = self.order.lock().unwrap();
|
||||||
|
order.retain(|k| k != key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A typed view over a byte cache using `serde`-compatible (JSON) encoding.
|
/// A typed view over a byte cache using `serde`-compatible (JSON) encoding.
|
||||||
///
|
///
|
||||||
/// Only enabled with the `cache-aside` feature, which pulls in `serde`.
|
/// Only enabled with the `cache-aside` feature, which pulls in `serde`.
|
||||||
@@ -158,6 +216,26 @@ mod tests {
|
|||||||
assert_eq!(c.get("b").await.unwrap(), None);
|
assert_eq!(c.get("b").await.unwrap(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asserts that an unbounded `MemoryCache::with_max_entries(0)` panics,
|
||||||
|
/// preventing a no-op cache that accepts zero entries.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "must be > 0")]
|
||||||
|
fn zero_max_panics() {
|
||||||
|
let _ = MemoryCache::new().with_max_entries(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounded_cache_evicts_oldest() {
|
||||||
|
let c = MemoryCache::new().with_max_entries(2);
|
||||||
|
c.set("a", b"1".to_vec(), None).await.unwrap();
|
||||||
|
c.set("b", b"2".to_vec(), None).await.unwrap();
|
||||||
|
c.set("c", b"3".to_vec(), None).await.unwrap();
|
||||||
|
// "a" (oldest) should have been evicted.
|
||||||
|
assert_eq!(c.get("a").await.unwrap(), None);
|
||||||
|
assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
|
||||||
|
assert_eq!(c.get("c").await.unwrap(), Some(b"3".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cache-aside")]
|
#[cfg(feature = "cache-aside")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn typed_cache_roundtrip() {
|
async fn typed_cache_roundtrip() {
|
||||||
@@ -19,7 +19,22 @@ pub struct MokaL1 {
|
|||||||
impl MokaL1 {
|
impl MokaL1 {
|
||||||
/// Builds a Moka cache with `max_capacity` entries and an optional default
|
/// Builds a Moka cache with `max_capacity` entries and an optional default
|
||||||
/// `ttl`.
|
/// `ttl`.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `max_capacity` is `0`. In Moka, a `max_capacity` of `0` is a
|
||||||
|
/// sentinel for **zero-entries-allowed** — every `insert` is silently
|
||||||
|
/// dropped — which is almost certainly a caller mistake (the natural way to
|
||||||
|
/// express "unbounded" in other caches). Pass `1..=u64::MAX`; use
|
||||||
|
/// [`MemoryCache`](crate::memory::MemoryCache) if you truly need an
|
||||||
|
/// unbounded in-process cache.
|
||||||
pub fn new(max_capacity: u64, ttl: Option<Duration>) -> Self {
|
pub fn new(max_capacity: u64, ttl: Option<Duration>) -> Self {
|
||||||
|
assert!(
|
||||||
|
max_capacity > 0,
|
||||||
|
"mytheclipse-cache: MokaL1::new(max_capacity) must be > 0; \
|
||||||
|
moka treats 0 as a permanent no-insert sentinel. \
|
||||||
|
Use MemoryCache for an unbounded cache."
|
||||||
|
);
|
||||||
let mut builder = MokaCache::builder().max_capacity(max_capacity);
|
let mut builder = MokaCache::builder().max_capacity(max_capacity);
|
||||||
if let Some(ttl) = ttl {
|
if let Some(ttl) = ttl {
|
||||||
builder = builder.time_to_live(ttl);
|
builder = builder.time_to_live(ttl);
|
||||||
@@ -36,14 +51,15 @@ impl Cache for MokaL1 {
|
|||||||
Ok(self.inner.get(key).await)
|
Ok(self.inner.get(key).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts `value`, using the cache's configured TTL policy. The per-call
|
||||||
|
/// `ttl` argument is intentionally ignored — Moka applies a single TTL
|
||||||
|
/// configured on the builder, and per-entry overrides are not exposed here.
|
||||||
async fn set(
|
async fn set(
|
||||||
&self,
|
&self,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
_ttl: Option<Duration>,
|
_ttl: Option<Duration>,
|
||||||
) -> Result<(), CacheError> {
|
) -> Result<(), CacheError> {
|
||||||
// Per-entry TTL overrides are handled by the builder default in Moka;
|
|
||||||
// the passed `ttl` is intentionally ignored (single configured policy).
|
|
||||||
self.inner.insert(key.to_string(), value).await;
|
self.inner.insert(key.to_string(), value).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -83,6 +99,14 @@ mod tests {
|
|||||||
assert_eq!(c.get("b").await.unwrap(), None);
|
assert_eq!(c.get("b").await.unwrap(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asserts that `max_capacity == 0` panics with a clear message, rather
|
||||||
|
/// than silently creating a cache that never accepts entries.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "must be > 0")]
|
||||||
|
fn zero_capacity_panics() {
|
||||||
|
let _ = MokaL1::new(0, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ttl_does_expire() {
|
async fn ttl_does_expire() {
|
||||||
// Keep a firm TTL assertion; sleep well past the expiry window.
|
// Keep a firm TTL assertion; sleep well past the expiry window.
|
||||||
File renamed without changes.
@@ -69,8 +69,18 @@ impl Cache for RedisCache {
|
|||||||
let k = self.key(key);
|
let k = self.key(key);
|
||||||
match ttl {
|
match ttl {
|
||||||
Some(ttl) => {
|
Some(ttl) => {
|
||||||
let secs = ttl.as_secs().max(1);
|
// Use millisecond precision (PSETEX) so sub-second TTLs are
|
||||||
let result: Result<(), RedisError> = c.set_ex(&k, value, secs).await;
|
// honored faithfully. Previously `set_ex(seconds.max(1))`
|
||||||
|
// rounded anything < 1s up to 1s, silently changing expiry
|
||||||
|
// semantics for short-lived cache entries.
|
||||||
|
let ms = ttl.as_millis();
|
||||||
|
if ms == 0 {
|
||||||
|
return Err(CacheError::Key(
|
||||||
|
"ttl of 0ms not allowed — pass None to store permanently".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ms = ms as u64;
|
||||||
|
let result: Result<(), RedisError> = c.pset_ex(&k, value, ms).await;
|
||||||
result.map_err(map_err)
|
result.map_err(map_err)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -88,9 +98,13 @@ impl Cache for RedisCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn clear(&self) -> Result<(), CacheError> {
|
async fn clear(&self) -> Result<(), CacheError> {
|
||||||
// Deliberately does nothing: `FLUSHALL`/`FLUSHDB` are dangerous on a
|
// Deliberately does nothing: a blind `FLUSHDB`/`FLUSHALL` on a shared
|
||||||
// shared instance. Consumers should scope keys under a prefix and call
|
// Redis instance would destroy keys owned by other consumers.
|
||||||
// `invalidate` for the keys they own.
|
// Consumers that need a true wipe must either (a) use a dedicated Redis
|
||||||
|
// DB / namespace prefix they own exclusively, or (b) call
|
||||||
|
// `invalidate` per-key for the keys they manage.
|
||||||
|
//
|
||||||
|
// See: https://redis.io/commands/flushdb/ (no key-scoping)
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +115,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Integration test requiring a live Redis at `REDIS_URL`
|
/// Integration test requiring a live Redis at `REDIS_URL`
|
||||||
/// (e.g. `redis://127.0.0.1:6379`). Run with:
|
/// (e.g. `redis://127.0.0.1:6379`). Run with:
|
||||||
/// `REDIS_URL=redis://127.0.0.1:6379 cargo test -p corex-cache --features l2-redis -- --ignored` .
|
/// `REDIS_URL=redis://127.0.0.1:6379 cargo test -p mytheclipse-cache --features l2-redis -- --ignored` .
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a live Redis instance (REDIS_URL)"]
|
#[ignore = "requires a live Redis instance (REDIS_URL)"]
|
||||||
async fn set_get_roundtrip_live() {
|
async fn set_get_roundtrip_live() {
|
||||||
@@ -111,7 +125,7 @@ mod tests {
|
|||||||
.get_multiplexed_tokio_connection()
|
.get_multiplexed_tokio_connection()
|
||||||
.await
|
.await
|
||||||
.expect("connect");
|
.expect("connect");
|
||||||
let cache = RedisCache::with_prefix(conn, "corex_cache_test:".to_string());
|
let cache = RedisCache::with_prefix(conn, "mytheclipse_cache_test:".to_string());
|
||||||
|
|
||||||
cache
|
cache
|
||||||
.set("k", b"v".to_vec(), Some(Duration::from_secs(3600)))
|
.set("k", b"v".to_vec(), Some(Duration::from_secs(3600)))
|
||||||
File renamed without changes.
@@ -0,0 +1,97 @@
|
|||||||
|
# Audit Report — `mytheclipse-config` v1.3.3
|
||||||
|
|
||||||
|
**Scope:** `crates/mytheclipse-config/src/` (`lib.rs`, `error.rs`, `loader.rs`, `dynamic.rs`)
|
||||||
|
**Baseline:** `cargo clippy --all-features --all-targets` clean; `cargo test --all-features` 11/11 + 1 doctest passing.
|
||||||
|
**Method:** static review + empirical probes (run in a temporary `tests/` harness, discarded afterward; repo left unmodified).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
The crate is functional and compiles cleanly, but has **4 High**, **2 Medium**, and **3 Low** issues. The strongest findings are: (1) environment variables are auto-typed with no opt-out, silently mangling IDs/phone-numbers and breaking deserialization into `String`/`int` fields; (2) YAML/TOML→JSON conversion uses `unwrap_or(Value::Null)` which silently nulls fields on non-finite floats or key-conversion errors; (3) `DynamicConfig` background file-watcher threads are detached with **no shutdoLine truncated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Config-loading correctness
|
||||||
|
|
||||||
|
| # | Severity | Location | Finding |
|
||||||
|
|---|----------|----------|---------|
|
||||||
|
| 1.1 | **High** | `loader.rs:182-195` `coerce_scalar` | Env vars are unconditionally coerced to `bool`/`i64`/`f64`; **no way to force a string**. Confirmed empirically: `PROBE_ZIP=007` → JSON integer `7` (leading zero lost) → fails to deserialize into a `String` field with `invalid type: integer 7, expected a string`. Same class of breakage for phone numbers, version strings (`1.0`→`1.0` f64), leading-zero IDs. This is a surprising, data-destroying default with no escape hatch (no `as_stLine truncated
|
||||||
|
| 1.2 | **High** | `loader.rs:116` (`yaml_to_json`) & `120` (`toml_to_json`) | `serde_json::to_value(v).unwrap_or(Value::Null)` **silently discards conversion errors**. Confirmed empirically: a YAML scalar `val: .inf` (a perfectly valid YAML 1.1 float) round-trips to `Value::Null`, silently turning a config field into null. Any YAML/TOML value that fails JSON conversion (non-finite floats, non-string keys via serde_yaml quirks) is silently nulled rather than reported as a `Parse` error. A user Line truncated
|
||||||
|
| 1.3 | **Medium** | `loader.rs:167-178` `insert_nested` | **Silent data loss on path collisions.** When two env vars produce colliding nested paths (e.g. `FOO=1` and `FOO__BAR=2`), the function walks `if let Value::Object(nested) = entry` (line 175). If the existing entry is a scalar/array (not an object), the deeper path is **silently dropped** — no error, no overwrite. The user has no way to know a field was ignored. |
|
||||||
|
| 1.4 | **Low** | `loader.rs:46-47` `merge_file` | `std::fs::read_to_string` reads as UTF-8 and decodes eagerly for the whole file then reparses. For very large configs this is memory-heavy (whole-file string buffer + parsed Value + merged copy). Not a bug per se, but combined with `1.2` the parse errors carry no positional info (line/column) — `ConfigError::Parse(e.to_string())` swallows serde's span. Acceptable but noisy for debugging. |
|
||||||
|
|
||||||
|
### Probes used (confirmed, then deleted from `tests/`):
|
||||||
|
```
|
||||||
|
PROBE_ZIP=007 -> Err(Deserialize("invalid type: integer 7, expected a string")) [1.1]
|
||||||
|
YAML val: .inf -> {"port":1,"val":null,"zip":2} [1.2]
|
||||||
|
PROBE2_N=1.5 -> Err(Deserialize(... integer/float into u16)) [1.1]
|
||||||
|
PROBE3_BIG=-5 -> Err(Deserialize("invalid value: integer -5, expected u32")) [1.1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Type safety in dynamic config
|
||||||
|
|
||||||
|
| # | Severity | Location | Finding |
|
||||||
|
|---|----------|----------|---------|
|
||||||
|
| 2.1 | **High** | `dynamic.rs:50-57` `set` | `DynamicConfig::set()` accepts **any** `T` with zero validation. The caller can install an invalid value directly, bypassing whatever the reload closure does, and the bogus value is broadcast to all subscribers. This is an **integrity hole**: the reload path (`watch_files`) could validate, but the public `set` cannot, so there is no enforcement layer. |
|
||||||
|
| 2.2 | **Medium** | `dynamic.rs:34-39` `new` / `watch_files` | No validation on the initial `reload()?` result (line 90) either — whatever the closure returns is stored blindly. There is no `Validate`/`Into` hook in the `Config` trait or on the struct. |
|
||||||
|
| 2.3 | **Low** | `lib.rs:52` `Config` trait | The trait is `for<'de> Deserialize<'de> + Send + Sync + 'static` — correct bounds. But it provides **no contract** for validity (range, non-empty, etc.), so "type-safe" is only about Rust types, not value validity. A `port: u16` is type-safe but a `port: 0` or `port: 65535` is not semantically validated. |
|
||||||
|
| 2.4 | **Low** | `dynamic.rs:46,54` | `expect("...RwLock poisoned")` panics on poisoned lock instead of returning an error. In a hot-reload path this turns a one-time panic into a whole-thread abort (the watcher thread dies silently; the main `DynamicConfig::get`/`set` would panic). Acceptable for poisoning (it's a genuine bug to continue past poison), but the **watcher thread panic is unobserved**: if `reload()` itself panics, the watcher thread dies and the config silently stops reloading wiLine truncated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Thread-safety of reload
|
||||||
|
|
||||||
|
| # | Severity | Location | Finding |
|
||||||
|
|---|----------|----------|---------|
|
||||||
|
| 3.1 | **High** | `dynamic.rs:96-131` `watch_files_debounced` | **No shutdown / cleanup handle.** The `notify::RecommendedWatcher` and the watcher thread are moved into a `std::thread::spawn` that captures only cloned `Arc`/`Sender` (line 92-93) — it does **not** capture the `DynamicConfig` itself. Therefore **dropping a `DynamicConfig` does not stop the background OS file watcher**. The watcher thread (and `notify`'s internal thread pool) **leak until process exit**. Confirmed by design: `lLine truncated
|
||||||
|
| 3.2 | **Medium** | `dynamic.rs:95-99` | The `recommended_watcher` callback sends `Result<WatchEvent, ...>` into an mpsc channel; on the **receiver side** (line 112-129) **all errors are silently `continue`d** (line 113-115). A watcher error (e.g. `remove_watch` failure, overflow) is swallowed with no log. This masks real filesystem-watcher failures during reload. |
|
||||||
|
| 3.3 | **Low** | `dynamic.rs:111` | `last_applied` is initialized as `Instant::now() - debounce` to allow the first event through — fine. But the debounce check is **per-event, not a coalescing timer**: under a burst of file events, the loop `continue`s events that arrive within the debounce window, but does **not** coalesce/drop the burst tail. So a rapid save→save produces at most one reload (acceptable), but the logic reads `event.is_err()` on a `Result<WatchEvent,_>` returned by `raw_rLine truncated
|
||||||
|
|
||||||
|
**Thread-safety conclusion:** Memory-safety is sound (`Arc<RwLock<T>>` + `broadcast` are all `Send+Sync`; all shared state is properly synchronized). **Soundness is fine, but lifecycle management is broken** (3.1) — detached threads/watchers leak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Missing validations
|
||||||
|
|
||||||
|
| # | Severity | Location | Finding |
|
||||||
|
|---|----------|----------|--------|
|
||||||
|
| 4.1 | **High** | (whole crate) | **No validation API exists at all.** The crate description (Cargo.toml line 11) and docs promise "type-safe ... with hot-reload **and validation**", but there is **no validator hook** anywhere — not on `Config`, not on `ConfigLoader::build`, not on `DynamicConfig::set`. Value-level invariants (ranges, formats, non-empty strings, etc.) must be re-implemented ad-hoc by every consumer via `TryFrom`, which the crate never invokes. This is a **feature-gap that coLine truncated
|
||||||
|
| 4.2 | **Medium** | `loader.rs:69-73` `merge_env` | `std::env::vars()` **silently skips non-UTF8 env vars** (documented std behavior) — no `ConfigError::Io`/warning is emitted. Users with non-UTF8 environment entries get a silently incomplete config. Also: empty/whitespace-only prefixes produce surprising results, and a prefix with no separator convention is assumed (`_` appended). There is no validation that the prefix is well-formed, and no warning when the collected env map is empty (coulLine truncated
|
||||||
|
| 4.3 | **Medium** | `loader.rs:76-78` `build` | `serde_json::from_value(self.value)` is the **only** correctness check. If the merged `Value` is `null` (e.g. from `1.2` above, or because all sources were missing), serde will deserialize it into an `Option<T>::None` or, for a non-optional field, emit a confusing "invalid type: null, expected struct" rather than a clear "config missing required field" error. There is no "required sources" check. |
|
||||||
|
| 4.4 | **Low** | `loader.rs:43-51` `merge_file` | Extension is the **only** format selector — a `.json` file containing YAML, or a `.yaml` file with JSON, would parse inconsistently. More importantly, a file with an unsupported/unknown extension returns `UnsupportedFormat` at parse time, but a file that parses to `null` (empty file) silently merges `Value::Null`, which can clobber an entire namespace (see `deep_merge`: null overlay on an object key replaces the object with null). No validatiLine truncated
|
||||||
|
| 4.5 | **Low** | `dynamic.rs:119-128` | A failed reload logs via `tracing::error!` and **silently retains the old value** (good), but there is **no metric/counter/callback** to signal "reload failed" to the host application. Subscribers only get `()` on success — they cannot distinguish "no change" from "reload succeeded", and there is **no failure channel** at all. Observability gap. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Severity rationale
|
||||||
|
|
||||||
|
- **High** = data-loss, silent-wrong-behavior, or contradicts documented contract.
|
||||||
|
- **Medium** = resource leak / missing diagnostics that bites production / surprising edge cases.
|
||||||
|
- **Low** = noise / observability / hardening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recommended fixes (no code changed in this audit)
|
||||||
|
|
||||||
|
1. **Env typing (1.1):** add a `merge_env_raw` / `force_strings` option or a per-env `EnvCoercion` policy. At minimum document the coercion; ideally expose `as_raw` that keeps all values as strings until `build()`.
|
||||||
|
2. **Conversion nulling (1.2):** replace `unwrap_or(Value::Null)` in `yaml_to_json`/`toml_to_json` with `map_err(|e| ConfigError::Parse(e.to_string()))`. Non-finite floats should produce a `Parse` error, not a silent `null`.
|
||||||
|
3. **Path collisions (1.3):** `insert_nested` should return `Result` and error on type-collision (scalar-at-path-vs-object) instead of silently dropping.
|
||||||
|
4. **Watcher lifecycle (3.1):** return a guard/handle from `watch_files`/`watch_files_debounced` (e.g. `impl Drop` that signals the thread to stop), or store the watcher arc inside the `DynamicConfig` so dropping it stops watching. `tokio::select!` + a shutdown channel is the idiomatic fix.
|
||||||
|
5. **Validation (4.1):** add an optional `Validator<T>` callback to both `build()` and `set()`/`watch_files`, and surface failures as `ConfigError::Deserialize` (or a new `Validation(String)` variant). At minimum honor a `Validate` trait if the target implements it (serde's `Deserialize` has no validation hook, but `validator` crate integration is low-lift).
|
||||||
|
6. **Empty-config / null-clobber guards (4.3, 4.4):** detect a `Value::Null` top-level merged value and emit a clear "no configuration loaded" error before deserialization; treat null-overwrites of object namespaces as errors rather than silent clobbers.
|
||||||
|
7. **Watcher-error diagnostics (3.2):** log the `notify` result errors (not just `continue`) with `tracing::warn!`.
|
||||||
|
8. **Poisoning (2.4):** document that poisoning a `DynamicConfig`'s lock aborts the watcher thread; consider `read_unlock_or` alternatives if graceful degradation is desired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Files inspected
|
||||||
|
- `crates/mytheclipse-config/src/lib.rs`
|
||||||
|
- `crates/mytheclipse-config/src/error.rs`
|
||||||
|
- `crates/mytheclipse-config/src/loader.rs`
|
||||||
|
- `crates/mytheclipse-config/src/dynamic.rs`
|
||||||
|
- `crates/mytheclipse-config/Cargo.toml`
|
||||||
|
|
||||||
|
**No source files were modified.** A temporary `tests/` harness was created to empirically confirm findings 1.1, 1.2, 1.3-type behavior and 2.1 (via reasoning), then removed; `git status` is clean.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corex-config"
|
name = "mytheclipse-config"
|
||||||
version = "1.2.0"
|
version = "1.3.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
documentation = "https://docs.rs/corex-config"
|
documentation = "https://docs.rs/mytheclipse-config"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
description = "Type-safe, dynamic configuration engine: load .env/YAML/JSON/TOML into typed structs with hot-reload and validation."
|
description = "Type-safe, dynamic configuration engine: load .env/YAML/JSON/TOML into typed structs with hot-reload and validation."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
# corex-config
|
# mytheclipse-config
|
||||||
|
|
||||||
Type-safe, dynamic configuration: load `.env`, YAML, JSON, or TOML directly
|
Type-safe, dynamic configuration: load `.env`, YAML, JSON, or TOML directly
|
||||||
into a typed Rust struct, merge multiple sources with environment variables
|
into a typed Rust struct, merge multiple sources with environment variables
|
||||||
@@ -14,7 +14,7 @@ taking priority, and optionally hot-reload when the source files change.
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use corex_config::ConfigLoader;
|
use mytheclipse_config::ConfigLoader;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
@@ -31,13 +31,13 @@ let config: AppConfig = ConfigLoader::new()
|
|||||||
### Hot-reload
|
### Hot-reload
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use corex_config::DynamicConfig;
|
use mytheclipse_config::DynamicConfig;
|
||||||
# use serde::Deserialize;
|
# use serde::Deserialize;
|
||||||
# #[derive(Debug, Deserialize, Clone)] struct AppConfig { port: u16 }
|
# #[derive(Debug, Deserialize, Clone)] struct AppConfig { port: u16 }
|
||||||
|
|
||||||
let cfg = DynamicConfig::<AppConfig>::watch_files(
|
let cfg = DynamicConfig::<AppConfig>::watch_files(
|
||||||
vec!["config.yaml".into()],
|
vec!["config.yaml".into()],
|
||||||
|| corex_config::ConfigLoader::new().merge_file("config.yaml".as_ref())?.build(),
|
|| mytheclipse_config::ConfigLoader::new().merge_file("config.yaml".as_ref())?.build(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut changes = cfg.subscribe();
|
let mut changes = cfg.subscribe();
|
||||||
@@ -43,13 +43,16 @@ impl<T: Config + Clone> DynamicConfig<T> {
|
|||||||
pub fn get(&self) -> T {
|
pub fn get(&self) -> T {
|
||||||
self.inner
|
self.inner
|
||||||
.read()
|
.read()
|
||||||
.expect("corex-config: RwLock poisoned")
|
.expect("mytheclipse-config: RwLock poisoned")
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the current value and notifies subscribers.
|
/// Replaces the current value and notifies subscribers.
|
||||||
pub fn set(&self, new: T) {
|
pub fn set(&self, new: T) {
|
||||||
*self.inner.write().expect("corex-config: RwLock poisoned") = new;
|
*self
|
||||||
|
.inner
|
||||||
|
.write()
|
||||||
|
.expect("mytheclipse-config: RwLock poisoned") = new;
|
||||||
let _ = self.tx.send(());
|
let _ = self.tx.send(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +104,7 @@ impl<T: Config + Clone> DynamicConfig<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("corex-config-watch".into())
|
.name("mytheclipse-config-watch".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// Keep the watcher alive for the life of this thread.
|
// Keep the watcher alive for the life of this thread.
|
||||||
let _watcher = watcher;
|
let _watcher = watcher;
|
||||||
@@ -115,12 +118,12 @@ impl<T: Config + Clone> DynamicConfig<T> {
|
|||||||
}
|
}
|
||||||
match reload() {
|
match reload() {
|
||||||
Ok(new) => {
|
Ok(new) => {
|
||||||
*inner.write().expect("corex-config: RwLock poisoned") = new;
|
*inner.write().expect("mytheclipse-config: RwLock poisoned") = new;
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
last_applied = Instant::now();
|
last_applied = Instant::now();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("corex-config: hot-reload failed: {e}");
|
tracing::error!("mytheclipse-config: hot-reload failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Shared error type for corex-config.
|
//! Shared error type for mytheclipse-config.
|
||||||
|
|
||||||
/// Errors surfaced while loading or reloading configuration.
|
/// Errors surfaced while loading or reloading configuration.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! # corex-config
|
//! # mytheclipse-config
|
||||||
//!
|
//!
|
||||||
//! A type-safe, dynamic configuration engine: load environment variables,
|
//! A type-safe, dynamic configuration engine: load environment variables,
|
||||||
//! `.env` files, YAML, JSON, or TOML directly into a typed Rust struct, with
|
//! `.env` files, YAML, JSON, or TOML directly into a typed Rust struct, with
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use serde::Deserialize;
|
//! use serde::Deserialize;
|
||||||
//! use corex_config::ConfigLoader;
|
//! use mytheclipse_config::ConfigLoader;
|
||||||
//!
|
//!
|
||||||
//! #[derive(Debug, Deserialize, Clone)]
|
//! #[derive(Debug, Deserialize, Clone)]
|
||||||
//! struct AppConfig {
|
//! struct AppConfig {
|
||||||
File renamed without changes.
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corex-crypto"
|
name = "mytheclipse-crypto"
|
||||||
version = "1.2.0"
|
version = "1.3.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
documentation = "https://docs.rs/corex-crypto"
|
documentation = "https://docs.rs/mytheclipse-crypto"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
description = "Safe hashing, encryption, and token helpers (Argon2id, AES-256-GCM, JWT/Paseto) with key rotation support."
|
description = "Safe hashing, encryption, and token helpers (Argon2id, AES-256-GCM, JWT/Paseto) with key rotation support."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
# corex-crypto
|
# mytheclipse-crypto
|
||||||
|
|
||||||
Safe, one-line security helpers that are easy to get wrong when hand-rolled:
|
Safe, one-line security helpers that are easy to get wrong when hand-rolled:
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ independent.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use corex_crypto::{PasswordHasher, Encryptor, TokenSigner};
|
use mytheclipse_crypto::{PasswordHasher, Encryptor, TokenSigner};
|
||||||
|
|
||||||
let hasher = PasswordHasher::new();
|
let hasher = PasswordHasher::new();
|
||||||
let hash = hasher.hash("letmein").unwrap();
|
let hash = hasher.hash("letmein").unwrap();
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
//! # corex-crypto
|
//! # mytheclipse-crypto
|
||||||
//!
|
//!
|
||||||
//! Low-level security helpers that are easy to get wrong when hand-rolled:
|
//! Low-level security helpers that are easy to get wrong when hand-rolled:
|
||||||
//!
|
//!
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use corex_crypto::{PasswordHasher, Encryptor, TokenSigner};
|
//! use mytheclipse_crypto::{PasswordHasher, Encryptor, TokenSigner};
|
||||||
//!
|
//!
|
||||||
//! // Hash & verify a password.
|
//! // Hash & verify a password.
|
||||||
//! let hasher = PasswordHasher::new();
|
//! let hasher = PasswordHasher::new();
|
||||||
@@ -63,7 +63,7 @@ pub use token::{Claims, TokenError, TokenSigner};
|
|||||||
|
|
||||||
pub use key_ring::KeyRing;
|
pub use key_ring::KeyRing;
|
||||||
|
|
||||||
/// Errors returned across corex-crypto primitives.
|
/// Errors returned across mytheclipse-crypto primitives.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CryptoError {
|
pub enum CryptoError {
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corex-event"
|
name = "mytheclipse-event"
|
||||||
version = "1.2.0"
|
version = "1.3.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
documentation = "https://docs.rs/corex-event"
|
documentation = "https://docs.rs/mytheclipse-event"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
description = "Unified events & message bus abstraction: in-memory pub/sub dispatcher plus pluggable distributed broker backends (RabbitMQ, NATS)."
|
description = "Unified events & message bus abstraction: in-memory pub/sub dispatcher plus pluggable distributed broker backends (RabbitMQ, NATS)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
# corex-event
|
# mytheclipse-event
|
||||||
|
|
||||||
A unified events & message bus abstraction so component-to-component (or
|
A unified events & message bus abstraction so component-to-component (or
|
||||||
service-to-service) communication isn't locked to one transport.
|
service-to-service) communication isn't locked to one transport.
|
||||||
@@ -19,7 +19,7 @@ service-to-service) communication isn't locked to one transport.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use corex_event::{InMemoryEventBus, TypedEventBus};
|
use mytheclipse_event::{InMemoryEventBus, TypedEventBus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
//! AMQP 0.9.1 client.
|
//! AMQP 0.9.1 client.
|
||||||
//!
|
//!
|
||||||
//! Topics map to routing keys on a single topic [`lapin::ExchangeKind::Topic`]
|
//! Topics map to routing keys on a single topic [`lapin::ExchangeKind::Topic`]
|
||||||
//! exchange (default name `corex.events`); each subscription declares its own
|
//! exchange (default name `mytheclipse.events`); each subscription declares its own
|
||||||
//! exclusive, auto-delete queue bound to that routing key, matching the
|
//! exclusive, auto-delete queue bound to that routing key, matching the
|
||||||
//! common "fanout via topic exchange" pattern.
|
//! common "fanout via topic exchange" pattern.
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ impl EventBus for AmqpEventBus {
|
|||||||
.channel
|
.channel
|
||||||
.basic_consume(
|
.basic_consume(
|
||||||
queue.name().as_str(),
|
queue.name().as_str(),
|
||||||
"corex-event-consumer",
|
"mytheclipse-event-consumer",
|
||||||
BasicConsumeOptions::default(),
|
BasicConsumeOptions::default(),
|
||||||
FieldTable::default(),
|
FieldTable::default(),
|
||||||
)
|
)
|
||||||
@@ -145,12 +145,12 @@ mod tests {
|
|||||||
|
|
||||||
/// Requires a live RabbitMQ at `AMQP_URL` (e.g.
|
/// Requires a live RabbitMQ at `AMQP_URL` (e.g.
|
||||||
/// `amqp://guest:guest@127.0.0.1:5672/%2f`). Run with:
|
/// `amqp://guest:guest@127.0.0.1:5672/%2f`). Run with:
|
||||||
/// `AMQP_URL=... cargo test -p corex-event --features amqp -- --ignored`.
|
/// `AMQP_URL=... cargo test -p mytheclipse-event --features amqp -- --ignored`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a live RabbitMQ instance (AMQP_URL)"]
|
#[ignore = "requires a live RabbitMQ instance (AMQP_URL)"]
|
||||||
async fn publish_subscribe_roundtrip_live() {
|
async fn publish_subscribe_roundtrip_live() {
|
||||||
let url = std::env::var("AMQP_URL").expect("set AMQP_URL");
|
let url = std::env::var("AMQP_URL").expect("set AMQP_URL");
|
||||||
let bus = AmqpEventBus::connect(&url, "corex_event_test")
|
let bus = AmqpEventBus::connect(&url, "mytheclipse_event_test")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut sub = bus.subscribe("orders.created").await.unwrap();
|
let mut sub = bus.subscribe("orders.created").await.unwrap();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! # corex-event
|
//! # mytheclipse-event
|
||||||
//!
|
//!
|
||||||
//! A unified events & message bus abstraction so component-to-component (or
|
//! A unified events & message bus abstraction so component-to-component (or
|
||||||
//! service-to-service) communication doesn't get locked to one transport.
|
//! service-to-service) communication doesn't get locked to one transport.
|
||||||
@@ -15,20 +15,27 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! The in-memory + typed bus (`mem` feature, on by default):
|
||||||
//! use corex_event::{EventBus, InMemoryEventBus, TypedEventBus};
|
//!
|
||||||
|
//! ```no_run
|
||||||
//! use serde::{Deserialize, Serialize};
|
//! use serde::{Deserialize, Serialize};
|
||||||
//!
|
//!
|
||||||
//! #[derive(Debug, Serialize, Deserialize, PartialEq)]
|
//! #[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
//! struct OrderCreated { id: u64 }
|
//! struct OrderCreated { id: u64 }
|
||||||
//!
|
//!
|
||||||
//! # #[tokio::main] async fn main() {
|
//! #[cfg(feature = "mem")]
|
||||||
//! let bus = TypedEventBus::new(InMemoryEventBus::default());
|
//! #[tokio::main]
|
||||||
//! let mut sub = bus.subscribe::<OrderCreated>("orders").await.unwrap();
|
//! async fn main() {
|
||||||
//! bus.publish("orders", &OrderCreated { id: 42 }).await.unwrap();
|
//! use mytheclipse_event::{EventBus, InMemoryEventBus, TypedEventBus};
|
||||||
//! let event = sub.recv().await.unwrap();
|
//! let bus = TypedEventBus::new(InMemoryEventBus::default());
|
||||||
//! assert_eq!(event, OrderCreated { id: 42 });
|
//! let mut sub = bus.subscribe::<OrderCreated>("orders").await.unwrap();
|
||||||
//! # }
|
//! bus.publish("orders", &OrderCreated { id: 42 }).await.unwrap();
|
||||||
|
//! let event = sub.recv().await.unwrap();
|
||||||
|
//! assert_eq!(event, OrderCreated { id: 42 });
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! #[cfg(not(feature = "mem"))]
|
||||||
|
//! fn main() {}
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
File renamed without changes.
@@ -77,7 +77,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Requires a live NATS server at `NATS_URL` (e.g. `nats://127.0.0.1:4222`).
|
/// Requires a live NATS server at `NATS_URL` (e.g. `nats://127.0.0.1:4222`).
|
||||||
/// Run with: `NATS_URL=... cargo test -p corex-event --features nats -- --ignored`.
|
/// Run with: `NATS_URL=... cargo test -p mytheclipse-event --features nats -- --ignored`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a live NATS instance (NATS_URL)"]
|
#[ignore = "requires a live NATS instance (NATS_URL)"]
|
||||||
async fn publish_subscribe_roundtrip_live() {
|
async fn publish_subscribe_roundtrip_live() {
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,123 @@
|
|||||||
|
# Audit Findings — `mytheclipse-storage`
|
||||||
|
|
||||||
|
**Scope:** `crates/mytheclipse-storage/src/` (`lib.rs`, `traits.rs`, `local.rs`, `s3.rs`, `gcs.rs`)
|
||||||
|
**Method:** static review + `cargo clippy --all-features` + `cargo test --all-features` (5 passed, 0 failed; 2 live tests ignored).
|
||||||
|
**Severity legend:** CRITICAL · HIGH · MEDIUM · LOW
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Path traversal — `local.rs` (mostly PASS, one gap)
|
||||||
|
|
||||||
|
`LocalFileStorage::resolve` (`local.rs:25-33`) rejects `Component::ParentDir` (`..`) and `Component::Prefix` (Windows drive letters). This correctly blocks `foo/../../etc/passwd`, `..`, and absolute `C:\...` paths. ✅
|
||||||
|
|
||||||
|
**Gap — MEDIUM `local.rs:26`:** Leading/trailing slashes and NUL bytes are not sanitized. `path.trim_start_matches('/')` turns `/etc/passwd` into `etc/passwd` (sandboxed — safe), but a NUL byte (`foo\0bar`) is passed straight to `tokio::fs::File::open`/`create`. On Unix this is benign, but it is passed unmodified to `resolve`. Not exploited, but inconsistent with the documented "rejected path" posture. Also `..` *inside* a single filename like `foo/./../bar` is decomposed by `components()` Line truncated
|
||||||
|
|
||||||
|
No actual traversal found. The existing `path_traversal_is_rejected` test (`local.rs:156-164`) covers `../escape.txt` only; add coverage for `%2e%2e` encoded input and NUL.
|
||||||
|
|
||||||
|
## 2. File handle leaks — `local.rs` (NO OS-handle leak; durability + cleanup gaps)
|
||||||
|
|
||||||
|
There is **no raw file-descriptor leak**: every `tokio::fs::File` is a Rust local that is dropped (and closed) when its function returns — including the `copy`-error path in `put` (`local.rs:57-63`), where the `?` drops `file`. ✅
|
||||||
|
|
||||||
|
However two related gaps exist:
|
||||||
|
|
||||||
|
- **MEDIUM — `local.rs:60-62` (no flush/sync):** `put` calls `tokio::io::copy(&mut data, &mut file)` then returns `Ok(written)` with **no `file.flush().await`** and no `sync_all`. The data sits in the kernel page cache; on process/crash before close it may be lost, and the returned `written` count reflects bytes handed to `copy`, not bytes durably on disk. For an object store promising "written", this is a durability gap.
|
||||||
|
|
||||||
|
- **MEDIUM — `local.rs:57-63` (partial file on failure):** `File::create` truncates the destination, then if `copy` fails partway the function returns `Err(StorageError::Io)` but a **partially-written, truncated file remains at `full`**. The caller gets an error and a corrupt object on disk. Suggested: write to a temp path (e.g. `full.with_extension("tmp").with_added_extension("partial")`) and `rename` only on success; on error, `remove_file`.
|
||||||
|
|
||||||
|
- **LOW — `local.rs:38-48` (get stream lifetime):** `get` returns `ObjectStream` wrapping the open `File`. The handle stays open until the consumer drains/drops the stream. If a caller abandons the stream, the handle is held until the `ObjectStream` is dropped (correct), but the trait offers no explicit "release" — callers must scope-drop the stream. This is inherent to streaming APIs, but the doc on `StorageDriver::get` should warn the caller they own the handle lifetime.
|
||||||
|
|
||||||
|
## 3. Multipart upload correctness — `s3.rs` (PASS with validation gap)
|
||||||
|
|
||||||
|
`S3Storage::put` (`s3.rs:100-183`) and `upload_parts` (`s3.rs:238-291`) implement the standard **read-first-chunk → 1-part PutObject vs. multipart** pattern correctly:
|
||||||
|
|
||||||
|
- Single-part upload when `filled < MULTIPART_CHUNK_SIZE` (`s3.rs:117`). ✅
|
||||||
|
- First chunk becomes part 1 of the multipart (`s3.rs:144-151`). ✅
|
||||||
|
- Loop reads full 8 MiB chunks and uploads each; breaks when a read returns empty. ✅
|
||||||
|
- On part-upload error, `abort_multipart_upload` is invoked (`s3.rs:171-181`) — proper cleanup. ✅
|
||||||
|
- `CompletedPart` is built with `e_tag` + `part_number`. ✅
|
||||||
|
- `complete_multipart_upload` is sent with the accumulated parts (`s3.rs:154-169`). ✅
|
||||||
|
|
||||||
|
**Gap — MEDIUM — `s3.rs:248` / `s3.rs:269` (part-number overflow, no client cap):** `part_number` is `i32`, starting at 1 and incremented per part. S3 allows **max 10,000 parts**. With `MULTIPART_CHUNK_SIZE = 8 MiB` (`s3.rs:21`), the silent failure point is **~80 GiB**. At part 10001 the AWS SDK returns a server error; the code maps it to `StorageError::Io` *after* a part has already been uploaded, triggering `abort_multipart_upload` (good — cleanup runs). But the failure should be detectLine truncated
|
||||||
|
|
||||||
|
- **`s3.rs:21` comment mismatch:** `// S3's minimum multipart part size (5 MiB)` but `MULTIPART_CHUNK_SIZE = 8 MiB`. Fix the comment. ✅-ish but misleading docs.
|
||||||
|
|
||||||
|
- **LOW — `s3.rs:265`:** `resp.e_tag().unwrap_or_default()` — if S3 omits the ETag (rare), an empty string is sent in `CompletedPart`. Some backends reject empty ETags. Use `unwrap_or("")` explicitly with a TODO, or skip. Minor.
|
||||||
|
|
||||||
|
## 4. Error-handling gaps — all backends
|
||||||
|
|
||||||
|
### 4.1 Fragile NotFound detection (`s3.rs:295-297`, `gcs.rs:66`, `gcs.rs:125`)
|
||||||
|
|
||||||
|
`is_not_found` (`s3.rs:295`):
|
||||||
|
```rust
|
||||||
|
fn is_not_found<E: std::fmt::Debug>(e: &E) -> bool {
|
||||||
|
format!("{e:?}").contains("NotFound") || format!("{e:?}").contains("404")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`404` substring match** will false-positive on any error whose Debug string mentions "404" (e.g. a 4048-port URL, a `4040`-style code in a message). HIGH risk for incorrect `NotFound` mapping on S3.
|
||||||
|
- Relies on `Debug`, not typed error matching. The `aws-sdk-s3` v1 `Error` enum exposes typed `NoSuchKey`/`NotFound` via `.kind()` — prefer `e.kind() == Some(ErrorCode::NoSuchKey)` or the `Display`-based `NotFound` variant.
|
||||||
|
|
||||||
|
GCS (`gcs.rs:66`, `gcs.rs:125`) uses `e.to_string().contains("404")` — same `404` false-positive problem; GCS errors surface `404` in `Display` for `Status` but a non-NotFound 404-ish message would be mis-mapped to `NotFound`. MEDIUM.
|
||||||
|
|
||||||
|
**Recommendation:** match on typed error variants / HTTP status codes, not substring.
|
||||||
|
|
||||||
|
### 4.2 Inconsistent `delete` semantics (MEDIUM)
|
||||||
|
|
||||||
|
- `local.rs:66-75`: deleting a missing object → `StorageError::NotFound`. ✅ (documented in test)
|
||||||
|
- `s3.rs:185-194`: `delete_object` on a missing key returns **204 No Content** from S3 (idempotent) → `Ok(())`. ✅ consistent with S3 semantics.
|
||||||
|
- `gcs.rs:96-105`: `delete_object` on missing → GCS returns **404**, mapped to `StorageError::Io` (via `map_err`), **not** `NotFound`. **Inconsistent**: `GcsStorage::delete` of a missing object errors with `Io`, while `LocalFileStorage::delete` errors with `NotFound`. Callers handling "ignore missing" must special-case. MEDIUM.
|
||||||
|
|
||||||
|
### 4.3 `StorageError` has no `Backend`-level detail, only `Io(String)` (LOW)
|
||||||
|
|
||||||
|
`traits.rs:17` — `Io(String)` collapses all transport errors into a string. No structured access to the underlying `std::io::Error` (e.g. `ErrorKind::UnexpectedEof`). Limits retry logic. Acceptable for v1, but `map_sdk_err` (`s3.rs:23-25`) re-formats via `{e:?}` (Debug) — callers see a Debug dump, not a clean message. Consider `Display`. LOW.
|
||||||
|
|
||||||
|
### 4.4 `stat` size coercion (LOW — `s3.rs:227`)
|
||||||
|
|
||||||
|
`resp.content_length().unwrap_or(0).max(0) as u64` — `content_length()` returns `Option<i64>`; `.max(0)` clamps negatives. Fine, but the `.unwrap_or(0)` for a *missing* object is unreachable here because a 404 is mapped to `NotFound` first. OK.
|
||||||
|
|
||||||
|
## 5. Feature / API consistency gaps (MEDIUM)
|
||||||
|
|
||||||
|
| Operation | `local` (default) | `s3` | `gcs` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `put` streaming | ✅ streaming | ✅ multipart streaming | **❌ buffers whole file in `Vec<u8>`** (`gcs.rs:76-79`) — contradicts crate promise "never holds the whole object in memory" |
|
||||||
|
| `get` streaming | ✅ file handle | ✅ `into_async_read` | ✅ but actually **downloads full object into `Vec<u8>`** then re-boxes (`gcs.rs:54-72`) — not true streaming |
|
||||||
|
| Multipart / resumable | n/a | ✅ multipart | ❌ none (simple upload only) |
|
||||||
|
| `not_impl!` for disabled features | n/a | the trait is always compiled; backends are feature-gated at module level | ✅ |
|
||||||
|
|
||||||
|
**`gcs.rs:75-93` `put`:** `data.read_to_end(&mut buf)` materializes the *entire* upload in memory. The module doc (`gcs.rs:4-8`) admits this. For a storage abstraction whose USP is "huge objects never buffered in memory", GCS is the weak backend. Recommend implementing resumable upload (`google-cloud-storage` supports `upload_types::Resumable`) to match S3.
|
||||||
|
|
||||||
|
**Feature consistency note:** because `StorageDriver` is in `traits.rs` (always compiled) and `tokio` is declared with only `io-util` (`Cargo.toml`), consumers using `s3`/`gcs` without the `tokio` full feature for their own runtime could hit compile friction. Minor — `Cargo.toml` `dev-dependencies` pulls `tokio = { features = ["full"] }`. LOW.
|
||||||
|
|
||||||
|
## 6. Build / clippy
|
||||||
|
|
||||||
|
`cargo clippy -p mytheclipse-storage --all-features` → **0 warnings**. ✅
|
||||||
|
`cargo test -p mytheclipse-storage --all-features` → **5 passed**, 2 ignored (live-only). ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| # | Area | Severity | Location |
|
||||||
|
|---|------|----------|----------|
|
||||||
|
| 1 | No real path traversal (control rejected) | — | `local.rs:25-33` ✅ |
|
||||||
|
| 2 | No OS file-handle leak (Rust Drop closes files) | — | `local.rs:57-63` ✅ |
|
||||||
|
| 3 | `put` missing flush/sync (durability) | MEDIUM | `local.rs:60-62` |
|
||||||
|
| 4 | Failed `put` leaves partial/truncated file | MEDIUM | `local.rs:57-63` |
|
||||||
|
| 5 | `get` stream handle lifetime not documented | LOW | `local.rs:38-48`, `traits.rs:56-58` |
|
||||||
|
| 6 | Multipart part-number not capped at 10000 | MEDIUM | `s3.rs:248,269` |
|
||||||
|
| 7 | Comment says 5 MiB min, value is 8 MiB | LOW | `s3.rs:21` |
|
||||||
|
| 8 | Fragile `404`/`NotFound` substring matching (S3) | HIGH | `s3.rs:295-297` |
|
||||||
|
| 9 | Fragile `404` substring matching (GCS) | MEDIUM | `gcs.rs:66,125` |
|
||||||
|
| 10 | Inconsistent `delete` on missing object (GCS returns `Io`, not `NotFound`) | MEDIUM | `gcs.rs:96-105` vs `local.rs:66-75` |
|
||||||
|
| 11 | GCS `put`/`get` buffer whole object (no resumable/multipart) | MEDIUM | `gcs.rs:54-93` |
|
||||||
|
| 12 | `map_sdk_err` uses `Debug` format for messages | LOW | `s3.rs:23-25` |
|
||||||
|
| 13 | `CompletedPart` ETag `unwrap_or_default()` can be empty | LOW | `s3.rs:265` |
|
||||||
|
| 14 | `StorageError::Io(String)` loses structured io::Error | LOW | `traits.rs:17-18` |
|
||||||
|
|
||||||
|
## Recommended remediation (priority)
|
||||||
|
|
||||||
|
1. **(HIGH)** `s3.rs:295` / `gcs.rs:66,125` — replace `format!("{e:?}").contains("404")` with typed error-variant matching (S3 `Error::kind()` / `ErrorCode::NoSuchKey`; GCS `Error::status.code() == 404`).
|
||||||
|
2. **(MEDIUM)** `s3.rs` — validate `part_number <= 10000` client-side and return a clear `StorageError` before exceeding; fix the `5 MiB` → `8 MiB` comment.
|
||||||
|
3. **(MEDIUM)** `local.rs` — write to a temp file and `rename` on success; clean up on error (`remove_file`).
|
||||||
|
4. **(MEDIUM)** `local.rs` — `put` should `file.flush().await` (and optionally `sync_all`) before returning `Ok`.
|
||||||
|
5. **(MEDIUM)** `gcs.rs` — implement resumable uploads to restore the streaming contract; map GCS 404 on `delete` to `NotFound` for cross-backend consistency.
|
||||||
|
6. **(LOW)** `s3.rs:265` — handle missing ETag explicitly; `traits.rs` — consider `StorageError::Io` carrying the raw `io::Error` or an `ErrorKind` for retry heuristics.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corex-storage"
|
name = "mytheclipse-storage"
|
||||||
version = "1.2.0"
|
version = "1.3.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
documentation = "https://docs.rs/corex-storage"
|
documentation = "https://docs.rs/mytheclipse-storage"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
description = "Unified storage & file system abstraction: one driver interface over Local Disk, S3/MinIO, and Google Cloud Storage, with stream-based upload/download."
|
description = "Unified storage & file system abstraction: one driver interface over Local Disk, S3/MinIO, and Google Cloud Storage, with stream-based upload/download."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
File renamed without changes.
File renamed without changes.
Loaded 50 of 57 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user