Skip to content

fix(file-storage): remediate audit findings across cleanup, multipart, authz, and docs#4218

Open
ffedoroff wants to merge 4 commits into
constructorfabric:mainfrom
ffedoroff:fix/file-storage-audit-remediation
Open

fix(file-storage): remediate audit findings across cleanup, multipart, authz, and docs#4218
ffedoroff wants to merge 4 commits into
constructorfabric:mainfrom
ffedoroff:fix/file-storage-audit-remediation

Conversation

@ffedoroff

@ffedoroff ffedoroff commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

A doc-vs-implementation audit of gears/file-storage (all of docs/ cross-checked against the code) surfaced correctness bugs, security gaps, and systemic documentation drift. This PR remediates them in staged batches, each verified with the full test suite.

Split note: the independent Postgres-jsonb entity fix, the S3 readiness-probe fix, and a few standalone doc corrections were split into #4219 to keep both PRs under the 50-file review limit. The two PRs are independent and can merge in any order.

Data loss / integrity

  • Cleanup sweep step 1 now uses the status-guarded delete_pending_version: a version finalized between the sweep's list and delete no longer gets destroyed along with its blob.
  • Expired multipart sessions always get backend.abort_multipart, even when sweep step 1 already reclaimed the version row (previously leaked S3 MPU handles).
  • Abort / expired-session sweep now delete multipart_upload_parts rows (previously accumulated forever, contradicting the feature doc's checked acceptance criterion).
  • Create-exclusive blob publish (local-fs via hard_link, in-memory check-and-insert under one mutex): replaying a still-valid PUT token against a published object returns 409 instead of silently overwriting finalized content — restoring the immutable-blob invariant ADR-0003 promises.

DoS / limits

  • MAX_PART_COUNT = 10_000 enforced uniformly in compute_plan with part-size widening (up to 5 GiB) before rejection; declared_size = u64::MAX is rejected without allocation (previously ~84 TB Vec reservation → process abort); plans S3 would reject at part 10 001 are no longer minted.
  • New multipart_session_ttl_secs config (default 24 h) decouples multipart session lifetime from the 15-minute signed-URL TTL, making introspect/resume actually usable for large uploads.

Authorization / consistency

  • File-scope retention-rule authz prefetches with the caller's tenant scope instead of allow_all — closes a cross-tenant file-existence oracle (201-vs-404).
  • Orphaned file-scope retention rules (target file deleted) are deletable again; foreign tenants still get 404.
  • transfer_ownership returns the pre-captured row instead of a post-commit re-read under the stale scope (could 404 after a committed swap).
  • Idempotent create_file replay re-validates against the current effective policy.
  • GET /policy?scope=user without scope_owner_id → 400 (was silent 204).
  • report-part validates the 32-byte hash exactly like finalize does.

API / infra

  • file.metadata_updated event emitted transactionally with the mutation (was documented, never emitted).
  • Range parsing per RFC 9110 (start>end ignored → 200, strict digits, no +); 416 responses carry Accept-Ranges.
  • Sidecar fails fast on malformed numeric env vars (was: silent fallback to defaults).
  • validate() covers page-size/TTL cross-field rules; GET /files returns real custom_metadata via one batched query; missing 400/409 declarations added to OpenAPI routes (docs/api/api.json regenerated).

Documentation sync

  • operations.md, DESIGN.md (token codec/claims, ETag formula, hash pipeline, sidecar MIME), api.md (real sidecar headers with an explicit Planned section, actual contracts and status codes), migration.sql (hash-mode DDL, exact event/operation catalogs), ADR-0003/0005 corrections.
  • Unimplemented documented features (X-FS-* headers, ip/tok.* claims, enabled_event_types gating, multipart part-hash trust gap) explicitly marked as tracked gaps instead of shipped behavior.
  • Stale temp plans DEFERRED_ITEMS_PLAN.txt / IMPLEMENTATION_PLAN_TEMP.txt deleted; the one still-open item (content_id FK deferral) moved to DECOMPOSITION.md.

Test plan

  • cargo test -p cf-gears-file-storage -p cf-gears-file-storage-sdk on this branch alone (without fix(file-storage): Postgres jsonb binding, S3 readiness probe, standalone doc corrections #4219) — 407 passed, 0 failed
  • cargo clippy --all-targets clean, cargo fmt --check clean, cfs validate — 0 errors
  • cargo build --bin sidecar clean
  • New regression tests for every behavioral fix (sweep race, part-row deletion, replay 409, part-count ceiling, session TTL, authz oracle, orphaned-rule delete, policy re-check on replay, metadata event, Range edge cases, env fail-fast, batched list metadata)

https://claude.ai/code/session_01WZUBxb7myjUZHMYmbRtJqG

Summary by CodeRabbit

  • New Features

    • File listings now include custom metadata via batched lookup.
    • Multipart uploads now use independent session expiration and enforce a maximum part count.
    • Aborted multipart uploads now remove associated part rows.
    • File metadata updates now enqueue the corresponding file event.
  • Bug Fixes

    • Upload replay no longer overwrites existing content; conflicts return 409.
    • Multipart part hash validation is stricter, and 416 responses now include Accept-Ranges: bytes.
    • Cleanup sweeps are race-safe against concurrent finalize operations.
    • Idempotency/policy replay is re-validated with the current effective policy.
  • Documentation

    • Updated API error/status documentation and File Storage behavior/config guidance.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ffedoroff, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f749adc6-0213-436f-a979-889e08219a12

📥 Commits

Reviewing files that changed from the base of the PR and between 2d024ea and d8dc09c.

📒 Files selected for processing (1)
  • gears/file-storage/docs/DESIGN.md
📝 Walkthrough

Walkthrough

File Storage updates add exclusive upload publication, stricter range and multipart validation, independent multipart session TTLs, safer cleanup and authorization behavior, batched metadata and transactional events, configuration/schema changes, expanded API error documentation, and aligned architecture and operational documentation.

Changes

File Storage behavior

Layer / File(s) Summary
Exclusive uploads and range handling
gears/file-storage/file-storage/src/infra/backend/*, gears/file-storage/file-storage/src/bin/sidecar.rs, gears/file-storage/file-storage/src/infra/content/*
Uploads publish without overwriting existing blobs, replay outcomes return 409 when appropriate, invalid numeric environment values fail startup, and range parsing rejects invalid syntax while exposing Accept-Ranges on 416 responses.
Multipart lifecycle and cleanup
gears/file-storage/file-storage/src/domain/multipart*, gears/file-storage/file-storage/src/domain/cleanup.rs, gears/file-storage/file-storage/src/infra/storage/*multipart*, gears/file-storage/file-storage/tests/*
Multipart planning enforces MAX_PART_COUNT, session expiry is independent from part URL expiry, abort removes part rows transactionally, expired sessions clean up missing-version cases, and reported hashes must be 32-byte SHA-256 values.
Authorization, metadata, and events
gears/file-storage/file-storage/src/domain/policy_service.rs, gears/file-storage/file-storage/src/domain/service/*, gears/file-storage/file-storage/src/infra/storage/*metadata*, gears/file-storage/file-storage/tests/*
Policy replay checks current rules, retention authorization is tenant-scoped, file metadata is listed in batches, metadata updates enqueue transactional events, and ownership-transfer responses reflect committed ownership fields.
Configuration and schema
gears/file-storage/file-storage/src/config.rs, gears/file-storage/file-storage/src/gear.rs, gears/file-storage/docs/migration.sql
Configuration validates TTL and page-size relationships, adds multipart session TTL settings, wires the setting into MultipartService, and documents content-hash mode columns and manifests.
API and architecture contracts
gears/file-storage/docs/api.md, gears/file-storage/docs/DESIGN.md, docs/api/api.json, gears/file-storage/docs/ADR/*, gears/file-storage/docs/operations.md
API status codes, signed-token claims, sidecar responsibilities, MIME validation, multipart semantics, backend capabilities, startup configuration, and shipped implementation status are updated.
Feature and implementation notes
gears/file-storage/README.md, gears/file-storage/docs/features/*, gears/file-storage/docs/DECOMPOSITION.md
Known multipart hash-trust, event-outbox, retention, audit, ownership-transfer, cleanup, and deferred-FK behavior is documented.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: artifizer, mikefalcon77, striped-zebra-dev

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the PR’s broad file-storage remediation work across cleanup, multipart, authorization, and docs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ffedoroff
ffedoroff force-pushed the fix/file-storage-audit-remediation branch from db0622e to f1fd616 Compare July 16, 2026 14:44
@ffedoroff
ffedoroff force-pushed the fix/file-storage-audit-remediation branch 2 times, most recently from 3ed3292 to 00cc7c8 Compare July 16, 2026 16:33
…, authz, and docs

Doc-vs-implementation audit of gears/file-storage surfaced correctness bugs,
security gaps, and systemic doc drift. Fixes, by area:

Data loss / integrity:
- cleanup sweep step 1 now uses the status-guarded delete_pending_version,
  so a version finalized between list and delete survives the sweep
- backend abort_multipart now always fires for expired sessions, even when
  step 1 already reclaimed the version row (S3 MPU handle leak)
- abort and expired-session sweep delete multipart_upload_parts rows
- create-exclusive blob publish (local-fs via hard_link, in-memory under one
  mutex): PUT-token replay against a published object returns 409 instead of
  silently overwriting finalized content

Postgres compatibility:
- policies.body / retention_rules.body entities switched from Text to Json,
  matching the jsonb DDL; writes previously failed on Postgres (suite runs
  SQLite only); regression tests pin the column type

DoS / limits:
- MAX_PART_COUNT=10000 enforced uniformly in compute_plan with part-size
  widening up to 5 GiB before rejection; huge declared_size rejected without
  allocation; plans that S3 would reject at part 10001 are no longer minted
- new multipart_session_ttl_secs config (default 24h) decouples session
  lifetime from the 15-minute signed-URL TTL

Authorization / consistency:
- file-scope retention-rule authz prefetches with tenant scope, closing a
  cross-tenant file-existence oracle; orphaned file-scope rules deletable
- transfer_ownership returns the pre-captured row instead of a post-commit
  re-read under the stale scope
- idempotent create replay re-validates against the current policy
- GET /policy?scope=user without scope_owner_id returns 400
- report-part validates the 32-byte hash like finalize does

API / infra:
- file.metadata_updated event emitted transactionally with the mutation
- Range parsing per RFC 9110 (start>end ignored, strict digits); 416 carries
  Accept-Ranges; S3 is_ready probes the bucket (ListObjectsV2) instead of
  folding NoSuchBucket into ready; sidecar fails fast on malformed numeric
  env vars; config validate() covers page-size/TTL cross-field rules;
  GET /files returns real custom_metadata via one batched query; missing
  400/409 declarations added to OpenAPI routes

Documentation sync:
- operations.md, DESIGN.md (token codec/claims, ETag formula, hash pipeline,
  sidecar MIME), api.md (real sidecar headers with an explicit Planned
  section, actual contracts and status codes), migration.sql (hash-mode DDL,
  exact event/operation catalogs), ADR-0003/0005/0006 corrections
- unimplemented documented features (X-FS-* headers, ip/tok.* claims,
  enabled_event_types gating, part-hash trust gap) marked as tracked gaps
- stale temp plans DEFERRED_ITEMS_PLAN.txt / IMPLEMENTATION_PLAN_TEMP.txt
  deleted; the one still-open item (content_id FK) moved to DECOMPOSITION.md

Claude-Session: https://claude.ai/code/session_01WZUBxb7myjUZHMYmbRtJqG

Signed-off-by: Roland From <rfedorov@linkentools.com>
@ffedoroff
ffedoroff force-pushed the fix/file-storage-audit-remediation branch from 00cc7c8 to 9d2d7d9 Compare July 16, 2026 17:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
gears/file-storage/file-storage/src/domain/service/write.rs (1)

471-501: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Derive the event revision inside the transaction.

With no expected_meta_version, concurrent patches can both read revision N and enqueue N+1, while the second transaction commits revision N+2. Obtain the updated revision inside patch_metadata_atomic and build the event payload from that committed value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/file-storage/src/domain/service/write.rs` around lines 471
- 501, The event revision is computed before the transaction, allowing
concurrent patches to enqueue stale values. Update patch_metadata_atomic and its
caller to derive the post-CAS meta_version inside the transaction, then
construct the file.metadata_updated event payload from that committed revision
before enqueueing or committing it; remove the pre-transaction file.meta_version
+ 1 calculation.
🧹 Nitpick comments (1)
gears/file-storage/file-storage/tests/multipart_test.rs (1)

3426-3479: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert both sides of each TTL contract.

These one-sided checks still pass for already-expired URLs, excessively long sessions, or resume tokens extending beyond the session. Add bounded tolerance windows and verify claims.exp <= session.expires_at.

Proposed assertions
+    assert!(
+        plan.expires_at >= before + time::Duration::seconds(60 - 5)
+    );
     assert!(
         plan.expires_at <= after + time::Duration::seconds(60 + 5)
     );

     assert!(
         session.expires_at >= before + time::Duration::seconds(3600 - 5)
     );
+    assert!(
+        session.expires_at <= after + time::Duration::seconds(3600 + 5)
+    );

     assert!(
         claims.exp > (before + time::Duration::seconds(60)).unix_timestamp()
     );
+    assert!(claims.exp <= session.expires_at.unix_timestamp());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/file-storage/tests/multipart_test.rs` around lines 3426 -
3479, Strengthen the TTL assertions in the multipart test around
plan.expires_at, session.expires_at, and claims.exp: add upper and lower
tolerance bounds so expired or excessively long values fail, while preserving
the expected short URL TTL and long session TTL. Also assert that claims.exp
does not exceed session.expires_at, using the existing session expiry and token
verification flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@gears/file-storage/docs/ADR/0004-cpt-cf-file-storage-adr-signed-url-transport.md`:
- Around line 155-156: Update the ADR’s migration-status references so the
PASETO v4.public and kid/key-rotation work has one unambiguous tracking status:
remove the stale “no dedicated ticket exists” Tier-4 statement near the cited
passage, or revise the later “Tier 4 item 4.9” reference to match the actual
status.

In `@gears/file-storage/docs/api.md`:
- Around line 528-530: Update the upload-integrity sentence in the API
documentation to remove the claim that hashing occurs at bind. State that
single-part size/hash verification occurs during finalize, while multipart
completion derives the composite hash from reported parts without a full
assembled-object read-back; preserve the existing signed-URL and
max_size/expected_hash context.

In `@gears/file-storage/docs/DESIGN.md`:
- Line 1227: Update the hash_algorithm row in DESIGN.md to describe SHA-256 as
the single algorithm with both whole-object and multipart-composite hash modes,
replacing the inaccurate “single mode” wording while preserving the existing
migration references.
- Around line 1584-1602: The earlier sidecar responsibility section must stop
claiming enforcement of IP/CIDR and token-claim predicates. Update that section
to list only the implemented checks: signature, expiry, operation, target
binding, and streaming size/hash constraints, matching the behavior described in
the verification checklist.
- Around line 1516-1522: Update the earlier signed-URL responsibility section to
remove its contradictory claim that backend_id and backend_path are resolved
from the version row and excluded from the token. Align that description with
the authoritative Claims section by stating that both fields are carried in the
signed token for stateless sidecar resolution.
- Around line 315-323: Update the remaining configuration documentation and
BackendConfig model descriptions to remove stale “static TOML” and “loaded from
TOML” references. Align them with the gear YAML FileStorageConfig source and
FS_SIDECAR_* environment variables described in the configuration section,
without changing the documented runtime behavior.
- Around line 608-619: Update the preceding section rationale and all related
responsibility references to remove the superseded two-tap content-pipeline
model, including centralized sidecar SHA-256 and in-stream magic-byte detection.
Align the documentation with the shipped split described here: sidecar handlers
hash incrementally, while control-plane finalize/complete performs MIME
validation after storage with the existing 400 rejection behavior; do not
describe or imply an in-stream 415 flow.
- Around line 62-63: Update DESIGN.md to remove all remaining claims that the
sidecar connects to or reads from the metadata DB, including the SC --> DB
deployment-diagram relationship, the technology-table entry, and later SDK-based
DB-read references. Preserve the documented boundary that only the control plane
accesses the metadata DB and the sidecar uses verified signed-token claims.

In `@gears/file-storage/file-storage/src/bin/sidecar.rs`:
- Around line 53-75: Update upload and its PublishOutcome handling so a rejected
exclusive publish never finalizes from the rejected attempt’s size/hash.
Validate size and digest before committing the new blob, or obtain trustworthy
metadata for an existing blob and require an exact match before finalization;
ensure newly published blobs are not finalized when later checks fail. Add a
regression covering an invalid first PUT followed by a valid retry.

In `@gears/file-storage/file-storage/src/domain/multipart_service.rs`:
- Around line 541-551: Update the resume URL expiration logic in
introspect_multipart_upload to cap each minted part URL at the configured
url_ttl_secs, using the earlier of session.expires_at and now plus url_ttl_secs.
Preserve the session expiration as the upper bound while preventing resumed URLs
from lasting beyond the short URL lifetime.

In `@gears/file-storage/file-storage/src/domain/policy_service.rs`:
- Around line 66-78: Update the shared policy scope/owner validation used by
both read and write paths, including Self/admin handling, to accept only Tenant
+ None, User + Some(owner), and the other valid scope shapes. Reject Tenant +
Some(scope_owner_id) and User + None with validation errors, and reuse this
validator from the relevant read and write methods instead of maintaining
separate checks.
- Around line 263-287: Update the retention-rule lookup flow around
authorize_retention_scope so the rule is first fetched with the caller’s tenant
scope instead of allow_all, preventing foreign-tenant rules from being
distinguishable from missing IDs when resource-less WRITE is denied; preserve
the existing deleted File-scope fallback and scope-specific authorization. In
gears/file-storage/file-storage/tests/policy_authz_test.rs lines 604-659, add
coverage for a foreign caller denied resource-less WRITE and verify the response
matches the nonexistent-rule result.

In `@gears/file-storage/file-storage/src/domain/service/create.rs`:
- Around line 174-192: The replay validation in the create flow must also
enforce the current effective maximum size. Update the logic around
get_effective_policy_internal, PolicyResolver::check_allowed_mime, and
PolicyResolver::check_metadata_limits to compare the stored upload URL’s
max_size with the current policy and reject replay when it exceeds that limit,
or re-sign the URL using the current constraint.

In `@gears/file-storage/file-storage/src/domain/service/read_ops.rs`:
- Around line 45-56: Restrict list_metadata_for_files so callers cannot fetch
metadata for arbitrary file IDs without authorization. Prefer replacing it with
an authorization-bound list_files_with_metadata operation that accepts
SecurityContext and owner, or make the raw batch lookup private/internal and
require a validated authorization scope before use; update handlers::list_files
and callers accordingly while preserving batched retrieval.

In `@gears/file-storage/file-storage/src/infra/backend/mod.rs`:
- Around line 164-234: The default implementation of Backend::publish_exclusive
is racy because its exists-then-put sequence can overwrite data under concurrent
calls, especially for S3Backend. Remove this fallback behavior and require each
production backend, including S3Backend, to provide an atomic create-exclusive
implementation or explicitly return an unsupported error; ensure uploads are not
routed through a non-atomic implementation.

---

Outside diff comments:
In `@gears/file-storage/file-storage/src/domain/service/write.rs`:
- Around line 471-501: The event revision is computed before the transaction,
allowing concurrent patches to enqueue stale values. Update
patch_metadata_atomic and its caller to derive the post-CAS meta_version inside
the transaction, then construct the file.metadata_updated event payload from
that committed revision before enqueueing or committing it; remove the
pre-transaction file.meta_version + 1 calculation.

---

Nitpick comments:
In `@gears/file-storage/file-storage/tests/multipart_test.rs`:
- Around line 3426-3479: Strengthen the TTL assertions in the multipart test
around plan.expires_at, session.expires_at, and claims.exp: add upper and lower
tolerance bounds so expired or excessively long values fail, while preserving
the expected short URL TTL and long session TTL. Also assert that claims.exp
does not exceed session.expires_at, using the existing session expiry and token
verification flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9247a286-75b5-4d5e-9a04-8d6eaa672c78

📥 Commits

Reviewing files that changed from the base of the PR and between e7361e7 and 9d2d7d9.

📒 Files selected for processing (49)
  • docs/api/api.json
  • gears/file-storage/README.md
  • gears/file-storage/docs/ADR/0003-cpt-cf-file-storage-adr-sidecar-data-plane.md
  • gears/file-storage/docs/ADR/0004-cpt-cf-file-storage-adr-signed-url-transport.md
  • gears/file-storage/docs/ADR/0005-cpt-cf-file-storage-adr-s3-client-selection.md
  • gears/file-storage/docs/DECOMPOSITION.md
  • gears/file-storage/docs/DEFERRED_ITEMS_PLAN.txt
  • gears/file-storage/docs/DESIGN.md
  • gears/file-storage/docs/IMPLEMENTATION_PLAN_TEMP.txt
  • gears/file-storage/docs/api.md
  • gears/file-storage/docs/features/audit-trail.md
  • gears/file-storage/docs/features/multipart-coordinator.md
  • gears/file-storage/docs/features/ownership-transfer.md
  • gears/file-storage/docs/features/policy-engine.md
  • gears/file-storage/docs/features/retention-cleanup.md
  • gears/file-storage/docs/migration.sql
  • gears/file-storage/docs/operations.md
  • gears/file-storage/file-storage/src/api/rest/handlers.rs
  • gears/file-storage/file-storage/src/api/rest/routes.rs
  • gears/file-storage/file-storage/src/bin/sidecar.rs
  • gears/file-storage/file-storage/src/bin/sidecar_tests.rs
  • gears/file-storage/file-storage/src/config.rs
  • gears/file-storage/file-storage/src/config_tests.rs
  • gears/file-storage/file-storage/src/domain/cleanup.rs
  • gears/file-storage/file-storage/src/domain/multipart.rs
  • gears/file-storage/file-storage/src/domain/multipart_service.rs
  • gears/file-storage/file-storage/src/domain/policy_service.rs
  • gears/file-storage/file-storage/src/domain/service/create.rs
  • gears/file-storage/file-storage/src/domain/service/read_ops.rs
  • gears/file-storage/file-storage/src/domain/service/write.rs
  • gears/file-storage/file-storage/src/gear.rs
  • gears/file-storage/file-storage/src/infra/backend/backend_tests.rs
  • gears/file-storage/file-storage/src/infra/backend/in_memory.rs
  • gears/file-storage/file-storage/src/infra/backend/local_fs.rs
  • gears/file-storage/file-storage/src/infra/backend/mod.rs
  • gears/file-storage/file-storage/src/infra/content/range.rs
  • gears/file-storage/file-storage/src/infra/content/range_tests.rs
  • gears/file-storage/file-storage/src/infra/storage/repo/metadata_repo.rs
  • gears/file-storage/file-storage/src/infra/storage/repo/multipart_repo.rs
  • gears/file-storage/file-storage/src/infra/storage/store/files.rs
  • gears/file-storage/file-storage/src/infra/storage/store/metadata.rs
  • gears/file-storage/file-storage/src/infra/storage/store/multipart.rs
  • gears/file-storage/file-storage/tests/audit_test.rs
  • gears/file-storage/file-storage/tests/cleanup_test.rs
  • gears/file-storage/file-storage/tests/idempotency_authz_test.rs
  • gears/file-storage/file-storage/tests/multipart_test.rs
  • gears/file-storage/file-storage/tests/ownership_test.rs
  • gears/file-storage/file-storage/tests/policy_authz_test.rs
  • gears/file-storage/file-storage/tests/service_test.rs
💤 Files with no reviewable changes (1)
  • gears/file-storage/docs/DEFERRED_ITEMS_PLAN.txt

Comment thread gears/file-storage/docs/api.md Outdated
Comment thread gears/file-storage/docs/DESIGN.md
Comment thread gears/file-storage/docs/DESIGN.md
Comment thread gears/file-storage/docs/DESIGN.md
Comment thread gears/file-storage/file-storage/src/domain/policy_service.rs Outdated
Comment thread gears/file-storage/file-storage/src/domain/policy_service.rs
Comment thread gears/file-storage/file-storage/src/domain/service/create.rs
Comment thread gears/file-storage/file-storage/src/domain/service/read_ops.rs
Comment on lines +164 to +234
/// Publish a blob at `path`, but **only if nothing is stored there yet**
/// (create-exclusive semantics) — unlike [`Self::put_stream`], which is
/// documented as overwrite-allowed and stays that way for its existing
/// callers (per-part multipart writes, which are deliberately
/// overwrite-safe for resume; `migrate_backend`'s write to a fresh
/// backend). This method exists for exactly one call site: the sidecar's
/// single-shot upload handler, publishing a version's *final*, canonical
/// object at `/{file_id}/{version_id}`.
///
/// # Why this must not just overwrite (P2 remediation — replay-`PUT` fix)
/// A `PUT` token's signature covers `op`/`file_id`/`version_id`/size/hash
/// *constraints*, never the body bytes (DESIGN.md, ADR-0003), and stays
/// valid until `exp`. Once a version has been finalized and bound as a
/// file's live content, a holder of that same still-unexpired token could
/// otherwise re-`PUT` different bytes to the same backend path and
/// silently replace the live, already-served content out from under the
/// recorded size/hash/`ETag`/MIME — a HIGH-severity immutability break,
/// not the merely-orphans-a-version consequence the design previously
/// assumed. Making this call create-exclusive closes that: only the
/// *first* write to a given path ever lands; every subsequent attempt
/// observes `created: false` and the existing bytes are provably
/// untouched.
///
/// The default implementation is a non-atomic (TOCTOU) `exists` check
/// followed by `put` — good enough for a backend with no cheaper atomic
/// primitive, but a real race window exists between the check and the
/// write. [`LocalFsBackend`](super::LocalFsBackend) (`std::fs::hard_link`,
/// which atomically fails with `AlreadyExists` if the target already has
/// a directory entry) and [`InMemoryBackend`](super::InMemoryBackend)
/// (a single mutex guards both the check and the insert) both override
/// this with a truly atomic implementation. `S3Backend` is left on this
/// default fallback (out of scope for this remediation — see its own
/// module doc for the ADR-0005 merge gate).
async fn publish_exclusive(
&self,
path: &str,
stream: futures::stream::BoxStream<'_, std::io::Result<Bytes>>,
max_size: Option<u64>,
) -> Result<PublishOutcome, DomainError> {
use futures::StreamExt;

let mut buf = Vec::new();
let mut stream = stream;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| DomainError::backend(self.id(), e.to_string()))?;
buf.extend_from_slice(&chunk);
if max_size.is_some_and(|m| buf.len() as u64 > m) {
return Err(DomainError::validation("size", "exceeds max_size"));
}
}
let bytes_written = buf.len() as u64;
let digest =
crate::infra::content::hash::digest_to_array(crate::infra::content::hash::sha256(&buf));

// Non-atomic check-then-act: see this method's doc comment for the
// race this fallback accepts as the price of a backend-agnostic
// default.
if self.exists(path).await? {
return Ok(PublishOutcome {
bytes_written,
digest,
created: false,
});
}
self.put(path, Bytes::from(buf)).await?;
Ok(PublishOutcome {
bytes_written,
digest,
created: true,
})
}

@coderabbitai coderabbitai Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Do not expose a racy fallback as create-exclusive.

Two concurrent calls can both observe exists == false and then execute overwrite-capable put; the last write wins. Since Lines 194-196 state that S3 uses this fallback, concurrent token replay still violates the promised immutability guarantee. Require an atomic implementation—or return unsupported—for every production backend before routing uploads through this method.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/file-storage/src/infra/backend/mod.rs` around lines 164 -
234, The default implementation of Backend::publish_exclusive is racy because
its exists-then-put sequence can overwrite data under concurrent calls,
especially for S3Backend. Remove this fallback behavior and require each
production backend, including S3Backend, to provide an atomic create-exclusive
implementation or explicitly return an unsupported error; ensure uploads are not
routed through a non-atomic implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and scoped rather than fixed here: the only backends routed through publish_exclusive in a default topology (local-fs, in-memory) have atomic overrides; S3 — the one backend left on the non-atomic fallback — is opt-in and already release-gated by the ADR-0005 external-dependency security review. s3.rs belongs to the split PR #4219, so on this branch we strengthened the default-impl doc comment (explicit TOCTOU warning + requirement of an atomic If-None-Match: * conditional-PUT override before S3 leaves its gate) and recorded it in ADR-0003's Known-gap paragraph alongside the part-hash trust gap.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ffedoroff, understood. The atomic overrides protect the default local-fs and in-memory paths, and the strengthened contract/ADR makes the remaining S3 limitation explicit.

I’ll keep the finding open rather than withdraw it: an opt-in S3 deployment would still permit the race until S3Backend supplies an atomic conditional publish. If #4219 does not implement that If-None-Match: * behavior, would you like me to open a dedicated follow-up issue with the ADR-0005 release-gate acceptance criterion?


✏️ Learnings added
Learnt from: ffedoroff
Repo: constructorfabric/gears-rust PR: 4218
File: gears/file-storage/file-storage/src/infra/backend/mod.rs:164-255
Timestamp: 2026-07-16T20:01:48.675Z
Learning: In `gears/file-storage/file-storage/src/infra/backend/mod.rs`, `LocalFsBackend` and `InMemoryBackend` implement atomic `StorageBackend::publish_exclusive` overrides. `S3Backend` currently relies on the non-atomic default fallback, is opt-in, and must implement an atomic conditional PUT (for example, `If-None-Match: *`) before ADR-0005’s release gate permits general availability. S3 work is split into PR `#4219`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — keeping it open as a tracked gate item is the right call. #4219 does not implement the If-None-Match: * conditional publish for S3; please go ahead and open the dedicated follow-up issue with the ADR-0005 release-gate acceptance criterion (atomic conditional-PUT override on S3Backend::publish_exclusive, 412→already-exists mapping, concurrent-replay test). S3 stays behind the ADR-0005 security-review gate until then, so no production topology routes replay through the racy fallback in the meantime.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="constructorfabric/gears-rust"
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT

cat >"$body_file" <<'EOF'
## Summary

Implement an atomic, create-exclusive `StorageBackend::publish_exclusive` override for `S3Backend`.

`S3Backend` currently inherits the trait's `exists`-then-`put` fallback. Concurrent uploads using the same still-valid single-shot token can both observe an absent object and then overwrite each other, violating the immutable-blob guarantee.

## Required changes

- Add an S3-specific atomic conditional publish operation in `gears/file-storage/file-storage/src/infra/backend/s3.rs`.
- Use S3 conditional creation (`If-None-Match: *`, or an equivalent atomic S3 primitive) when publishing the canonical single-shot object.
- Map S3 `412 Precondition Failed` to `PublishOutcome { created: false, .. }`; preserve the existing object and report the attempted stream's measured bytes/digest.
- Keep all other S3/backend failures surfaced as backend errors.
- Do not rely on the default non-atomic `StorageBackend::publish_exclusive` fallback for S3 uploads.

## Rationale

The LocalFs and in-memory implementations already provide atomic create-exclusive semantics. S3 is opt-in and remains behind the ADR-0005 security-review/release gate, but it must not leave that gate until this implementation is present.

## Acceptance criteria

- [ ] `S3Backend::publish_exclusive` uses an atomic conditional-PUT/create operation.
- [ ] A pre-existing object produces `created: false` by mapping HTTP `412 Precondition Failed`, without modifying stored bytes.
- [ ] A concurrent-replay regression test proves that racing publishes to the same path cannot overwrite the first successfully published object.
- [ ] Relevant S3 error handling and tests cover successful publication, already-exists behavior, and unexpected backend failures.
- [ ] ADR-0005's release gate is not considered satisfied until the above behavior is implemented and validated.

## Backlinks

- Requested by `@ffedoroff` in PR `#4218`: https://github.com/constructorfabric/gears-rust/pull/4218
- Review discussion: https://github.com/constructorfabric/gears-rust/pull/4218#discussion_r3597756704
- Related work: PR `#4219`
EOF

gh issue create \
  --repo "$repo" \
  --title "Implement atomic S3 publish_exclusive for immutable uploads" \
  --body-file "$body_file" \
  --assignee "ffedoroff"

Length of output: 12182


@ffedoroff, created and assigned the follow-up issue: #4221Implement atomic S3 publish_exclusive for immutable uploads.

It includes the ADR-0005 release-gate requirement, atomic If-None-Match: * conditional publish, 412 → created: false mapping, concurrent-replay regression coverage, and backlinks to this PR discussion.


🧬 Code Graph Analysis Results

Relevant snippets

gears/file-storage/file-storage/src/infra/backend/mod.rs

[Approx. lines 80-170] — PublishOutcome

#[derive(Debug, Clone, Copy)]
pub struct PublishOutcome {
    pub bytes_written: u64,
    pub digest: [u8; 32],
    pub created: bool,
}

[Approx. lines 150-270] — StorageBackend::publish_exclusive default (non-atomic TOCTOU fallback; S3 explicitly called out as missing atomic conditional-PUT override)

async fn publish_exclusive(
    &self,
    path: &str,
    stream: futures::stream::BoxStream<'_, std::io::Result<Bytes>>,
    max_size: Option<u64>,
) -> Result<PublishOutcome, DomainError> {
    use futures::StreamExt;

    let mut buf = Vec::new();
    let mut stream = stream;
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.map_err(|e| DomainError::backend(self.id(), e.to_string()))?;
        buf.extend_from_slice(&chunk);
        if max_size.is_some_and(|m| buf.len() as u64 > m) {
            return Err(DomainError::validation("size", "exceeds max_size"));
        }
    }
    let bytes_written = buf.len() as u64;
    let digest =
        crate::infra::content::hash::digest_to_array(crate::infra::content::hash::sha256(&buf));

    // Non-atomic check-then-act: see this method's doc comment for the
    // race this fallback accepts as the price of a backend-agnostic
    // default.
    if self.exists(path).await? {
        return Ok(PublishOutcome {
            bytes_written,
            digest,
            created: false,
        });
    }
    self.put(path, Bytes::from(buf)).await?;
    Ok(PublishOutcome {
        bytes_written,
        digest,
        created: true,
    })
}

Doc-comment context (inside this method): the default is explicitly non-atomic (exists then put), and the comments state S3 is the one backend still left on this fallback until it is replaced with an atomic conditional-PUT override on S3Backend::publish_exclusive (e.g. conditional write using If-None-Match: *), including mapping the resulting 412 to created: false to match LocalFsBackend/InMemoryBackend, plus closing the replay/exclusive publish race.


gears/file-storage/file-storage/src/infra/backend/local_fs.rs

[lines 316-360] — LocalFsBackend::publish_exclusive (atomic create-exclusive via hard-link; maps already-exists to created: false)

async fn publish_exclusive(
    &self,
    path: &str,
    stream: BoxStream<'_, std::io::Result<Bytes>>,
    max_size: Option<u64>,
) -> Result<PublishOutcome, DomainError> {
    let (target, parent) = self.prepare_target(path).await?;
    let tmp = Self::tmp_path_for(&target);

    let write_result = self.write_stream_to_tmp(&tmp, stream, max_size).await;
    let (bytes_written, digest) = match write_result {
        Ok(v) => v,
        Err(e) => {
            drop(tokio::fs::remove_file(&tmp).await);
            return Err(e);
        }
    };

    match self
        .publish_tmp_exclusive(&tmp, &target, parent.as_deref())
        .await
    {
        Ok(true) => Ok(PublishOutcome {
            bytes_written,
            digest,
            created: true,
        }),
        Ok(false) => {
            // The destination already existed: it was never touched.
            // `tmp` still holds this attempt's freshly-written (but
            // never linked) bytes and must be cleaned up like any other
            // rejected upload.
            drop(tokio::fs::remove_file(&tmp).await);
            Ok(PublishOutcome {
                bytes_written,
                digest,
                created: false,
            })
        }
        Err(e) => {
            drop(tokio::fs::remove_file(&tmp).await);
            Err(e)
        }
    }
}

[lines 165-179] — publish_tmp_exclusive helper (AlreadyExists → Ok(false))

async fn publish_tmp_exclusive(
    &self,
    tmp: &Path,
    target: &Path,
    parent: Option<&Path>,
) -> Result<bool, DomainError> {
    match tokio::fs::hard_link(tmp, target).await {
        Ok(()) => {
            self.finish_exclusive_publish(tmp, parent).await;
            Ok(true)
        }
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
        Err(e) => Err(self.io_err(e)),
    }
}

gears/file-storage/file-storage/src/infra/backend/in_memory.rs

[lines 112-143] — InMemoryBackend::publish_exclusive (atomic under a single lock; already-present → created: false)

async fn publish_exclusive(
    &self,
    path: &str,
    mut stream: BoxStream<'_, std::io::Result<Bytes>>,
    max_size: Option<u64>,
) -> Result<PublishOutcome, DomainError> {
    let mut buf = Vec::new();
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.map_err(|e| DomainError::backend(&self.id, e.to_string()))?;
        buf.extend_from_slice(&chunk);
        if max_size.is_some_and(|m| buf.len() as u64 > m) {
            return Err(DomainError::validation("size", "exceeds max_size"));
        }
    }
    let bytes_written = buf.len() as u64;
    let digest = hash::digest_to_array(hash::sha256(&buf));

    let mut blobs = self.lock_blobs()?;
    if blobs.contains_key(path) {
        return Ok(PublishOutcome {
            bytes_written,
            digest,
            created: false,
        });
    }
    blobs.insert(path.to_owned(), Bytes::from(buf));
    Ok(PublishOutcome {
        bytes_written,
        digest,
        created: true,
    })
}

gears/file-storage/file-storage/src/infra/backend/s3.rs

[lines 309-317] — S3Backend::put (plain PutObject; no conditional header logic shown in this snippet)

async fn put(&self, path: &str, bytes: Bytes) -> Result<(), DomainError> {
    let key = Self::path_to_key(path);
    let url = self
        .bucket
        .put_object(Some(&self.credentials), key)
        .sign(SIGN_DURATION);
    self.send_and_check(self.http.put(url).body(bytes)).await?;
    Ok(())
}

[lines 596-613] — S3Backend::exists (implemented via HEAD; returns true/false based on status)

async fn exists(&self, path: &str) -> Result<bool, DomainError> {
    let key = Self::path_to_key(path);
    let url = self
        .bucket
        .head_object(Some(&self.credentials), key)
        .sign(SIGN_DURATION);
    let resp = self
        .http
        .head(url)
        .send()
        .await
        .map_err(|e| self.transport_err(&e))?;
    match resp.status() {
        StatusCode::OK => Ok(true),
        StatusCode::NOT_FOUND => Ok(false),
        other => Err(self.head_error(path, other)),
    }
}

gears/file-storage/file-storage/src/infra/backend/backend_tests.rs

[lines 329-412] — local_fs_publish_exclusive_rejects_second_write_to_same_path (single-path second call → created: false, immutability verified)

async fn local_fs_publish_exclusive_rejects_second_write_to_same_path() {
    let root = unique_root();
    let b = LocalFsBackend::new("fs", &root);

    let stream1: BoxStream<'_, std::io::Result<Bytes>> =
        Box::pin(stream::iter(vec![Ok(Bytes::from_static(b"first"))]));
    let outcome1 = b
        .publish_exclusive("fid/vid", stream1, None)
        .await
        .expect("first publish_exclusive call must succeed");
    assert!(
        outcome1.created,
        "first publish_exclusive call to a fresh path must create it"
    );
    assert_eq!(
        b.get("fid/vid").await.unwrap(),
        Bytes::from_static(b"first")
    );

    let stream2: BoxStream<'_, std::io::Result<Bytes>> = Box::pin(stream::iter(vec![Ok(
        Bytes::from_static(b"second-different-bytes"),
    )]));
    let outcome2 = b
        .publish_exclusive("fid/vid", stream2, None)
        .await
        .expect("second publish_exclusive call must not error, just report created=false");
    assert!(
        !outcome2.created,
        "a second publish_exclusive call to an already-published path must not overwrite it"
    );

    assert_eq!(
        b.get("fid/vid").await.unwrap(),
        Bytes::from_static(b"first"),
        "an already-published blob must never be overwritten by publish_exclusive"
    );

    drop(tokio::fs::remove_dir_all(&root).await);
}

[lines 381-412] — in_memory_publish_exclusive_rejects_second_write_to_same_path (same semantics as local-fs test)

async fn in_memory_publish_exclusive_rejects_second_write_to_same_path() {
    let b = InMemoryBackend::new("mem");

    let stream1: BoxStream<'_, std::io::Result<Bytes>> =
        Box::pin(stream::iter(vec![Ok(Bytes::from_static(b"first"))]));
    let outcome1 = b
        .publish_exclusive("fid/vid", stream1, None)
        .await
        .expect("first publish_exclusive call must succeed");
    assert!(
        outcome1.created,
        "first publish_exclusive call to a fresh path must create it"
    );

    let stream2: BoxStream<'_, std::io::Result<Bytes>> = Box::pin(stream::iter(vec![Ok(
        Bytes::from_static(b"second-different-bytes"),
    )]));
    let outcome2 = b
        .publish_exclusive("fid/vid", stream2, None)
        .await
        .expect("second publish_exclusive call must not error, just report created=false");
    assert!(
        !outcome2.created,
        "a second publish_exclusive call to an already-published path must not overwrite it"
    );

    assert_eq!(
        b.get("fid/vid").await.unwrap(),
        Bytes::from_static(b"first"),
        "an already-published blob must never be overwritten by publish_exclusive"
    );
}

Code:
- introspect resume URLs capped at min(session expiry, now + url_ttl) so the
  24h session TTL no longer produces day-long signed part URLs
- shared scope/owner-shape validator: GET /policy now also rejects
  scope=tenant with scope_owner_id
- retention-rule prefetch uses the caller's tenant scope (closes the
  cross-tenant rule-ID 403-vs-404 oracle; orphaned-rule delete preserved)
- idempotent create replay re-mints the upload URL against the current
  effective max_size instead of replaying the stale stored constraint
- list_metadata_for_files narrowed to pub(crate) with an authorization
  contract note
- clarifying comment on the sidecar replay path (finalize re-derives
  size/hash from stored bytes, reported digest cannot poison metadata) and
  on the non-atomic publish_exclusive default (S3 override required before
  the ADR-0005 release gate lifts; noted in ADR-0003 known gap)

Docs: aligned remaining stale DESIGN.md sections (sidecar DB edges, TOML
refs, in-stream MIME 415, token claim contract, ip/tok.* checklist, "single
mode" wording), api.md bind-vs-finalize integrity timing, ADR-0004 stale
Tier-4 reference.

Claude-Session: https://claude.ai/code/session_01WZUBxb7myjUZHMYmbRtJqG
Signed-off-by: Roland From <rfedorov@linkentools.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
gears/file-storage/docs/features/multipart-coordinator.md (1)

221-221: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep shipped-status checkboxes consistent.

Line 221 marks part-row deletion complete while the surrounding abort flow and DoD remain unchecked, despite the acceptance criteria stating that full abort cleanup is shipped. Likewise, lines 476-479 mark resume URLs shipped while the final acceptance item at line 484 remains [ ]. Align these status markers so the document does not present the same behavior as both planned and complete.

Also applies to: 476-479

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/docs/features/multipart-coordinator.md` at line 221, Align
the shipped-status checkboxes in multipart-coordinator.md: update the part-row
deletion item and the resume-URLs items to match the surrounding abort-flow and
final acceptance statuses. Ensure each behavior is represented consistently as
either shipped or not shipped, without changing the documented acceptance
criteria.
gears/file-storage/docs/DESIGN.md (1)

1521-1523: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use one token serialization contract throughout DESIGN.md.

This section correctly says the shipped token is bespoke and not literal PASETO, but earlier architecture, FR, component, and example text still calls the wire token “PASETO v4.public.” Align those references; otherwise implementers may emit literal PASETO instead of the shipped compact codec.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/docs/DESIGN.md` around lines 1521 - 1523, Update all
earlier architecture, functional-requirement, component, and example references
in DESIGN.md that describe the wire token as PASETO v4.public. Use the shipped
bespoke Ed25519-signed compact serialization and reference
infra::signed_url::Issuer::issue and Verifier::verify consistently, while
preserving the distinction that it is not literal PASETO.
gears/file-storage/file-storage/src/bin/sidecar.rs (1)

168-175: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don't treat NotUnicode as missing
std::env::var(name).ok() also turns VarError::NotUnicode into None, so a malformed FS_SIDECAR_* value silently falls back to the default instead of failing fast. Only NotPresent should use the default; return an error for any other lookup failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/file-storage/src/bin/sidecar.rs` around lines 168 - 175,
Update parse_env_or_default to distinguish environment lookup errors: use the
default only when std::env::var returns VarError::NotPresent, and propagate
VarError::NotUnicode as an anyhow error instead of passing None to
parse_optional. Preserve the existing parsing and default behavior for valid
values and missing variables.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@gears/file-storage/docs/DESIGN.md`:
- Around line 348-350: Remove hash_policy from the BackendConfig field list in
the design documentation, keeping the existing statement that HashPolicy
configuration is unsupported. Ensure the documented BackendConfig model no
longer exposes an unavailable configuration surface.
- Around line 1885-1888: Update the Phase A multipart completion description to
reflect the shipped composite-manifest behavior, including use of persisted
per-part hashes and offsets rather than re-reading and re-hashing the assembled
object. Remove references claiming completion re-hashes the object or that
offset-manifest mode is only proposed, keeping the Phase B description
consistent.

In `@gears/file-storage/file-storage/src/domain/service/create.rs`:
- Around line 220-238: The replay flow around
PolicyResolver::compute_effective_max_bytes and self.sign_url must perform the
same current storage-quota preflight as fresh creation before re-minting the
upload URL. Reuse the existing quota validation path, return its error when the
quota is exhausted, and only call sign_url after validation succeeds.

---

Outside diff comments:
In `@gears/file-storage/docs/DESIGN.md`:
- Around line 1521-1523: Update all earlier architecture,
functional-requirement, component, and example references in DESIGN.md that
describe the wire token as PASETO v4.public. Use the shipped bespoke
Ed25519-signed compact serialization and reference
infra::signed_url::Issuer::issue and Verifier::verify consistently, while
preserving the distinction that it is not literal PASETO.

In `@gears/file-storage/docs/features/multipart-coordinator.md`:
- Line 221: Align the shipped-status checkboxes in multipart-coordinator.md:
update the part-row deletion item and the resume-URLs items to match the
surrounding abort-flow and final acceptance statuses. Ensure each behavior is
represented consistently as either shipped or not shipped, without changing the
documented acceptance criteria.

In `@gears/file-storage/file-storage/src/bin/sidecar.rs`:
- Around line 168-175: Update parse_env_or_default to distinguish environment
lookup errors: use the default only when std::env::var returns
VarError::NotPresent, and propagate VarError::NotUnicode as an anyhow error
instead of passing None to parse_optional. Preserve the existing parsing and
default behavior for valid values and missing variables.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 52f288b5-ffb7-4661-8d06-c959c07a1e41

📥 Commits

Reviewing files that changed from the base of the PR and between 9d2d7d9 and fcb39a4.

📒 Files selected for processing (16)
  • gears/file-storage/docs/ADR/0003-cpt-cf-file-storage-adr-sidecar-data-plane.md
  • gears/file-storage/docs/ADR/0004-cpt-cf-file-storage-adr-signed-url-transport.md
  • gears/file-storage/docs/DESIGN.md
  • gears/file-storage/docs/api.md
  • gears/file-storage/docs/features/multipart-coordinator.md
  • gears/file-storage/file-storage/src/bin/sidecar.rs
  • gears/file-storage/file-storage/src/domain/multipart.rs
  • gears/file-storage/file-storage/src/domain/multipart_service.rs
  • gears/file-storage/file-storage/src/domain/policy_service.rs
  • gears/file-storage/file-storage/src/domain/service/create.rs
  • gears/file-storage/file-storage/src/domain/service/read_ops.rs
  • gears/file-storage/file-storage/src/domain/service/read_ops_tests.rs
  • gears/file-storage/file-storage/src/infra/backend/mod.rs
  • gears/file-storage/file-storage/tests/multipart_test.rs
  • gears/file-storage/file-storage/tests/policy_authz_test.rs
  • gears/file-storage/file-storage/tests/service_test.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • gears/file-storage/docs/ADR/0004-cpt-cf-file-storage-adr-signed-url-transport.md
  • gears/file-storage/docs/ADR/0003-cpt-cf-file-storage-adr-sidecar-data-plane.md
  • gears/file-storage/file-storage/src/domain/service/read_ops.rs
  • gears/file-storage/file-storage/src/infra/backend/mod.rs
  • gears/file-storage/file-storage/src/domain/multipart_service.rs
  • gears/file-storage/file-storage/src/domain/multipart.rs
  • gears/file-storage/docs/api.md

Comment thread gears/file-storage/docs/DESIGN.md Outdated
Comment thread gears/file-storage/docs/DESIGN.md
Comment thread gears/file-storage/file-storage/src/domain/service/create.rs
- create_file idempotent replay now runs the quota preflight before
  re-minting the upload URL (was skipped, unlike the fresh path)
- DESIGN.md: drop hash_policy from the BackendConfig field list; update the
  Phase A multipart example and the multipart_upload_parts row to the shipped
  composite-manifest behavior (no assembled-object re-read)

Claude-Session: https://claude.ai/code/session_01WZUBxb7myjUZHMYmbRtJqG
Signed-off-by: Roland From <rfedorov@linkentools.com>
@ffedoroff
ffedoroff force-pushed the fix/file-storage-audit-remediation branch from 297aa9a to 2d024ea Compare July 16, 2026 21:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
gears/file-storage/docs/DESIGN.md (2)

67-69: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove the contradictory hash-policy claim.

These lines say the full configurable hash-policy surface is exposed in P1, but line 348 says HashPolicy is not implemented and SHA-256 is hard-coded. This could cause consumers to depend on an unsupported configuration surface.

Proposed wording
-  the full configurable hash-policy surface is exposed from P1 with a locked allow-list of ["SHA-256"]
+  hashing is fixed to SHA-256 in P1/P2; the configurable hash-policy surface is not implemented
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/docs/DESIGN.md` around lines 67 - 69, Update the hash
algorithm description in DESIGN.md to remove the claim that a configurable
hash-policy surface is exposed in P1. State only the currently supported
behavior: SHA-256 is computed on the sidecar’s streaming upload path and remains
hard-coded, consistent with the HashPolicy status described later in the
document.

345-350: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use one authoritative token-format description.

The changed sections correctly describe a bespoke Ed25519 compact token that is codec-equivalent to, but not literally, PASETO. Earlier architecture text still calls it a PASETO v4.public token; update that wording to avoid clients implementing the wrong wire format.

Also applies to: 460-464

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gears/file-storage/docs/DESIGN.md` around lines 345 - 350, The architecture
document contains earlier references describing SignedUrl as a PASETO v4.public
token, conflicting with the authoritative bespoke Ed25519 compact-token
definition in the SignedUrl entry. Update those earlier descriptions, including
the referenced sections around the token format, to consistently state the
custom base64url payload/signature format and clarify it is codec-equivalent to
but not literally PASETO.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@gears/file-storage/docs/DESIGN.md`:
- Around line 1876-1880: The later Phase D finalization description must match
the composite-manifest completion flow: replace references to the assembled
object’s “real size/hash” with the reported total size and composite manifest
root. Retain the existing bounded MIME-sniff read, and update only the relevant
Phase D wording.

---

Outside diff comments:
In `@gears/file-storage/docs/DESIGN.md`:
- Around line 67-69: Update the hash algorithm description in DESIGN.md to
remove the claim that a configurable hash-policy surface is exposed in P1. State
only the currently supported behavior: SHA-256 is computed on the sidecar’s
streaming upload path and remains hard-coded, consistent with the HashPolicy
status described later in the document.
- Around line 345-350: The architecture document contains earlier references
describing SignedUrl as a PASETO v4.public token, conflicting with the
authoritative bespoke Ed25519 compact-token definition in the SignedUrl entry.
Update those earlier descriptions, including the referenced sections around the
token format, to consistently state the custom base64url payload/signature
format and clarify it is codec-equivalent to but not literally PASETO.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 55d33589-eebc-4dfe-8210-a9215cd78d42

📥 Commits

Reviewing files that changed from the base of the PR and between fcb39a4 and 2d024ea.

📒 Files selected for processing (2)
  • gears/file-storage/docs/DESIGN.md
  • gears/file-storage/file-storage/src/domain/service/create.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • gears/file-storage/file-storage/src/domain/service/create.rs

Comment thread gears/file-storage/docs/DESIGN.md
…ped behavior

Third CodeRabbit pass: the Phase D walkthrough still described completion as
finalizing from the assembled object's real size/hash and returning 204 with
no body. Corrected to the shipped contract — finalize from the reported total
part size and composite manifest root (no re-read beyond the bounded MIME
sniff), optional If-Match, 200 with the MultipartCompleteDto JSON body.

Claude-Session: https://claude.ai/code/session_01WZUBxb7myjUZHMYmbRtJqG
Signed-off-by: Roland From <rfedorov@linkentools.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant