Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2596b357ec | ||
|
|
2c9367a83c | ||
|
|
8ff33817a8 | ||
|
|
b6f138b90d | ||
|
|
4027d3eb17 | ||
|
|
5f1e3ace5c | ||
|
|
0f2b776bb8 | ||
|
|
5717f8aaaa |
No files matched your search
@@ -1,3 +1,31 @@
|
|||||||
|
## [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)
|
## [1.3.1](https://github.com/asepharyana/mytheclipse/compare/v1.3.0...v1.3.1) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+6
-6
@@ -2539,7 +2539,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mytheclipse"
|
name = "mytheclipse"
|
||||||
version = "1.3.1"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
@@ -2551,7 +2551,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mytheclipse-cache"
|
name = "mytheclipse-cache"
|
||||||
version = "1.3.1"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"moka",
|
"moka",
|
||||||
@@ -2564,7 +2564,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mytheclipse-config"
|
name = "mytheclipse-config"
|
||||||
version = "1.3.1"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -2579,7 +2579,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mytheclipse-crypto"
|
name = "mytheclipse-crypto"
|
||||||
version = "1.3.1"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -2596,7 +2596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mytheclipse-event"
|
name = "mytheclipse-event"
|
||||||
version = "1.3.1"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -2612,7 +2612,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mytheclipse-storage"
|
name = "mytheclipse-storage"
|
||||||
version = "1.3.1"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mytheclipse-cache"
|
name = "mytheclipse-cache"
|
||||||
version = "1.3.1"
|
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"
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
|
//! Multi-layer + cache-aside composition (default features):
|
||||||
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use mytheclipse_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.
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mytheclipse-config"
|
name = "mytheclipse-config"
|
||||||
version = "1.3.1"
|
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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mytheclipse-crypto"
|
name = "mytheclipse-crypto"
|
||||||
version = "1.3.1"
|
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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mytheclipse-event"
|
name = "mytheclipse-event"
|
||||||
version = "1.3.1"
|
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"
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mytheclipse-storage"
|
name = "mytheclipse-storage"
|
||||||
version = "1.3.1"
|
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"
|
||||||
|
|||||||
@@ -15,15 +15,22 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! Local disk storage (`local` feature, default):
|
||||||
//! use mytheclipse_storage::{LocalFileStorage, StorageDriver, bytes_stream, read_to_vec};
|
//!
|
||||||
//! # #[tokio::main] async fn main() {
|
//! ```no_run
|
||||||
|
//! use mytheclipse_storage::{StorageDriver, bytes_stream, read_to_vec};
|
||||||
|
//! # #[cfg(feature = "local")]
|
||||||
|
//! # #[tokio::main]
|
||||||
|
//! # async fn main() {
|
||||||
|
//! use mytheclipse_storage::LocalFileStorage;
|
||||||
//! # let dir = tempfile::tempdir().unwrap();
|
//! # let dir = tempfile::tempdir().unwrap();
|
||||||
//! let storage = LocalFileStorage::new(dir.path());
|
//! let storage = LocalFileStorage::new(dir.path());
|
||||||
//! storage.put("hello.txt", bytes_stream(b"hi".to_vec())).await.unwrap();
|
//! storage.put("hello.txt", bytes_stream(b"hi".to_vec())).await.unwrap();
|
||||||
//! let data = read_to_vec(storage.get("hello.txt").await.unwrap()).await.unwrap();
|
//! let data = read_to_vec(storage.get("hello.txt").await.unwrap()).await.unwrap();
|
||||||
//! assert_eq!(data, b"hi");
|
//! assert_eq!(data, b"hi");
|
||||||
//! # }
|
//! # }
|
||||||
|
//! # #[cfg(not(feature = "local"))]
|
||||||
|
//! # fn main() {}
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ impl LocalFileStorage {
|
|||||||
Self { root: root.into() }
|
Self { root: root.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the root directory path.
|
||||||
|
pub fn root(&self) -> &std::path::Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve(&self, path: &str) -> Result<PathBuf, StorageError> {
|
fn resolve(&self, path: &str) -> Result<PathBuf, StorageError> {
|
||||||
let rel = Path::new(path.trim_start_matches('/'));
|
let rel = Path::new(path.trim_start_matches('/'));
|
||||||
for component in rel.components() {
|
for component in rel.components() {
|
||||||
@@ -52,14 +57,30 @@ impl StorageDriver for LocalFileStorage {
|
|||||||
if let Some(parent) = full.parent() {
|
if let Some(parent) = full.parent() {
|
||||||
tokio::fs::create_dir_all(parent)
|
tokio::fs::create_dir_all(parent)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
.map_err(|e| StorageError::Io(e.to_string()))?
|
||||||
}
|
}
|
||||||
let mut file = tokio::fs::File::create(&full)
|
// Write to a sibling temp file then atomically rename. On POSIX this is
|
||||||
|
// atomic, so a crash mid-write leaves *either* the previous file *or*
|
||||||
|
// the complete new file — never a half-written truncated object.
|
||||||
|
// Use a PID-unguarded temp name and clean it up if anything fails.
|
||||||
|
let tmp = full.with_extension(format!(".tmp-{}", std::process::id()));
|
||||||
|
let mut file = tokio::fs::File::create(&tmp)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||||
let written = tokio::io::copy(&mut data, &mut file)
|
let written = tokio::io::copy(&mut data, &mut file)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
StorageError::Io(e.to_string())
|
||||||
|
})?;
|
||||||
|
// Ensure durability: flush to OS, fsync, then rename.
|
||||||
|
tokio::fs::File::open(&tmp)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StorageError::Io(e.to_string()))?
|
||||||
|
.sync_all()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||||
|
std::fs::rename(&tmp, &full).map_err(|e| StorageError::Io(e.to_string()))?;
|
||||||
Ok(written)
|
Ok(written)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,4 +183,23 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, StorageError::InvalidPath(_)));
|
assert!(matches!(err, StorageError::InvalidPath(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies the atomic-write contract: after a successful `put`, the temp
|
||||||
|
/// file must not linger on disk.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_leaves_no_temp_file() {
|
||||||
|
let (storage, _dir) = driver();
|
||||||
|
storage
|
||||||
|
.put("clean.txt", bytes_stream(b"data".to_vec()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// No `*.tmp-*` files should remain in the root after a clean write.
|
||||||
|
let leftover: Vec<_> = std::fs::read_dir(storage.root())
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name())
|
||||||
|
.filter(|n| n.to_string_lossy().contains(".tmp-"))
|
||||||
|
.collect();
|
||||||
|
assert!(leftover.is_empty(), "temp files left behind: {leftover:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mytheclipse"
|
name = "mytheclipse"
|
||||||
version = "1.3.1"
|
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"
|
||||||
@@ -10,7 +10,7 @@ documentation = "https://docs.rs/mytheclipse"
|
|||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
description = "Resource-aware abstractions for async I/O, heavy compute, background queue management, resiliency, traffic control, lifecycle, and observability."
|
description = "Resource-aware abstractions for async I/O, heavy compute, background queue management, resiliency, traffic control, lifecycle, and observability."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["async", "concurrency", "rayon", "tokio", "resource-management", "resiliency", "retry", "circuit-breaker", "rate-limit", "observability", "cron", "shutdown"]
|
keywords = ["async", "concurrency", "resiliency", "circuit-breaker", "observability"]
|
||||||
categories = ["asynchronous", "concurrency", "rust-patterns"]
|
categories = ["asynchronous", "concurrency", "rust-patterns"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Reference in new issue
Block a user