11 KiB
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() and caught — good.
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):putcallstokio::io::copy(&mut data, &mut file)then returnsOk(written)with nofile.flush().awaitand nosync_all. The data sits in the kernel page cache; on process/crash before close it may be lost, and the returnedwrittencount reflects bytes handed tocopy, 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::createtruncates the destination, then ifcopyfails partway the function returnsErr(StorageError::Io)but a partially-written, truncated file remains atfull. 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")) andrenameonly on success; on error,remove_file. -
LOW —
local.rs:38-48(get stream lifetime):getreturnsObjectStreamwrapping the openFile. The handle stays open until the consumer drains/drops the stream. If a caller abandons the stream, the handle is held until theObjectStreamis dropped (correct), but the trait offers no explicit "release" — callers must scope-drop the stream. This is inherent to streaming APIs, but the doc onStorageDriver::getshould 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_uploadis invoked (s3.rs:171-181) — proper cleanup. ✅ CompletedPartis built withe_tag+part_number. ✅complete_multipart_uploadis 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 detected client-side with a clear StorageError before attempting an illegal part number, and the constant 8 MiB (s3.rs:21) exceeds S3's documented minimum of 5 MiB — the comment says "5 MiB" but the value is 8 * 1024 * 1024. The comment is wrong.
-
s3.rs:21comment mismatch:// S3's minimum multipart part size (5 MiB)butMULTIPART_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 inCompletedPart. Some backends reject empty ETags. Useunwrap_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):
fn is_not_found<E: std::fmt::Debug>(e: &E) -> bool {
format!("{e:?}").contains("NotFound") || format!("{e:?}").contains("404")
}
404substring match will false-positive on any error whose Debug string mentions "404" (e.g. a 4048-port URL, a4040-style code in a message). HIGH risk for incorrectNotFoundmapping on S3.- Relies on
Debug, not typed error matching. Theaws-sdk-s3v1Errorenum exposes typedNoSuchKey/NotFoundvia.kind()— prefere.kind() == Some(ErrorCode::NoSuchKey)or theDisplay-basedNotFoundvariant.
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_objecton a missing key returns 204 No Content from S3 (idempotent) →Ok(()). ✅ consistent with S3 semantics.gcs.rs:96-105:delete_objecton missing → GCS returns 404, mapped toStorageError::Io(viamap_err), notNotFound. Inconsistent:GcsStorage::deleteof a missing object errors withIo, whileLocalFileStorage::deleteerrors withNotFound. 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)
- (HIGH)
s3.rs:295/gcs.rs:66,125— replaceformat!("{e:?}").contains("404")with typed error-variant matching (S3Error::kind()/ErrorCode::NoSuchKey; GCSError::status.code() == 404). - (MEDIUM)
s3.rs— validatepart_number <= 10000client-side and return a clearStorageErrorbefore exceeding; fix the5 MiB→8 MiBcomment. - (MEDIUM)
local.rs— write to a temp file andrenameon success; clean up on error (remove_file). - (MEDIUM)
local.rs—putshouldfile.flush().await(and optionallysync_all) before returningOk. - (MEDIUM)
gcs.rs— implement resumable uploads to restore the streaming contract; map GCS 404 ondeletetoNotFoundfor cross-backend consistency. - (LOW)
s3.rs:265— handle missing ETag explicitly;traits.rs— considerStorageError::Iocarrying the rawio::Erroror anErrorKindfor retry heuristics.