merge: integrate object storage production capability

This commit is contained in:
donghyeon-ka
2026-08-01 00:57:40 +09:00
445 changed files with 32429 additions and 796 deletions
+28
View File
@@ -164,3 +164,31 @@ gates:
workflow: link-check.yml
job: lychee
execution: job
- id: object-storage-minio-managed-contract
release_blocking: true
mechanism: gradle-custom-task
ref: objectStorageMinioContractTest
workflow: object-storage-qualification.yml
job: minio-managed-contract
execution: explicit
- id: poster-image-v7-migration
release_blocking: true
mechanism: gradle-custom-task
ref: posterImageMigrationTest
workflow: object-storage-qualification.yml
job: poster-image-v7-migration
execution: explicit
- id: object-storage-minio-managed-fault
release_blocking: conditional
mechanism: gradle-custom-task
ref: objectStorageMinioFaultTest
workflow: object-storage-qualification.yml
job: minio-managed-fault
execution: explicit
- id: object-storage-aws-protected-qualification
release_blocking: conditional
mechanism: delegated-pending
ref: approval-gate-b
workflow: object-storage-qualification.yml
job: aws-managed-common-subset
execution: job
+1 -1
View File
@@ -5,7 +5,7 @@ readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
readonly EXPECTED_GATE_COUNT=22
readonly EXPECTED_GATE_COUNT=26
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
@@ -0,0 +1,96 @@
name: object-storage-qualification
on:
pull_request:
schedule:
- cron: "23 3 * * 2"
workflow_dispatch:
inputs:
run_protected_aws:
description: Run the protected AWS sandbox qualification lane
required: true
default: false
type: boolean
permissions:
contents: read
env:
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
poster-image-v7-migration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run non-skipping Poster image V7 migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
minio-managed-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run exact-release MinIO managed contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
minio-managed-fault:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run digest-pinned MinIO and Toxiproxy fault contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
aws-managed-common-subset:
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
environment: object-storage-aws-qualification
runs-on: ubuntu-latest
env:
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run protected AWS common-subset qualification
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
@@ -0,0 +1,81 @@
# Object Storage Batch A Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Claimed level: R0 application contract only
- Provider readiness advanced: no
## Implemented scope
- Characterized the legacy caller-key overwrite, whole-object materialization, locator exposure,
eager filesystem directory creation, optional S3 bucket provisioning, and Poster transaction/API
coupling without changing those behaviors.
- Added provider-neutral identities, opaque checked references/handles, bounded streaming
callbacks, content identity, digest/range values, requests, receipts, outcomes, and narrow ports
under `dev.caskeleton.application.objectstorage`.
- Required an `ObjectOperationKey` on mutation requests and separated normal publication,
scan-maintenance, purge-maintenance, direct, and staged privilege surfaces.
- Added recursive contract-purity tests and an ArchUnit freeze for the one existing sample legacy
import.
- Marked the legacy `ObjectStoragePort` and `StoredObject` as removal boundaries without adapting
new semantic calls back to raw keys.
No provider-neutral kernel, canonical namespace/control codec, local R1 provider, S3/MinIO
qualification, sample migration, or R2 readiness claim is included.
## TDD evidence
The planned RED checks failed only for the intentionally missing types or removal annotations:
- `ObjectStorageIdentityContractTest`: missing identity types before Task 2 implementation.
- `ObjectContentContractTest` and `ObjectStorageValueContractTest`: missing content/value types
before Task 3 implementation.
- `ObjectStoragePortContractTest`: missing request/receipt/port family before Task 4 implementation.
- `ObjectStorageArchitectureContractTest`: missing legacy removal annotations before Task 5
implementation.
An initial ArchUnit DSL compilation error was a test-authoring error, not accepted as a RED result;
the rule was corrected and rerun.
## GREEN verification
All commands ran from `src/` and completed with `BUILD SUCCESSFUL`:
```bash
./gradlew :application-core:resolveAndLockAll --write-locks
./gradlew :application-core:verifyDependencyLocks --console=plain
./gradlew :application-core:test --tests '*ObjectStorageIdentityContractTest' --console=plain
./gradlew :application-core:test \
--tests '*ObjectContentContractTest' \
--tests '*ObjectStorageValueContractTest' --console=plain
./gradlew :application-core:test --tests '*ObjectStoragePortContractTest' --console=plain
./gradlew :application-core:test \
--tests '*ObjectStorageArchitectureContractTest' --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew :application-core:check --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain
```
The final combined legacy focused suites completed in 27 seconds. Deprecation-for-removal warnings
are expected evidence that legacy consumers remain visible; they are not suppressed.
## LLM Wiki capture
The canonical vault required by repository policy,
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
`raw/branch-notes/codex-objectstorage-production-capability.md` could not be created or updated.
No similarly named non-canonical clone was used. This exact access block is recorded in both the
plan and design headers and here at the Batch A boundary.
## Remaining gates and risks
- External broker and REST consumers and deployed legacy data were not inspected; Gate A remains
blocked for legacy removal or public API versioning.
- The new contracts have no provider implementation yet.
- The current legacy adapter retains whole-object and raw-locator behavior by design until the
later migration batch.
- No readiness registry row is promoted by this checkpoint.
@@ -0,0 +1,109 @@
# Object Storage Batch B Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Evidence grade: repository-local non-skipping unit/contract/application-context tests
- Advanced cards: local managed single upload R1, local managed download R1
- R2 or production-provider readiness advanced: no
## Implemented scope
- Added deterministic data/control namespaces, opaque reference/handle codecs, canonical request
fingerprints, frozen binding/policy revisions, and bounded operation epochs.
- Added six strict canonical JSON control-record families with fixed field order, outer SHA-256
envelopes, schema/size checks, corruption rejection, and checked-in golden digests.
- Added provider-neutral publication, scan, reference, direct-session, multipart, and pending-effect
state transitions with same-operation replay and conflicting-intent rejection.
- Added a provider contract and `filesystem-local-dev` implementation with bounded streaming,
immutable exclusive create, SHA-256 verification, exact inspect/version, full/range transfer,
conditional retirement, create resolution, restrictive permissions, and path/symlink
confinement.
- Added single-process exact-version control CAS and restart/corruption/fault characterization.
Logical control keys use `.record` physical leaves locally so object-store-valid prefix/leaf key
pairs cannot collide as filesystem file/directory paths.
- Added constructor-bound `app.object-storage` settings and compile-before-construction
provider/destination/route/policy binding. The capability is disabled by default and
`filesystem-local-dev` is rejected for `prod`/`production`.
- Added disabled, unselected, invalid, selected-success, selected-construction-failure, close,
legacy-only, and namespace-separated dual-run composition tests.
- Added semantic routing evidence for publish, replay without producer invocation, inspect,
full transfer, absent reference, and exact retained route lookup.
- Added the exact nine-card readiness registry. Only local managed single upload/download are R1;
direct, multipart, quarantine, retention, and production reconciliation remain R0.
## TDD and defect evidence
Planned RED checks failed for the intentionally absent codec/kernel/provider/settings/readiness
types before each implementation. Additional tests found and drove these corrections:
- Local control keys may legally have both a leaf and a child in object storage, while a filesystem
cannot have both `reference` and `reference/lifecycle`; local physical `.record` mapping fixed the
collision without changing logical keys.
- `ObjectInspectionPort.inspect` initially threw for an absent known-route reference; it now
returns `Optional.empty()` while incomplete/corrupt evidence still fails closed.
- The application purity test initially scanned its own test output after a full `check`; it now
derives the production class root from a production contract type.
- The general B7 ArchUnit rule initially classified objectstorage provider-internal SPI/control
return values as public adapter responses. The existing negative fixture remains active, while a
dedicated non-empty rule now checks the actual objectstorage `*Adapter` semantic boundaries.
No skipped Docker or external-service test is used as Batch B readiness evidence.
## GREEN verification
All commands ran from `src/` unless noted and completed with `BUILD SUCCESSFUL` after the documented
RED/fix cycles:
```bash
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectNamespaceCodecTest' \
--tests '*ObjectRequestFingerprintCodecTest' \
--tests '*ObjectOperationEpochTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectControlRecordCodecTest' \
--tests '*ObjectOperationStateMachineTest' \
--tests '*ObjectOperationKernelTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageProviderContract' \
--tests '*LocalDevObjectStorageProviderTest' \
--tests '*LocalDevObjectStorageRecoveryTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageBindingCompilerTest' \
--tests '*ObjectStorageCapabilityConfigTest' \
--tests '*RoutingObjectStorageAdapterTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
./gradlew :sample-portfolio:test --console=plain
./gradlew :application-core:check \
:adapter:outbound:objectstorage:check --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
```
The final combined application/objectstorage checkpoint completed in 23 seconds. The focused
Clean Architecture suite and dependency verification also passed.
## LLM Wiki capture
The canonical vault required by repository policy,
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
`raw/branch-notes/codex-objectstorage-production-capability.md` and any derived raw documents could
not be created or updated. No similarly named non-canonical clone was used. This exact access block
is recorded in the plan/design status and at this Batch B boundary.
## Remaining gates and risks
- `filesystem-local-dev` has no multi-node linearizability or power-loss durability evidence and is
forbidden in production profiles.
- The canonical S3/MinIO provider contribution, async bounded transport, provider qualification,
response-loss fault tests, and protected AWS evidence are not implemented.
- Direct grants, multipart, quarantine/scan, retention/legal hold, privileged purge, reapers, and
production reconciliation remain R0.
- The sample Poster workflow still uses the deprecated whole-`byte[]` port and transaction-coupled
legacy choreography. It is explicitly activated only in sample local/test configuration.
- External API/broker consumers and deployed legacy data remain uninspected, so Gate A still blocks
destructive migration or legacy removal.
@@ -0,0 +1,84 @@
# Object Storage Batch C Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Evidence grade: repository-local tests plus digest-pinned single-node MinIO/Toxiproxy tests
- AWS execution: not authorized; source set compiled only
- Production-provider readiness advanced: no
## Implemented scope
- Added exact AWS S3 and MinIO provider bindings, bounded evidence descriptors, qualifier/error
mapping, secret references, endpoint/owner/addressing validation, and selected-only lifecycle
construction.
- Added bounded async request/response bridges and the managed S3 put, inspect, full/range download,
checksum, exact-version, cancellation, and content-length paths.
- Added canonical conditional S3 control storage and operation response-loss resolution. Provider
ETags remain adapter-private and are never exposed as logical versions.
- Added low-level managed multipart planning, sharded immutable part ledgers, initiate-before-I/O
state, explicit create/upload/list/complete/abort calls, and exact completion verification.
- Added non-skipping MinIO contract/fault lanes, an AWS compile-only qualification lane, a protected
workflow, and gate-matrix coverage.
The exact MinIO image is
`minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8`.
The exact Toxiproxy image is
`ghcr.io/shopify/toxiproxy@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e`.
## Exact MinIO finding
Real-provider tests proved an asymmetric conditional profile:
- `PutObject If-None-Match: *` was accepted but overwrote an existing object.
- stale `PutObject If-Match` was rejected with HTTP 412.
- `CompleteMultipartUpload If-None-Match: *` was accepted and overwrote an existing object.
- checksum, HEAD, and range behavior passed the exercised contract.
Because immutable create and create-if-absent control CAS cannot be proven, the exact MinIO managed
and direct mutation profiles remain `UNSUPPORTED`. The implementation does not emulate missing
atomicity with HEAD followed by an unconditional write and does not promote a readiness card.
## TDD and verification
The task-focused RED runs first failed on the planned absent binding, bridge, conditional store,
multipart, and qualification types. Provider qualification then found the real MinIO conditional
behavior above; the descriptor and negative contract were changed instead of weakening the
contract.
Commands completed with `BUILD SUCCESSFUL`:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3ProviderBindingTest' \
--tests '*S3ProviderQualifierTest' \
--tests '*S3ProviderCompositionTest' --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
verifyCleanArchitectureDependencies --console=plain
bash ../.github/scripts/verify-gate-matrix.sh
```
The gate matrix reports 22 gates: 21 verified and the protected AWS qualification gate explicitly
`delegated-pending`.
## LLM Wiki capture
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
be created or updated. No similarly named non-canonical clone was used.
## Remaining risks
- No AWS request was executed, so there is no observed AWS provider claim.
- The pinned MinIO topology is a local single-node container and is not production TLS,
multi-node, durability, or linearizability evidence.
- The detailed managed multipart fault matrix is not exhaustive enough for R2.
- No sample migration, public API, scan/publication choreography, retention, purge, or reaper is
included in this checkpoint.
@@ -0,0 +1,104 @@
# Object Storage Batch D Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Scope: direct-transfer provider/application primitives only
- Public endpoint: none
- Readiness advanced: no; all direct cards remain R0
## Implemented scope
- Added direct single-upload session policy, durable prepared/issued transitions, bearer
redaction, exact completion verification, published-version download resolution, and an
S3-presigner lifecycle owned by the selected provider.
- Added direct multipart durable session and part-grant families, opaque acknowledgement tokens,
sharded part records, admission-close/expiry fencing, exact ledger validation, completion/abort
states, response-loss resolution, and persisted terminal exact-version replay.
- Added direct S3 initiate/discovery, exact-part presign, `ListParts` acknowledgement, conditional
complete followed by exact HEAD verification, and abort resolution.
- Registered direct single and multipart delegates only when their exact compiled capability is
selected. One presigner is constructed and closed exactly once.
- Added golden canonical envelopes for the direct session, direct multipart session, and direct
multipart grant families.
- Fixed `MultipartCompleteRequest` null validation so valid immutable `List.of(...)` input no longer
throws from `contains(null)`.
## Qualification truth
The exact MinIO release cannot prove create-only PUT or create-only multipart completion, so both
direct profiles are explicitly `UNSUPPORTED`. The direct MinIO contract/fault lanes are negative
admission tests: they prove no bearer or multipart mutation enters an unsupported profile. No test
skip is used as positive evidence.
The AWS managed/direct source sets compile, but no AWS call was made and no AWS evidence row was
published. No inbound controller, authorization surface, CORS runtime configuration, or public
direct API exists.
## Verification
Commands completed with `BUILD SUCCESSFUL`:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectTransferCoordinatorTest' \
--tests '*PresignedGrantRedactionTest' \
--tests '*S3DirectTransferProviderTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectMultipartCoordinatorTest' \
--tests '*DirectMultipartRaceTest' \
--tests '*S3DirectMultipartProviderTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
--tests '*DirectTransfer*' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectTransferCorsContractTest' --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
verifyCleanArchitectureDependencies --console=plain
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
bash ../.github/scripts/verify-gate-matrix.sh
./gradlew test --console=plain
./gradlew check --console=plain
```
The module `check` includes unit tests, Checkstyle, Spotless, SpotBugs, architecture, configuration
processor, environment-key, and repository-wide policy checks. Existing test-only compiler
warnings remain non-failing. The final repository-wide test run completed 79 tasks and the final
repository-wide check completed 214 tasks.
## Deliberate limitations
- Issued bearer material is process-local. A restart fails closed instead of reconstructing or
reissuing an already-issued bearer.
- The signing clock/window is stored and bounded, but AWS SDK presigner query timing is not driven
by the injected application clock.
- The direct multipart recovery/race matrix covers its principal fences and completion response
loss but is not exhaustive enough for an R2 claim.
- Retention/Object Lock grant headers and a provider-enforced direct-single hard size ceiling are
not qualified.
- No public endpoint exists, so CORS evidence is a pure contract and no direct card may exceed R0
in the current registry.
## Approval Gate A
Tasks 2024 remain blocked until the user explicitly approves scanner ownership, the sample's
first publication profile, the additive asynchronous API/status contract, and digest transport.
No scan/publication/sample endpoint implementation was started.
## LLM Wiki capture
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
be created or updated. No similarly named non-canonical clone was used.
@@ -0,0 +1,94 @@
# Object Storage Batch E Pause Checkpoint
- Recorded: 2026-07-29 (Asia/Seoul)
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Status: implementation in progress; intentionally paused at the user's request
- Evidence grade: local unit/integration/architecture evidence only; no AWS R2 evidence
## Implemented at this checkpoint
- Staged integrity verification, fake-scanner routing, publication handoff fencing, and stable
replay receipts.
- Additive Poster V8 dual-read schema (renumbered from branch-local V7 during JPA integration),
upload/retirement intents, HMAC-sanitized idempotency scope,
PostgreSQL atomic claim SPI, and forward-only migration qualification lane.
- Short-transaction Poster image publication flow and additive locator-free `202` API under the
AIP-122-compatible `/posters/{id}/imagePublications` collection.
- Exact-reference/version logical retirement enqueue, lease/fence takeover, response-loss retry,
Poster deletion survival, and disabled-by-default worker composition.
- Isolated legacy migration contracts, report-only inspection, two-distinct-approver Ed25519
approval verification, nonce replay boundary, and explicit maintenance-only composition.
## Verification completed
The following focused command passed after the final architecture fixes:
```bash
cd src
./gradlew \
:sample-portfolio:spotlessApply \
:sample-portfolio:test --tests '*PosterImagePublicationControllerWireTest' \
:app-bootstrap:test --tests '*CleanArchitectureTest' \
--console=plain
```
The following focused suites also passed during this checkpoint:
```bash
./gradlew :adapter:outbound:objectstorage:test \
--tests '*LegacyObjectAdoptionServiceTest' \
--tests '*LegacyAdoptionApprovalVerifierTest' \
--tests '*ObjectStorageLegacyMigrationConfigTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*DeletePosterImageRetirementTest' \
--tests '*PosterImageRetirementCrashMatrixTest' \
--tests '*PosterImageRetirementConfigTest' \
--tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*SampleApplicationContextTest' \
:sample-portfolio:posterImageMigrationTest --console=plain
```
The migration lane included
`PosterImageRetirementQualificationTest`, which proved that an exact retirement row survives
deletion of its Poster row.
## Failures found and resolved
- `spotlessJavaCheck` initially found formatting drift in newly changed application-core and
persistence files. The owner-module Spotless apply tasks fixed it.
- `SampleApplicationContextTest` initially failed because Spring's persistence exception advisor
could not CGLIB-proxy the final `PosterImageAttachmentCasRepository`. Removing `final` fixed the
context; the focused context suite then passed.
- `CleanArchitectureTest` initially rejected an application-core return type from the sample domain
and the kebab-case `image-publications` path. Conversion moved back to the application use case,
and the endpoint changed to the repository's AIP-122-compatible `imagePublications` segment. The
complete focused architecture suite then passed.
## Not yet re-run / not complete
- The combined Batch E checkpoint command stopped on the two architecture failures above before all
requested root tasks could complete. The focused failing suites passed after the fixes, but
`:sample-portfolio:check`, `verifyPublicPathSnapshot`, and the full combined Batch E command have
not been re-run after those final fixes.
- The complete repository `./gradlew test` and `./gradlew check` have not been re-run after the
Batch E additions.
- The legacy adoption runner/configuration is not yet wired to a production legacy inspector,
permission-checked trust-key loader, or durable control-record replay-store implementation.
- Tasks 2530 (Batch F) have not started in this continuation.
- Actual AWS qualification is blocked by Approval Gate B: no approved account, bucket/namespaces,
workload roles, signed deployment attestation, or mutation/test authority was supplied.
- No readiness card was promoted. Local/MinIO ceilings and unsupported conditional behavior remain
unchanged.
## Wiki capture
At this isolated-branch checkpoint, the then-selected private vault
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` was absent, so capture was blocked. The final
main integration was later captured in the user-designated public vault at
`raw/branch-notes/chore-main-worktree-capability-integration.md`, with the derived error note
`raw/errors/multi-worktree-contract-drift-2026-07-31.md`.
@@ -0,0 +1,73 @@
# Object Storage Phase 0 Inventory
- Captured: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Scope: repository-local source, tests, configuration, migrations, and documentation
- Evidence grade: repository-local only; deployed data, broker subscribers, and external REST
consumers were not inspected
## Commands
```bash
rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \
src docs
rg -n 'image_key|posters/.*/image' src/sample-portfolio
rg -n 'poster\.image-attached|StoredObjectResponse|PosterResponse|imageKey' \
src/sample-portfolio docs
```
The commands completed successfully in the isolated worktree. Results are classified below.
Documentation hits in the Object Storage design/plan describe the migration and are not runtime
consumers. The `s3://bucket/key-1` fixture in
`IdempotencyStoreAdapterTest` belongs to the generic idempotency response-reference test and is not
an Object Storage legacy-port consumer.
## Repository-local runtime inventory
| Contract/data | Producer | Repository-local consumers | Classification |
| --- | --- | --- | --- |
| `ObjectStoragePort` / `StoredObject` | `application-core/application/storage` | filesystem and S3 adapters, `UploadPosterImageUseCase`, `PosterController`/`PosterWebMapper` | legacy runtime contract |
| `ca-skeleton.objectstorage.*` | `ObjectStorageSettings` / `ObjectStorageConfig` | sample runtime through its objectstorage runtime dependency | legacy runtime configuration |
| `file://` receipt | `FilesystemObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
| `s3://bucket/key` receipt | `S3ObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
| `/posters/{id}/image` | `PosterController` | repository tests and the generated/public HTTP contract | legacy inbound API |
| `StoredObjectResponse` | `PosterController` / `PosterWebMapper` | HTTP caller, with `key`, `size`, `contentType`, and `location` | legacy response DTO |
| `PosterResponse.imageKey` | `PosterWebMapper` | list/get/create/update/publish/archive HTTP responses | legacy general response field |
| `poster.image-attached` | `PosterEventPublisher` | no subscriber found in this repository | versionless broker event; external consumers unknown |
| `poster.image-attached.imageKey` | `PosterImageAttached` and publisher JSON | no subscriber found in this repository | raw locator-shaped event field |
| `poster.image_key` | Flyway V6, `PosterEntity`, persistence mapper | `Poster` aggregate and repository adapter | stored-data schema |
| `posters/{id}/image` key | `UploadPosterImageUseCase` | aggregate `imageKey`, event payload, DB row, HTTP response | deterministic overwriteable legacy key |
## Executable characterization
The following tests pin the current behavior without approving it as the target design:
- `LegacyObjectStorageBehaviorTest`
- caller-selected keys overwrite;
- `get` returns `Optional<byte[]>` and materializes the whole object;
- receipts expose `file://` and `s3://` locators.
- `LegacyObjectStorageConfigTest`
- missing backend configuration selects filesystem;
- context creation creates the filesystem directory before the first write;
- `autoCreateBucket=true` probes and creates a missing bucket during S3 bean construction.
- `LegacyPosterImageUploadCharacterizationTest`
- remote storage is called while `TransactionPort.inWrite` is active;
- the controller calls `MultipartFile.getBytes`;
- the response exposes raw key and provider location;
- Poster deletion leaves the legacy object untouched.
## External inventory gap and Gate A
Repository search does not prove that the following have no deployed consumers:
- `POST /posters/{id}/image`;
- `StoredObjectResponse.key` and `.location`;
- `PosterResponse.imageKey`;
- broker event type `poster.image-attached` and its `imageKey` payload;
- rows already stored in `poster.image_key`;
- filesystem/S3 objects already written under `posters/{id}/image`.
No deployed database, object namespace, access log, API client catalog, broker consumer group, schema
registry, or owning team approval was inspected. Therefore removal, in-place field rename, event
payload replacement, or legacy-object deletion remains blocked. Approval Gate A must obtain owner
and consumer evidence and choose an additive/versioned migration contract.
@@ -0,0 +1,114 @@
schema_version: 1
claims:
- card_id: object-storage-managed-upload-single
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-managed-integrity
claimed_level: R1
evidence_revision: batch-b-local-r1
evidence_expires_on: ""
required_tasks:
- ":adapter:outbound:objectstorage:check"
limitations:
- single-process control CAS only; no multi-node linearizability
- fsync and atomic move tests do not prove power-loss durability
- local development provider is forbidden in production profiles
- card_id: object-storage-managed-upload-multipart
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- multipart publication protocol is not implemented
- card_id: object-storage-managed-download
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-managed-integrity
claimed_level: R1
evidence_revision: batch-b-local-r1
evidence_expires_on: ""
required_tasks:
- ":adapter:outbound:objectstorage:check"
limitations:
- local functional full and range reads are not production-provider qualification
- no multi-node or power-loss durability claim
- card_id: object-storage-direct-upload-single
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- direct grant provider and public inbound endpoint are not implemented
- card_id: object-storage-direct-upload-multipart
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- direct multipart session and public inbound endpoint are not implemented
- card_id: object-storage-direct-download
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- direct download grant and public inbound endpoint are not implemented
- card_id: object-storage-quarantine-publication
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- scanner handoff and verdict fencing are not implemented
- card_id: object-storage-retention
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- retention and legal-hold provider enforcement are not implemented
- privileged purge composition remains intentionally empty
- card_id: object-storage-reconciliation
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- local create resolution is single-process functional evidence only
- production response-loss and multi-node reconciliation are not implemented
@@ -1,10 +1,10 @@
# Object Storage Production Capability Implementation Plan
- 작성일: 2026-07-28
- 상태: 구현 계획 작성 완료, 모든 task 미착수, R2 미구현
- 상태: Batch AD 및 Batch E pause checkpoint를 main에 통합, Batch E 전체 gate와 R2완료
- 범위: 상세 설계 Phase 06; Phase 7/R3는 별도 승인 계획
- LLM Wiki capture: 정본 vault
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함
- LLM Wiki capture: public vault
`raw/branch-notes/chore-main-worktree-capability-integration.md`에 main 통합 기록 완료
> **For agentic workers:** REQUIRED SUB-SKILLS: use
> `superpowers:subagent-driven-development` to execute independent tasks,
@@ -43,6 +43,27 @@ provider-leaf split, a production malware-scanner implementation, and browser AP
outside the approved contracts require follow-up plans. Writing this plan changes no runtime
behavior and advances no readiness card.
**Implementation checkpoint (2026-07-28):** The Batch AB application/kernel/local-provider
baseline and the Batch CD S3/MinIO managed/direct primitive slices are implemented and verified in
`codex/objectstorage-production-capability`. Exact MinIO qualification proved that create-only PUT
and multipart completion conditions are ignored, so its managed/direct mutation profiles remain
`UNSUPPORTED`. AWS lanes compile only and have no observed evidence. Only the exact local managed
single upload/download cards remain R1; every direct, multipart, quarantine, retention, and
production reconciliation card remains R0. The exhaustive R2 fault/race matrices named by
unchecked steps remain follow-up evidence, not implied completion. The legacy provider and Poster
consumer remain isolated behind explicit sample-local/test activation. Evidence:
[Batch A](../../evidence/object-storage/batch-a-checkpoint.md),
[Batch B](../../evidence/object-storage/batch-b-checkpoint.md), and
[Batch C](../../evidence/object-storage/batch-c-progress.md),
[Batch D](../../evidence/object-storage/batch-d-checkpoint.md).
Batch E의 개별 focused fix 증거는
[Batch E pause checkpoint](../../evidence/object-storage/batch-e-pause-checkpoint.md)에 기록됐다.
당시 Batch E combined/root gate는 재실행되지 않았으므로 Batch E 전체 완료나 R2를 주장하지
않는다. 격리 브랜치에서 잘못 선택했던 private vault 경로 대신, main 통합 작업은 사용자가
지정한 public vault의 `raw/branch-notes/chore-main-worktree-capability-integration.md`
캡처했다.
---
## Execution rules
@@ -154,7 +175,7 @@ Task 30; a batch is not closed if any output is missing.
- Create:
`docs/evidence/object-storage/phase-0-inventory.md`
- [ ] **Step 1: Record current executable behavior**
- [x] **Step 1: Record current executable behavior**
Prove the current contract exactly as-is:
@@ -169,7 +190,7 @@ Prove the current contract exactly as-is:
- the response exposes raw key/location;
- Poster deletion does not retire the object.
- [ ] **Step 2: Verify the characterization baseline**
- [x] **Step 2: Verify the characterization baseline**
Run:
@@ -185,7 +206,7 @@ cd src
Expected: PASS against the current implementation. This is a baseline, not R1/R2 evidence.
- [ ] **Step 3: Inventory runtime and data dependencies**
- [x] **Step 3: Inventory runtime and data dependencies**
In the evidence document record command output, not an unsupported repository-wide conclusion:
@@ -204,7 +225,7 @@ Classify every hit as legacy runtime, test, documentation, stored-data schema, o
Record whether real deployed consumers/data were inspected; if they were not, state that external
inventory is still required.
- [ ] **Step 4: Run unchanged focused suites**
- [x] **Step 4: Run unchanged focused suites**
Run:
@@ -232,7 +253,7 @@ Expected: PASS. No source behavior changes belong to this task.
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java`
- [ ] **Step 1: Add the approved test-only property engine**
- [x] **Step 1: Add the approved test-only property engine**
Add `testImplementation 'net.jqwik:jqwik:1.9.1'`, matching the existing sample test version, and
update only the application-core lockfile:
@@ -245,7 +266,7 @@ cd src
Expected: PASS with test-only jqwik entries and no production dependency.
- [ ] **Step 2: Write the failing identity contract**
- [x] **Step 2: Write the failing identity contract**
Test null/blank/control-character/oversize rejection, canonical round trips, operation-key
composition, part range `1..10_000`, prefix separation, route-token grammar, reference check-digit
@@ -253,7 +274,7 @@ tampering, and provider-locator non-disclosure. Include jqwik properties for arb
input and a fixed golden vector for each prefix. Route existence/retirement is adapter binding
state and is deliberately not tested or imported in `application-core`.
- [ ] **Step 3: Verify RED**
- [x] **Step 3: Verify RED**
Run:
@@ -265,7 +286,7 @@ cd src
Expected: compilation failure because the new identity types do not exist.
- [ ] **Step 4: Implement the minimum values**
- [x] **Step 4: Implement the minimum values**
Use immutable final classes or records with constructor validation. `ObjectOperationKey` contains
exactly destination, epoch, and operation ID. Public reference/handle types expose canonical text
@@ -274,7 +295,7 @@ validation in framework-free Java (`MessageDigest`). The adapter-owned codec in
minting path from a retained route token plus generated `ObjectId`; application values do not
consult a route registry.
- [ ] **Step 5: Verify GREEN**
- [x] **Step 5: Verify GREEN**
Run the command from Step 3 and
`./gradlew :application-core:verifyDependencyLocks --console=plain`. Expected: PASS.
@@ -299,7 +320,7 @@ Run the command from Step 3 and
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java`
- [ ] **Step 1: Write failing callback and value tests**
- [x] **Step 1: Write failing callback and value tests**
Prove:
@@ -313,7 +334,7 @@ Prove:
- range offset/length arithmetic rejects zero, negative, overflow, and over-budget delivery;
- media types are canonical, bounded, and contain no control characters.
- [ ] **Step 2: Verify RED**
- [x] **Step 2: Verify RED**
Run:
@@ -326,14 +347,14 @@ cd src
Expected: compilation failure because the callback/value types do not exist.
- [ ] **Step 3: Implement the minimum contract**
- [x] **Step 3: Implement the minimum contract**
Reuse `CallBudget`; do not create serializable wall-clock deadlines. Callback types may declare only
application exceptions and Java primitive/array types. Do not expose `InputStream`, `OutputStream`,
`ByteBuffer`, Reactor, Flow, servlet, Spring, or AWS types. Document that callbacks are synchronous
and blocking and that adapters must not invoke application callbacks on SDK event-loop threads.
- [ ] **Step 4: Verify GREEN**
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
@@ -376,7 +397,7 @@ Run the command from Step 2. Expected: PASS.
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java`
- [ ] **Step 1: Write the failing port-shape contract**
- [x] **Step 1: Write the failing port-shape contract**
Use reflection and construction tests to prove:
@@ -393,7 +414,7 @@ Use reflection and construction tests to prove:
- receipts expose no locator, ETag, upload ID, URL, credential, or provider enum;
- requirements can strengthen but never lower destination policy.
- [ ] **Step 2: Verify RED**
- [x] **Step 2: Verify RED**
Run:
@@ -405,7 +426,7 @@ cd src
Expected: compilation failure because the semantic port family does not exist.
- [ ] **Step 3: Implement the minimum framework-free API**
- [x] **Step 3: Implement the minimum framework-free API**
Follow design §9 exactly. Keep one public top-level type per file. Use immutable collections and
defensive copies where required. Grants contain a bounded URI, signed header names/values,
@@ -414,7 +435,7 @@ expiration, and opaque session identity, but their `toString` must redact the UR
Document the separate scanner-workflow composition boundary on `ObjectScanMaintenancePort`; the
normal staged port owns only stage, integrity verification, finalize, and abort.
- [ ] **Step 4: Verify GREEN**
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
@@ -431,7 +452,7 @@ Run the command from Step 2. Expected: PASS.
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
- [ ] **Step 1: Write failing purity tests**
- [x] **Step 1: Write failing purity tests**
Assert recursively through fields, methods, constructors, record components, generic arguments, and
annotations that `dev.caskeleton.application.objectstorage..` has no Spring, AWS SDK, servlet,
@@ -442,7 +463,7 @@ boundary. Add an ArchUnit rule that new sample business code may not import
into an explicitly named legacy-only package, and permit the later exact
`application.storage.migration` adoption use case as an admin-only compatibility exception.
- [ ] **Step 2: Verify RED**
- [x] **Step 2: Verify RED**
Run:
@@ -458,7 +479,7 @@ Expected: the new sample-import rule initially identifies the current legacy con
fixture explicitly records it as the single frozen violation. No unrelated architecture violation
may be accepted.
- [ ] **Step 3: Deprecate without adapting new calls back to raw keys**
- [x] **Step 3: Deprecate without adapting new calls back to raw keys**
Mark both legacy types `@Deprecated(forRemoval = true)` and document:
@@ -473,13 +494,13 @@ Task 23 must move the remaining compatibility surface into an allowlisted `..pos
slice; no non-legacy sample package may import the old port after that cutover. Do not create a
semantic-to-legacy adapter that throws away operation identity or guarantees.
- [ ] **Step 4: Verify GREEN**
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS with exactly the named frozen legacy violation and zero
provider/framework leaks. The later migration exception must be bounded/redacted and visible only
to the named administrative use case.
- [ ] **Step 5: Run Batch A checkpoint**
- [x] **Step 5: Run Batch A checkpoint**
Run:
@@ -523,7 +544,7 @@ object-storage provider.
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java`
- [ ] **Step 1: Add the approved test-only property engine**
- [x] **Step 1: Add the approved test-only property engine**
Add `testImplementation 'net.jqwik:jqwik:1.9.1'` and update only this leaf's lockfile:
@@ -535,7 +556,7 @@ cd src
Expected: PASS with no production jqwik dependency.
- [ ] **Step 2: Write failing codec and epoch tests**
- [x] **Step 2: Write failing codec and epoch tests**
Use golden vectors and property tests for:
@@ -555,7 +576,7 @@ Use golden vectors and property tests for:
- epoch `WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED` transitions, no token reuse, and
`OPERATION_EXPIRED` after seal/compaction.
- [ ] **Step 3: Verify RED**
- [x] **Step 3: Verify RED**
Run:
@@ -569,14 +590,14 @@ cd src
Expected: compilation failure because the codec/kernel types do not exist.
- [ ] **Step 4: Implement deterministic codecs**
- [x] **Step 4: Implement deterministic codecs**
Keep all physical key construction in these codecs. Route lookup uses retained binding revision,
never a current-provider default. The fingerprint includes exact content identity for R2 and an
explicit `R1_UNVERIFIED_CONTENT` marker for compatibility; it never hashes content by materializing
the object. Operation epoch records include finite replay/retention/compaction bounds.
- [ ] **Step 5: Verify GREEN**
- [x] **Step 5: Verify GREEN**
Run the command from Step 3 and
`./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain`. Expected: PASS.
@@ -610,7 +631,7 @@ Run the command from Step 3 and
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java`
- [ ] **Step 1: Write failing strict-codec tests**
- [x] **Step 1: Write failing strict-codec tests**
For every record family introduced through Task 7 prove:
@@ -629,7 +650,7 @@ size/digest/media type, encryption/retention evidence, and immutable data versio
small reference pointer CASes only the current manifest revision. No mutable pointer is treated as
the manifest itself.
- [ ] **Step 2: Write failing transition-table tests**
- [x] **Step 2: Write failing transition-table tests**
Cover every allowed and forbidden transition from design §12, including:
@@ -641,7 +662,7 @@ Cover every allowed and forbidden transition from design §12, including:
- stale fence/revision rejection;
- unknown/newer state never auto-deleted or downgraded.
- [ ] **Step 3: Verify RED**
- [x] **Step 3: Verify RED**
Run:
@@ -655,7 +676,7 @@ cd src
Expected: compilation failure because the control-plane and state-machine types do not exist.
- [ ] **Step 4: Implement the minimum kernel**
- [x] **Step 4: Implement the minimum kernel**
The kernel accepts a compiled policy snapshot and an `ObjectControlStore`; it does not import a
provider SDK. Reserve writes the frozen snapshot before provider mutation. Every mutation writes a
@@ -665,7 +686,7 @@ discriminator; no reflective or `Map<String,Object>` binding and no undecided JS
allowed. A later task that adds a durable family must modify this codec, add checked-in golden
bytes, and prove old/new reader compatibility before the selected write version changes.
- [ ] **Step 5: Verify GREEN**
- [x] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
@@ -689,7 +710,7 @@ Run the command from Step 3. Expected: PASS.
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java`
- [ ] **Step 1: Write the reusable provider contract**
- [x] **Step 1: Write the reusable provider contract**
The abstract suite must be executable for each provider and cover:
@@ -707,7 +728,7 @@ The abstract suite must be executable for each provider and cover:
Unsupported optional capabilities must assert descriptor `UNSUPPORTED`, not skip.
- [ ] **Step 2: Write failing local security/recovery tests**
- [x] **Step 2: Write failing local security/recovery tests**
Use `@TempDir` and injected filesystem/fault collaborators to test:
@@ -720,7 +741,7 @@ Use `@TempDir` and injected filesystem/fault collaborators to test:
- corrupt/newer records quarantined, never absent/deleted;
- file descriptor and temporary-file cleanup.
- [ ] **Step 3: Verify RED**
- [x] **Step 3: Verify RED**
Run:
@@ -733,7 +754,7 @@ cd src
Expected: compilation failure because the local provider/kernel integration does not exist.
- [ ] **Step 4: Implement bounded local R1**
- [x] **Step 4: Implement bounded local R1**
Use adapter-generated immutable data names, `CREATE_NEW`, bounded chunks, streaming SHA-256, staged
temporary files, force/atomic move only where the host proves it, and strict relative path checks.
@@ -741,7 +762,7 @@ The local control store serializes per operation in one process. When portabilit
durability cannot be proven, return the truthful R1 descriptor; never claim multi-node CAS or
power-loss durability. This provider is rejected in production profiles.
- [ ] **Step 5: Verify GREEN**
- [x] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
@@ -770,7 +791,7 @@ Run the command from Step 3. Expected: PASS.
- Modify:
`src/sample-portfolio/src/main/resources/application.yml`
- [ ] **Step 1: Write failing binding tests**
- [x] **Step 1: Write failing binding tests**
Bind `app.object-storage` and reject:
@@ -788,7 +809,7 @@ Bind `app.object-storage` and reject:
Prove a valid local profile compiles one exact route and immutable policy snapshot.
- [ ] **Step 2: Write failing composition tests**
- [x] **Step 2: Write failing composition tests**
Use `ApplicationContextRunner` to prove:
@@ -805,7 +826,7 @@ Use `ApplicationContextRunner` to prove:
- a namespace-separated canonical `legacy` subgroup plus the new capability may run together only
in the explicit migration/sample-local profile.
- [ ] **Step 3: Verify RED**
- [x] **Step 3: Verify RED**
Run:
@@ -819,7 +840,7 @@ cd src
Expected: compilation/test failure because canonical settings/composition do not exist and current
legacy configuration activates filesystem by default.
- [ ] **Step 4: Implement exact binding and activation**
- [x] **Step 4: Implement exact binding and activation**
Use immutable constructor-bound settings and typed `Duration`/`DataSize`/enums. Compile settings
before constructing any provider. Contributions are side-effect-free descriptors; the assembler
@@ -838,11 +859,11 @@ Until Task 22 migrates the consumer, the sample-local YAML explicitly enables th
filesystem seam and may also select `filesystem-local-dev` at `./.data/object-storage-v1` for the
new capability. No production YAML receives a local fallback.
- [ ] **Step 5: Verify GREEN**
- [x] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
- [ ] **Step 6: Prove the stable contribution seam**
- [x] **Step 6: Prove the stable contribution seam**
Add an `ApplicationContextRunner` matrix with a counting fake contribution: disabled, unselected,
invalid binding, selected success, selected construction failure, and close. Prove construction
@@ -874,7 +895,7 @@ unverified post-GREEN addition.
- Modify:
`docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md`
- [ ] **Step 1: Write the failing registry/schema test**
- [x] **Step 1: Write the failing registry/schema test**
Validate the frozen schema and exact nine card IDs. Reject:
@@ -886,7 +907,7 @@ Validate the frozen schema and exact nine card IDs. Reject:
- local-dev above R1;
- a limitation-free row when a provider descriptor reports limitations.
- [ ] **Step 2: Verify RED**
- [x] **Step 2: Verify RED**
Run:
@@ -898,13 +919,13 @@ cd src
Expected: failure because the registry and runtime card types do not exist.
- [ ] **Step 3: Add only evidenced claims**
- [x] **Step 3: Add only evidenced claims**
Initial entries may claim R0 for contract-only cards and R1 for local managed single upload/download
only after Tasks 69 pass. Direct, multipart, quarantine, retention, and production reconciliation
remain R0/unimplemented. Document that local process recovery is not multi-node CAS or R2.
- [ ] **Step 4: Run Batch B checkpoint**
- [x] **Step 4: Run Batch B checkpoint**
Run:
@@ -919,7 +940,7 @@ cd src
Expected: PASS.
- [ ] **Step 5: Verify rollback**
- [x] **Step 5: Verify rollback**
Start a context with the canonical capability disabled and legacy disabled. Assert there are no
storage beans or side effects. Start the explicit legacy profile and prove old data remains
@@ -945,7 +966,7 @@ readable. No migration or deletion occurs at startup.
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java`
- [ ] **Step 1: Write the failing pinned-API characterization**
- [x] **Step 1: Write the failing pinned-API characterization**
Compile and assert the exact `2.30.0` API used by later tasks:
@@ -960,7 +981,7 @@ Add boundary tests at `Integer.MAX_VALUE`, `Integer.MAX_VALUE + 1L`, part count
minimum S3 non-final part size. Record the full-object multipart checksum profile as unsupported
above the SDK integer boundary unless an independently approved path exists.
- [ ] **Step 2: Write failing client-policy tests**
- [x] **Step 2: Write failing client-policy tests**
Reject missing/non-positive/contradictory:
@@ -974,7 +995,7 @@ Reject missing/non-positive/contradictory:
Assert the factory uses `S3AsyncClient` plus explicit `NettyNioAsyncHttpClient`, not CRT or the sync
client, and owns close order.
- [ ] **Step 3: Verify RED**
- [x] **Step 3: Verify RED**
Run:
@@ -988,14 +1009,14 @@ cd src
Expected: compilation failure because the client factory and compile-scoped Netty async client do
not exist.
- [ ] **Step 4: Add only the required dependency and implementation**
- [x] **Step 4: Add only the required dependency and implementation**
Add `software.amazon.awssdk:netty-nio-client` under the existing AWS BOM. Keep
`software.amazon.awssdk:s3` and the BOM at `2.30.0`; do not add CRT or Transfer Manager. Configure
finite client and HTTP timeouts/pools/retry from the compiled policy. Default-chain credentials are
resolved only after the provider is selected.
- [ ] **Step 5: Regenerate and verify locks**
- [x] **Step 5: Regenerate and verify locks**
Run:
@@ -1752,6 +1773,13 @@ Approval Gate A.
## Approval Gate A — Scanner ownership and sample public contract
**Approved 2026-07-29:** The user authorized continued implementation using the recommended
defaults. The deep design now records the exact decisions: test-fake scanner only,
`integrity-verified-reference`, additive POST/GET with stable `202`, Base64 SHA-256 multipart
field, required keyed idempotency with atomic TX1 reservation/intent, existing `poster:write`,
legacy compatibility profile, new V2 event without v1 removal, intentionally scoped OpenAPI
snapshot, and additive forward-only V8 dual read.
Do not execute Tasks 2024 until the approved design records all of the following:
- staged scan fencing may be implemented with a test fake, but no production scanner/provider is
@@ -1759,7 +1787,7 @@ Do not execute Tasks 2024 until the approved design records all of the follow
- the sample's first migrated profile is either `integrity-verified-reference` or an explicitly
named scan-gated profile with a real scanner owner;
- the additive endpoint proposal
`POST /posters/{id}/image-publications`, status, authorization, request size/media policy, and
`POST /posters/{id}/imagePublications`, status, authorization, request size/media policy, and
locator-free response fields;
- the asynchronous response contract: POST always returns the same bounded `202` reservation
(opaque publication operation plus status link) replayed by `IdempotencyExecutor`; a separately
@@ -1783,8 +1811,8 @@ Do not execute Tasks 2024 until the approved design records all of the follow
window, consumer migration evidence, rollback, and zero-consumer proof before v1 removal. Never
rename `imageKey` to `reference` under the same unversioned event contract;
- intentional OpenAPI snapshot approval;
- the additive V7 schema and forward-only rollback window.
- the compatibility model during V7: existing legacy attachments remain readable/publishable,
- the additive V8 schema and forward-only rollback window.
- the compatibility model during V8: existing legacy attachments remain readable/publishable,
legacy writes are restricted to the compatibility controller/profile, and the canonical profile
writes only READY opaque references.
@@ -1902,7 +1930,7 @@ scanner, `object-storage-quarantine-publication` remains below R2.
- Modify:
`src/sample-portfolio/gradle.lockfile`
- Create:
`src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql`
`src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`:
`PosterImageUploadIntent.java`, `PosterImageUploadIntentState.java`,
@@ -1954,7 +1982,7 @@ scanner, `object-storage-quarantine-publication` remains below R2.
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java`
- Create:
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java`
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java`
- Create:
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java`
- Modify:
@@ -1986,7 +2014,7 @@ Expected: the task/configuration exists and locks are valid; no migration behavi
Run real PostgreSQL/Flyway and prove:
- fresh V1V7 and V6→V7 upgrade;
- fresh V1V8 and V7→V8 upgrade;
- V6 is unchanged;
- existing `poster.image_key` rows remain readable through the internal dual-read mapper;
- new READY attachments store only bounded opaque `image_reference`;
@@ -2004,7 +2032,7 @@ Run real PostgreSQL/Flyway and prove:
- replacement/delete work uses a separate durable `poster_image_retirement_intent` row and cannot
be inferred from a deleted Poster or overloaded upload intent.
The V7 intent table must include stable operation identity/epoch/destination, request fingerprint,
The V8 intent table must include stable operation identity/epoch/destination, request fingerprint,
bounded idempotency-scope digest, expected poster version, exact content identity/media type, state,
stage handle, handoff claim/fence/expiry, published reference/version, replaced reference,
retry/error timestamps, and optimistic revision. Index the unique scope, operation, worker claims,
@@ -2047,7 +2075,7 @@ cd src
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
```
Expected: compilation/migration failures because V7, the intent model, key-ring binding, and
Expected: compilation/migration failures because V8, the intent model, key-ring binding, and
non-skipping qualification lane do not exist. Docker/PostgreSQL absence is an actionable failure,
not a skip or passing default test.
@@ -2084,7 +2112,7 @@ Expected: PASS, and the `posterImageMigrationTest` result XML reports zero skipp
- [ ] **Step 6: Verify rollback window**
The non-skipping `PosterImageV7MigrationQualificationTest` must run a V6-compatible SQL/JPA
The non-skipping `PosterImageV8MigrationQualificationTest` must run a V7-compatible SQL/JPA
projection against the expanded schema, including old INSERT/UPDATE/read behavior. Document that
rollback is binary-only while old code ignores additive columns; there is no down migration,
column drop, intent deletion, or object mutation.
@@ -2313,7 +2341,7 @@ idempotency package. Preserve the existing application port contract and prove w
expired-find→reclaim→complete with new fingerprint/expiry/COMPLETED state, and unrelated managed
entity preservation in non-skipping real PostgreSQL. Do not emulate with a process lock. Update the
owner README/CLAUDE vendor SPI table. The same qualification queries both `idempotency_record` and
V7 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored
V8 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored
aliases/digests retain tenant separation.
Reconciliation settings are constructor-bound, disabled by default, and bound batch size,
@@ -2671,7 +2699,7 @@ migration-port/verifier/replay-store/runner beans and zero side effects. The clo
registers approval replay records with golden/new-reader/old-reader compatibility fixtures.
Retirement settings are constructor-bound and disabled by default, with bounded claim/renew,
batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V7
batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V8
retirement table and calls only logical exact-reference retirement outside DB transactions. A
disabled context creates no scheduler/thread; stale fence/takeover is rejected and response loss
remains operation-keyed exact-reference/version reconciliation, not blind retry. TX3 replacement and
@@ -3419,7 +3447,7 @@ gates.
Before the implementation completion response, read the canonical vault instructions and update:
```text
/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/<branch-name>.md
/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/<branch-name>.md
```
Record files, decisions, test commands/results, failures/blocks, evidence grade, and derivative
@@ -1,13 +1,15 @@
# Object Storage Production Capability Deep Design
- 작성일: 2026-07-28
- 상태: 상세 설계 및 구현 계획 완료, 구현 미착수, R2 미구현
- 상태: Approval Gate A 승인 반영, Batch AD 및 Batch E pause checkpoint를 main에 통합, R2 미구현
- 독립 재리뷰: 완료 — 아키텍처 및 문서 실행성 blocker 0, high 0
- LLM Wiki capture: 정본 vault
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함
- LLM Wiki capture: public vault
`raw/branch-notes/chore-main-worktree-capability-integration.md`에 main 통합 기록 완료
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
- 대상 leaf: `adapter-outbound-objectstorage`
- 현재 구현 수준: whole-object `byte[]` 기반 local filesystem/S3-MinIO 예제, R0~R1 일부
- 현재 구현 수준: provider-neutral kernel + `filesystem-local-dev` managed single R1,
S3 managed/direct primitives, exact MinIO mutation profiles `UNSUPPORTED`,
legacy whole-object filesystem/S3-MinIO 격리
- 상위 문서:
[Production Capability Platform Design](2026-07-26-production-capability-platform-design.md)
- 구현 계획:
@@ -19,39 +21,100 @@
## 0. 구현 상태
2026-07-28 기준 구현된 범위는 다음뿐이다.
### Approval Gate A 결정 — 2026-07-29
사용자의 구현 계속 승인에 따라 Batch E의 public/sample 계약을 다음과 같이 고정한다.
- production scanner는 주장하지 않는다. 스캔 경계는 test fake만 제공하고 첫 sample
destination profile은 `integrity-verified-reference`로 한다.
- canonical surface는 additive
`POST /posters/{id}/imagePublications`와 별도 authorized GET status resource다. 저장소의
AIP-122 경로 규칙에 따라 collection segment에는 kebab-case를 사용하지 않는다.
POST는 항상 동일한 bounded `202` reservation을 반환하며 READY 결과를 같은 idempotency
response에 섞지 않는다.
- 정확한 full-file SHA-256은 canonical Base64 형식의 bounded multipart field로 받고 TX1 전에
semantic fingerprint에 포함한 뒤 staging 중 다시 검증한다.
- `Idempotency-Key`는 필수다. 첫 committed UploadIntent가 stable operation ID를 할당하며
generic idempotency reservation과 intent는 같은 TX1에서 원자적으로 commit한다.
- sample authorization은 기존 `poster:write` permission을 유지한다. 존재하지 않는 Poster
owner 속성을 발명하지 않는다.
- legacy `/posters/{id}/image`는 명시적 compatibility profile에서만 유지한다. canonical과
legacy controller는 동시에 활성화하지 않는다.
- 기존 `poster.image-attached`/`imageKey` 이벤트는 변경하거나 제거하지 않는다. canonical
흐름은 새 versioned V2 event를 사용하며 외부 zero-consumer를 주장하지 않는다.
- OpenAPI 승인은 새 publication/status surface에만 한정한다. 기존 DTO의 locator-shaped
필드는 compatibility window 동안 그대로 둔다.
- 통합 타임라인의 V8은 additive, forward-only다. V7 Poster schema의 `image_key`와 legacy
reader를 유지하고 canonical writer는
READY opaque reference만 기록한다. rollback은 구 binary가 additive columns를 무시하는
binary-only window이며 down migration이나 자동 object mutation은 없다.
2026-07-28 Batch D 체크포인트 기준 다음 범위가 구현되었다.
- `application-core`의 framework/provider-neutral object-storage identity, opaque reference,
bounded content callback, digest/range, request/receipt/outcome, capability requirement;
- managed, staged, direct single/multipart, scan-maintenance, purge-maintenance를 분리한 narrow port;
- 모든 mutation의 `ObjectOperationKey`, strict reference grammar/checksum, transient grant redaction;
- application contract의 framework/provider/transport/persistent-locator purity 검사;
- legacy port/receipt의 removal-boundary deprecation과 sample legacy import freeze;
- deterministic `data/v1`/`control/v1` namespace, reference/handle/fingerprint/policy codec,
operation epoch와 immutable route/policy snapshot;
- strict canonical JSON control envelope와 operation/manifest/reference/multipart record,
reserve/replay/conflict/pending-effect state kernel;
- bounded immutable local create, exact inspect/version, full/range transfer, conditional retire,
create resolution, symlink/path confinement, restrictive permissions와 single-process CAS;
- constructor-bound `app.object-storage` settings, compile-before-construction provider
contribution/assembler, destination/retained-route routers, disabled/unselected/invalid/close
side-effect matrix;
- explicit canonical legacy subgroup와 old alias isolation, sample local/test explicit activation;
- exact nine-card readiness registry. `filesystem-local-dev` managed single upload/download만 R1,
나머지는 R0;
- pinned AWS SDK `2.30.0` conditional/checksum/expected-owner API characterization과 explicit
`NettyNioAsyncHttpClient` policy/factory/lifecycle;
- exact AWS/MinIO binding, bounded async transport, managed S3 single/multipart/control primitives,
response-loss resolution, selected-only provider composition;
- durable direct single/multipart session and grant records, S3 presigner lifecycle, exact part
acknowledgement, completion/abort fences, terminal exact-version replay;
- digest-pinned MinIO contract/fault lanes and protected AWS compile-only lane. Exact MinIO는
create-only PUT/complete 조건을 무시하므로 managed/direct mutation profile을
`UNSUPPORTED`로 유지하며 readiness를 올리지 않는다.
증거는 [Batch A checkpoint](../../evidence/object-storage/batch-a-checkpoint.md),
[Batch B checkpoint](../../evidence/object-storage/batch-b-checkpoint.md),
[Batch C checkpoint](../../evidence/object-storage/batch-c-progress.md),
[Batch D checkpoint](../../evidence/object-storage/batch-d-checkpoint.md),
[Batch E pause checkpoint](../../evidence/object-storage/batch-e-pause-checkpoint.md)에 기록했다.
격리 브랜치에서는 private vault 경로를 잘못 선택해 capture가 차단됐지만, main 통합 작업은
사용자가 지정한 public vault의
`raw/branch-notes/chore-main-worktree-capability-integration.md`에 캡처했다.
legacy runtime 범위는 별도로 유지된다.
- `application-core`의 범용 `ObjectStoragePort`;
- caller가 지정한 raw key에 `byte[]`를 put/get/delete/exists하는 계약;
- local filesystem adapter;
- synchronous AWS SDK v2 `S3Client`를 사용하는 S3/MinIO adapter;
- 설정 한 개로 filesystem 또는 S3 backend를 선택하는 Spring composition;
- 명시적 legacy-only filesystem 또는 S3 Spring composition;
- filesystem unit test, mocked S3 unit test, Testcontainers MinIO integration test;
- module registry가 허용한 `application-core`, `shared-contract` 의존성.
아직 구현되지 않은 범위:
아직 구현되지 않은 runtime/provider 범위:
- streaming upload/download와 range read;
- immutable object reference와 version token;
- stable operation ID, request fingerprint, durable operation record;
- conditional create/update/delete와 unknown-outcome reconciliation;
- checksum 생성·전송·검증 계약;
- metadata/head 계약;
- staged upload, quarantine, scan, publication;
- presigned upload/download;
- multipart start/part/complete/abort/recovery;
- public presigned upload/download endpoint와 inbound authorization;
- R2 수준의 exhaustive multipart/direct response-loss·race·restart qualification;
- lifecycle, versioning, retention, legal hold 검증;
- production credential, encryption, TLS, expected-owner 정책;
- bounded timeout, connection pool, retry amplification, graceful shutdown;
- provider capability qualification과 exact readiness card;
- multi-node linearizable control CAS와 production unknown-outcome reconciliation;
- production credential, encryption, TLS, retention/Object Lock qualification;
- orphan/abandoned multipart/retired object reaper;
- database와 object storage 사이의 crash-safe workflow;
- 운영 metric, trace, audit, runbook;
- AWS sandbox fault/security evidence.
따라서 현재 MinIO round-trip test가 통과하더라도 S3 production readiness를 의미하지 않는다.
이 문서의 상태가 “상세 설계 완료”인 것은 구현, R2 qualification 또는 운영 준비 완료를
뜻하지 않는다.
Batch B의 R1은 exact local functional topology 증거다. Multi-node CAS, power-loss durability,
S3/MinIO qualification, R2 또는 운영 준비 완료를 뜻하지 않는다.
## 1. 설계 판정
@@ -3955,8 +4018,10 @@ authorization endpoint의 ephemeral response이며 stored object DTO에 영구
정본 구현 순서와 아래 결정의 현재 freeze/approval gate는
[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md)에
기록한다. 계획 작성은 완료됐지만 모든 구현 task는 아직 미착수이며, 계획 승인이 public API,
scanner provider 또는 AWS/IaC 외부 변경 권한을 자동으로 부여하지 않는다.
기록한다. Tasks 124의 구현은 Batch E pause checkpoint까지 진행됐지만 Batch E 전체
combined/root gate는 아직 통과하지 않았다. Tasks 25–30은 미착수다. 이 구현 진행이나
계획 승인이 public API, scanner provider 또는 AWS/IaC 외부 변경 권한을 자동으로 부여하지
않는다.
구현 계획은 다음 결정을 task 단위로 명시해야 한다.
@@ -65,8 +65,7 @@ final class RedisSentinelMasterDiscovery {
&& query != null;
}
private static Set<DataEndpoint> normalizedSentinels(
List<SentinelEndpoint> sentinelEndpoints) {
private static Set<DataEndpoint> normalizedSentinels(List<SentinelEndpoint> sentinelEndpoints) {
Set<DataEndpoint> normalized = new HashSet<>();
for (SentinelEndpoint endpoint : sentinelEndpoints) {
DataEndpoint normalizedEndpoint = normalizedEndpoint(endpoint.host(), endpoint.port());
@@ -262,8 +261,7 @@ final class RedisSentinelMasterDiscovery {
}
int[] parsed = new int[8];
if (!parseIpv6Groups(left, parsed, 0)
|| !parseIpv6Groups(right, parsed, 8 - right.length)) {
if (!parseIpv6Groups(left, parsed, 0) || !parseIpv6Groups(right, parsed, 8 - right.length)) {
return null;
}
return parsed;
@@ -323,7 +321,8 @@ final class RedisSentinelMasterDiscovery {
return true;
}
if (allZero(address, 6) || (allZero(address, 5) && address[5] == 0xffff)) {
return semanticIpv4(new int[] {address[6] >>> 8, address[6] & 0xff, address[7] >>> 8, address[7] & 0xff});
return semanticIpv4(
new int[] {address[6] >>> 8, address[6] & 0xff, address[7] >>> 8, address[7] & 0xff});
}
return false;
}
@@ -52,7 +52,7 @@ class RedisCacheSecurityEvidenceTest {
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
deployment, settings, 65_536, credentials, trust, Clock.systemUTC())) {
runtime.set(
@@ -124,7 +124,7 @@ class RedisEfficiencyLeaseEvidenceTest {
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
byte[] hmacSecret = randomSecret();
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
secureDeployment(container),
runtimeSettings(),
@@ -122,7 +122,7 @@ class RedisIdempotencyEvidenceTest {
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
byte[] hmacSecret = randomSecret();
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
secureDeployment(container),
runtimeSettings(),
@@ -255,7 +255,7 @@ class RedisPrimitiveCatalogEvidenceTest {
assertThat(wrongTypeMutation.certainty())
.isEqualTo(RedisPrimitiveMutationResult.Certainty.NOT_APPLIED);
RedisTopologyCommandRuntime lossDelegate = harness.runtime("primitive-loss-delegate");
RedisRoutableCommandRuntime lossDelegate = harness.runtime("primitive-loss-delegate");
LossAfterApplyRuntime lossRuntime = new LossAfterApplyRuntime(lossDelegate);
try (RedisRoleCommandRouter lossRouter =
new RedisRoleCommandRouter(
@@ -633,14 +633,14 @@ class RedisPrimitiveCatalogEvidenceTest {
}
private Pod pod(RedisRole role, String clientName) {
RedisTopologyCommandRuntime runtime = runtime(clientName);
RedisRoutableCommandRuntime runtime = runtime(clientName);
RedisRoleCommandRouter router =
new RedisRoleCommandRouter(
role, runtime, 64, 65_536, 4_194_304, Duration.ofSeconds(6), Duration.ofSeconds(30));
return new Pod(runtime, router);
}
private RedisTopologyCommandRuntime runtime(String clientName) {
private RedisRoutableCommandRuntime runtime(String clientName) {
RedisCredentialMaterialProvider credentials =
reference ->
new VersionedRedisCredentialMaterial(
@@ -678,7 +678,7 @@ class RedisPrimitiveCatalogEvidenceTest {
}
}
private record Pod(RedisTopologyCommandRuntime runtime, RedisRoleCommandRouter router)
private record Pod(RedisRoutableCommandRuntime runtime, RedisRoleCommandRouter router)
implements AutoCloseable {
@Override
@@ -689,10 +689,10 @@ class RedisPrimitiveCatalogEvidenceTest {
private static final class LossAfterApplyRuntime implements RedisRoutableCommandRuntime {
private final RedisTopologyCommandRuntime delegate;
private final RedisRoutableCommandRuntime delegate;
private int calls;
private LossAfterApplyRuntime(RedisTopologyCommandRuntime delegate) {
private LossAfterApplyRuntime(RedisRoutableCommandRuntime delegate) {
this.delegate = delegate;
}
@@ -76,7 +76,7 @@ class RedisRateLimitEvidenceTest {
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
byte[] hmacSecret = randomSecret();
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
secureDeployment(container),
runtimeSettings(),
@@ -174,7 +174,7 @@ class RedisSemanticReadinessSecurityEvidenceTest {
DestroyableRedisPem.from(container.trustPem()));
RedisClientRuntimeSettings clientSettings = runtimeSettings();
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
deployment(container),
clientSettings,
@@ -244,7 +244,7 @@ class RedisSemanticReadinessSecurityEvidenceTest {
"semantic-evidence-v1",
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
deployment(container),
runtimeSettings(),
@@ -112,7 +112,7 @@ class RedisSessionEvidenceTest {
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
byte[] hmacSecret = randomSecret();
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
secureDeployment(container),
runtimeSettings(),
@@ -91,7 +91,7 @@ class RedisSoftLeaseEvidenceTest {
Instant.now().plusSeconds(300),
DestroyableRedisPem.from(container.trustPem()));
byte[] hmacSecret = randomSecret();
try (RedisTopologyCommandRuntime runtime =
try (RedisRoutableCommandRuntime runtime =
RedisTopologyCommandRuntime.connect(
secureDeployment(container),
runtimeSettings(),
@@ -26,8 +26,7 @@ class RedisSentinelMasterDiscoveryTest {
new RedisSentinelMasterDiscovery.DataEndpoint("master-b.internal", 6380));
@Test
void returnsTheNormalizedAllowlistedMasterWhenTwoSentinelsAgreeAndOneTimesOut()
throws Exception {
void returnsTheNormalizedAllowlistedMasterWhenTwoSentinelsAgreeAndOneTimesOut() throws Exception {
List<RedisSentinelMasterDiscovery.SentinelEndpoint> queried = new ArrayList<>();
RedisSentinelMasterDiscovery.DataEndpoint discovered =
@@ -39,9 +38,11 @@ class RedisSentinelMasterDiscoveryTest {
queried.add(sentinel);
assertThat(masterName).isEqualTo("cache-master");
if (sentinel.host().equals("sentinel-c.internal")) {
throw new TimeoutException("sentinel-c timeout with secret://redis/sentinel-password");
throw new TimeoutException(
"sentinel-c timeout with secret://redis/sentinel-password");
}
return new RedisSentinelMasterDiscovery.MasterObservation("Master-A.Internal", "6379");
return new RedisSentinelMasterDiscovery.MasterObservation(
"Master-A.Internal", "6379");
});
assertThat(discovered)
@@ -62,7 +63,8 @@ class RedisSentinelMasterDiscoveryTest {
new FixedObservations(observations)));
}
private static List<List<RedisSentinelMasterDiscovery.MasterObservation>> nonQuorumObservations() {
private static List<List<RedisSentinelMasterDiscovery.MasterObservation>>
nonQuorumObservations() {
return List.of(
List.of(
observation("master-a.internal", "6379"),
@@ -125,12 +127,14 @@ class RedisSentinelMasterDiscoveryTest {
"cache-master",
invalidAllowlist,
new FixedObservations(
List.of(observation(host, "6379"), observation(host, "6379"), observation(host, "6379")))));
List.of(
observation(host, "6379"),
observation(host, "6379"),
observation(host, "6379")))));
}
private static List<String> syntacticallyInvalidMasterHosts() {
return List.of(
"localhost", "a..b", "a.-b", "a:b", "1.2.3", "256.0.0.1", "::ffff:127.0.0.1");
return List.of("localhost", "a..b", "a.-b", "a:b", "1.2.3", "256.0.0.1", "::ffff:127.0.0.1");
}
@Test
@@ -225,7 +229,8 @@ class RedisSentinelMasterDiscoveryTest {
}));
}
private static RedisSentinelMasterDiscovery.MasterObservation observation(String host, String port) {
private static RedisSentinelMasterDiscovery.MasterObservation observation(
String host, String port) {
return new RedisSentinelMasterDiscovery.MasterObservation(host, port);
}
+29 -26
View File
@@ -4,46 +4,49 @@
- Module ID: `adapter-outbound-objectstorage`
- Gradle path: `:adapter:outbound:objectstorage`
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:objectstorage:test --console=plain`
- Focused test: `./gradlew :adapter:outbound:objectstorage:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.objectstorage`. Driven (outbound) adapter
implementing `dev.caskeleton.application.storage.ObjectStoragePort` (application-core). Design
rationale lives in [README.md](README.md).
Package root: `dev.caskeleton.adapter.outbound.objectstorage`. Design rationale and truthful
readiness live in [README.md](README.md) and
`docs/registries/object-storage-readiness.yaml`.
## Responsibility
- Persist/retrieve binary blobs behind `ObjectStoragePort`. Two backends select the same port by
`ca-skeleton.objectstorage.backend`: `filesystem` (default) and `s3` (S3/MinIO, AWS SDK v2).
- Opt-in: `ObjectStorageConfig` gates each backend with `@ConditionalOnProperty`; filesystem is the
`matchIfMissing` default. The adapters are plain classes; the config assembles them as beans.
- Implement application-core semantic object publication, read, retirement, and operation
resolution ports behind provider-neutral bounded streaming and strict durable control records.
- Compile disabled-by-default `app.object-storage` settings into exact immutable
provider/destination/route/policy bindings before constructing selected resources.
- Provide `filesystem-local-dev` as an R1-only development provider.
- Keep scanner maintenance, privileged purge, and direct-grant composition separate; unsupported
capabilities contribute no bean.
- Isolate the deprecated whole-`byte[]` `ObjectStoragePort` and old
`ca-skeleton.objectstorage.*` alias as explicit migration-only behavior.
## Allowed
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
`adapter-outbound-objectstorage` entry in `src/config/architecture/modules.json`; `src/build.gradle`
enforces it. No
`:domain-core`, no sibling adapters (shared outbound code would go through `:adapter:outbound:support`
if ever needed).
- External: `software.amazon.awssdk:s3` (version via the module-scoped `software.amazon.awssdk:bom`
platform, pinned by root `ext.awsSdkVersion`), `spring-boot-starter`,
`spring-boot-configuration-processor` (annotation processor).
- Project dependencies are exactly those allowed by the `adapter-outbound-objectstorage` entry in
`src/config/architecture/modules.json`; currently application-core and shared-contract.
- Provider SDK/configuration types stay within this leaf.
- Provider contributions may describe support without side effects and may construct resources
only after exact selection.
## Forbidden
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
- Leaking a raw AWS SDK type across `ObjectStoragePort` (B7) — the port returns only `StoredObject`
/ `byte[]` / primitives.
- Fully-qualified inline type references; more than one public top-level type per file.
- Inbound adapters, sibling outbound adapters, persistence, app-bootstrap, or sample-portfolio
dependencies.
- Provider keys, paths, locators, SDK types, Spring types, or control-record types leaking into
application-core.
- A default provider/destination/namespace, local-dev in production, or startup mutation when the
capability is disabled.
- Normal routing types implementing scanner maintenance or privileged purge ports.
- Claims above the exact rows in `docs/registries/object-storage-readiness.yaml`.
- Adapting new semantic operations back to legacy caller-keyed overwrite semantics.
## Tests
`FilesystemObjectStorageAdapterTest` (temp-dir round-trip), `S3ObjectStorageAdapterTest` (mocked
`S3Client` mapping), `S3ObjectStorageAdapterIT` (Testcontainers MinIO, `disabledWithoutDocker`).
## Test
```bash
cd src
./gradlew :adapter:outbound:objectstorage:check
./gradlew :adapter:outbound:objectstorage:check --console=plain
```
+51 -51
View File
@@ -1,72 +1,72 @@
# adapter:outbound:objectstorage — design-decision reference
Object-storage outbound (driven) adapter. Package root:
`dev.caskeleton.adapter.outbound.objectstorage`. Implements the `application-core` port
`dev.caskeleton.application.storage.ObjectStoragePort` behind an opt-in `@ConditionalOnProperty`
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient).
Object-storage driven adapter under
`dev.caskeleton.adapter.outbound.objectstorage`. It implements the semantic contracts in
`dev.caskeleton.application.objectstorage` and keeps provider keys, paths, SDK types, and control
records behind the application boundary.
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
`allowedProjectDependencies['adapter:outbound:objectstorage']` (SSOT). Module rules live in
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
comments.
The allowed production dependencies are owned by
`src/config/architecture/modules.json`. Module rules live in [CLAUDE.md](CLAUDE.md), the full design
in `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md`, and exact
evidence claims in `docs/registries/object-storage-readiness.yaml`.
## Module overview
## Current readiness
An **opt-in** blob-storage adapter placed behind an application-core port. Two backends select the
same `ObjectStoragePort` by configuration:
Batch B implements the provider-neutral namespace/control kernel and
`filesystem-local-dev` managed single upload/download at R1. R1 means bounded local functional
evidence only:
- **filesystem** (default, `matchIfMissing`) — `FilesystemObjectStorageAdapter` writes blobs under
`ca-skeleton.objectstorage.base-path`. No external service, so the local profile just works. The
`location` in the `StoredObject` receipt is the `file://` URI.
- **s3** — `S3ObjectStorageAdapter` uses the AWS SDK v2 `S3Client`. The client's endpoint override +
path-style access (wired in `ObjectStorageConfig`) make the same code work against real AWS S3
(leave `endpoint` unset) and MinIO (`endpoint=http://localhost:9000`). The `location` is an
`s3://bucket/key` URI.
- immutable streamed create with exact SHA-256 and exact version inspection;
- full/range bounded reads;
- strict canonical control-record codecs and single-process CAS;
- same-operation replay without invoking the producer again;
- restart/path/symlink/corruption/fault characterization;
- exactly-once selected-provider lifecycle.
Selector: `ca-skeleton.objectstorage.backend=filesystem|s3` (filesystem is the default). Exactly one
`ObjectStoragePort` bean is contributed, so a fork injects the port without knowing the active
backend.
It does not prove multi-node CAS, power-loss durability, production credentials/TLS/IAM/encryption,
S3 response-loss behavior, or R2 readiness. Direct transfer, multipart, quarantine, retention, and
production reconciliation cards remain R0.
## The port contract (framework-neutral)
## Activation and routing
`ObjectStoragePort` is a minimal, framework-neutral surface:
The canonical namespace is `app.object-storage` and is disabled by default. Enabling it requires an
explicit provider, exact provider version, logical destination, route token, binding/policy
revisions, and bounded sizes. Settings compile fully before any selected provider creates a
directory, client, thread, scheduler, or credential lookup.
- `StoredObject put(String key, byte[] content, String contentType)` — store/overwrite.
- `Optional<byte[]> get(String key)` — read, `empty()` when absent.
- `void delete(String key)` — idempotent delete.
- `boolean exists(String key)`.
`filesystem-local-dev` is rejected under `prod`/`production`. Normal composition contributes only
managed publication, inspection/transfer, retirement, and operation-resolution ports. Scanner and
privileged purge composition remain separate and empty; no direct-grant port is registered.
Keys are caller-supplied, backend-relative, opaque strings. Implementations reject a blank key or a
key that escapes the backend namespace (path traversal) with `IllegalArgumentException` — the
filesystem adapter normalises the resolved path and checks it still starts with the base directory.
The port intentionally exposes **no** streaming or presigned-URL surface; a fork adds those when a
concrete feature needs them. Raw external SDK types never cross the port (B7) — the adapter returns
only `StoredObject` / `byte[]` / primitives.
The local provider maps provider-neutral logical control keys to `.record` leaf files. This permits
object-store-valid pairs such as `reference` and `reference/lifecycle` without a filesystem
file/directory collision; callers and durable control records retain the canonical logical keys.
## AWS SDK versioning (why the BOM is imported at module scope)
## Legacy compatibility
`software.amazon.awssdk:*` versions are **not** managed by the Spring Boot BOM and this repo has no
version catalog. The AWS SDK v2 BOM is therefore imported as a `dependencyManagement` platform in
**this module's** `build.gradle` using the root `ext.awsSdkVersion` SSOT (set in `src/build.gradle`),
exactly like the grpc module imports `grpc-bom`. This keeps the strict-locking blast radius to this
module — the shared root `dependencyManagement` block stays awssdk-free.
`ObjectStoragePort`, `StoredObject`, and the old whole-`byte[]` filesystem/S3 adapters are deprecated
compatibility only. They preserve overwrite/raw-key semantics in an isolated namespace and never
back the new semantic ports.
## IO-failure handling
- Preferred temporary activation:
`app.object-storage.legacy.enabled=true` with an explicit backend and namespace.
- Old `ca-skeleton.objectstorage.*` properties remain an explicit legacy-only alias.
- Mixing any old alias with canonical settings fails startup without echoing values.
- The sample `local`/`test` profiles explicitly opt into legacy filesystem storage until its
consumer migration; no production profile receives a local fallback.
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
(`dependencyName="objectstorage"`) so a fork's web error handler classifies them uniformly with the
other outbound dependencies. Illegal/blank keys are `IllegalArgumentException` (a caller bug, not a
dependency failure). The S3 adapter maps `NoSuchKey` / HTTP 404 to `Optional.empty()` / `false`.
## Provider and dependency scope
## Tests
The AWS SDK v2 BOM stays module-scoped because Spring Boot does not manage
`software.amazon.awssdk:*`. The existing legacy S3/MinIO adapter remains characterized but is not a
canonical production provider and advances no S3 readiness card.
- `FilesystemObjectStorageAdapterTest``@TempDir` put/get/delete/exists round-trip, overwrite,
idempotent delete, path-traversal + blank-key rejection.
- `S3ObjectStorageAdapterTest` — key/metadata/URI mapping against a mocked `S3Client` (no network).
- `S3ObjectStorageAdapterIT` — real S3-protocol round-trip against Testcontainers MinIO; skipped
automatically when Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`).
## Verification
Focused contracts cover codecs/state machines, local provider behavior, fail-closed binding,
composition side effects/lifecycle, routing replay/read behavior, and the readiness registry.
```bash
cd src
./gradlew :adapter:outbound:objectstorage:check
./gradlew :adapter:outbound:objectstorage:check --console=plain
```
@@ -1,8 +1,6 @@
// Driven adapter: object storage behind application-core's ObjectStoragePort. Two backends — local
// filesystem (default, no external service) and S3/MinIO via the AWS SDK v2 S3 client (endpoint
// override makes the same code work against real AWS S3 and MinIO). Opt-in via
// @ConditionalOnProperty (ca-skeleton.objectstorage.backend); filesystem is the matchIfMissing
// default.
// Driven adapter: provider-neutral semantic object-storage ports plus a bounded local-development
// provider. Canonical app.object-storage activation is disabled by default. The old whole-byte[]
// filesystem/S3 adapters remain isolated, explicit legacy compatibility only.
//
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, and this repo has no
// version catalog, so the AWS SDK v2 BOM platform is imported HERE (module scope) using the root
@@ -10,6 +8,36 @@
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
sourceSets {
objectStorageMinioContractTest {
java.srcDir 'src/objectStorageMinioContractTest/java'
resources.srcDir 'src/objectStorageMinioContractTest/resources'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
objectStorageMinioFaultTest {
java.srcDir 'src/objectStorageMinioFaultTest/java'
resources.srcDir 'src/objectStorageMinioFaultTest/resources'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
objectStorageAwsQualificationTest {
java.srcDir 'src/objectStorageAwsQualificationTest/java'
resources.srcDir 'src/objectStorageAwsQualificationTest/resources'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
objectStorageMinioContractTestImplementation.extendsFrom testImplementation
objectStorageMinioContractTestRuntimeOnly.extendsFrom testRuntimeOnly
objectStorageMinioFaultTestImplementation.extendsFrom testImplementation
objectStorageMinioFaultTestRuntimeOnly.extendsFrom testRuntimeOnly
objectStorageAwsQualificationTestImplementation.extendsFrom testImplementation
objectStorageAwsQualificationTestRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencyManagement {
imports {
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
@@ -23,6 +51,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.slf4j:slf4j-api'
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:netty-nio-client'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
@@ -31,4 +60,32 @@ dependencies {
// Docker is available and is skipped (disabledWithoutDocker) otherwise.
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
testImplementation 'net.jqwik:jqwik:1.9.1'
}
tasks.register('objectStorageMinioContractTest', Test) {
description = 'Runs the non-skipping exact-release MinIO managed object contract.'
group = 'verification'
testClassesDirs = sourceSets.objectStorageMinioContractTest.output.classesDirs
classpath = sourceSets.objectStorageMinioContractTest.runtimeClasspath
useJUnitPlatform()
shouldRunAfter tasks.named('test')
}
tasks.register('objectStorageMinioFaultTest', Test) {
description = 'Runs the non-skipping digest-pinned MinIO/Toxiproxy fault contract.'
group = 'verification'
testClassesDirs = sourceSets.objectStorageMinioFaultTest.output.classesDirs
classpath = sourceSets.objectStorageMinioFaultTest.runtimeClasspath
useJUnitPlatform()
shouldRunAfter tasks.named('objectStorageMinioContractTest')
}
tasks.register('objectStorageAwsQualificationTest', Test) {
description = 'Runs only with explicit protected AWS sandbox authority and exact inputs.'
group = 'verification'
testClassesDirs = sourceSets.objectStorageAwsQualificationTest.output.classesDirs
classpath = sourceSets.objectStorageAwsQualificationTest.runtimeClasspath
useJUnitPlatform()
}
+171 -165
View File
@@ -1,208 +1,214 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.41.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-codec:commons-codec:1.19.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-compression:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-http2:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-http:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-marshalling:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-protobuf:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-compression:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http2:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-marshalling:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-protobuf:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-api:1.9.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-engine:1.9.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath
net.jqwik:jqwik-time:1.9.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-web:1.9.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik:1.9.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-compress:1.28.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,runtimeClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,runtimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestAnnotationProcessor,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.mockito:mockito-core:5.20.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.7.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-toxiproxy:2.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:annotations:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:apache-client:2.30.0=runtimeClasspath,testRuntimeClasspath
software.amazon.awssdk:arns:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:auth:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-query-protocol:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-xml-protocol:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:checksums-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:checksums:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:crt-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:endpoints-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-aws:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-client-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:identity-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:json-utils:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:metrics-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:netty-nio-client:2.30.0=runtimeClasspath,testRuntimeClasspath
software.amazon.awssdk:profiles:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:protocol-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:regions:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:retries-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:retries:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:s3:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:sdk-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:third-party-jackson-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:utils:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.eventstream:eventstream:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:annotations:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:apache-client:2.30.0=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
software.amazon.awssdk:arns:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:auth:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-core:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-query-protocol:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-xml-protocol:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:checksums-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:checksums:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:crt-core:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:endpoints-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-aws:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:http-client-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:identity-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:json-utils:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:metrics-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:netty-nio-client:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:profiles:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:protocol-core:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:regions:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:retries-spi:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:retries:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:s3:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:sdk-core:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:third-party-jackson-core:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:utils:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.eventstream:eventstream:1.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -5,6 +5,7 @@ import java.net.URI;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
@@ -14,11 +15,10 @@ import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3ClientBuilder;
/**
* Opt-in wiring for the object-storage adapter. The backend is selected by {@code
* ca-skeleton.objectstorage.backend}: {@code filesystem} (the {@code matchIfMissing} default)
* contributes a {@link FilesystemObjectStorageAdapter}; {@code s3} contributes an AWS SDK v2 {@link
* S3Client} plus an {@link S3ObjectStorageAdapter}. Exactly one {@link ObjectStoragePort} bean is
* contributed, so a fork can inject the port without knowing which backend is active.
* Deprecated opt-in wiring for the legacy object-storage adapter. The class-level activation guard
* requires an explicit {@code ca-skeleton.objectstorage.*} property before the historical
* filesystem backend default can contribute anything. New deployments use the disabled-by-default
* {@code app.object-storage} capability.
*
* <p>The S3 client's endpoint override + path-style access make the same adapter work against real
* AWS S3 (leave {@code endpoint} unset) and MinIO (set {@code endpoint=http://localhost:9000}).
@@ -26,6 +26,9 @@ import software.amazon.awssdk.services.s3.S3ClientBuilder;
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ObjectStorageSettings.class)
@Conditional(
dev.caskeleton.adapter.outbound.objectstorage.config.LegacyObjectStorageActivationGuard.class)
@SuppressWarnings("removal")
public class ObjectStorageConfig {
@Bean
@@ -3,14 +3,15 @@ package dev.caskeleton.adapter.outbound.objectstorage;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Typed settings for the object-storage adapter, bound from {@code ca-skeleton.objectstorage.*}.
* Everything both backends need is expressed here, so switching from the local filesystem to
* S3/MinIO is pure configuration. Bound as a mutable JavaBean (not a record) so a fork can leave
* any subset of fields unset and inherit the defaults below. Named {@code *Settings} per the
* code-conventions N6 naming rule for {@code dev.caskeleton} {@code @ConfigurationProperties}
* types.
* Deprecated alias settings bound from {@code ca-skeleton.objectstorage.*}. Presence is now an
* explicit legacy-only opt-in; the canonical capability uses {@code app.object-storage}. Everything
* both backends need is expressed here, so switching from the local filesystem to S3/MinIO is pure
* configuration. Bound as a mutable JavaBean (not a record) so a fork can leave any subset of
* fields unset and inherit the defaults below. Named {@code *Settings} per the code-conventions N6
* naming rule for {@code dev.caskeleton} {@code @ConfigurationProperties} types.
*/
@ConfigurationProperties(prefix = "ca-skeleton.objectstorage")
@Deprecated(forRemoval = true)
public class ObjectStorageSettings {
/** Which backend to activate: {@code filesystem} (default) or {@code s3}. */
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/** Canonical lowercase Crockford Base32 and bounded namespace support. */
public final class CrockfordBase32 {
private static final String ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
private CrockfordBase32() {}
public static String encode(byte[] bytes, int outputLength) {
if (bytes == null || bytes.length == 0 || outputLength < 1 || outputLength > 128) {
throw new IllegalArgumentException("base32 input is invalid");
}
BigInteger value = new BigInteger(1, bytes);
char[] result = new char[outputLength];
for (int index = outputLength - 1; index >= 0; index--) {
result[index] = ALPHABET.charAt(value.and(BigInteger.valueOf(31)).intValue());
value = value.shiftRight(5);
}
if (value.signum() != 0) {
throw new IllegalArgumentException("base32 output length is too small");
}
return new String(result);
}
static String requireNamespaceSegment(String label, String value, int maximumLength) {
if (value == null || value.length() > maximumLength || !value.matches("[a-z0-9][a-z0-9_-]*")) {
throw new IllegalArgumentException(label + " is not canonical");
}
return value;
}
static String shard(String canonicalIdentity) {
return sha256Hex(canonicalIdentity).substring(0, 2);
}
static String routedIdentity(String family, String route, String objectToken) {
String payload = family + "." + route + "." + objectToken;
return payload + "." + sha256Hex(payload).substring(0, 10);
}
static String sha256Hex(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
}
@@ -0,0 +1,172 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import java.util.Objects;
/** Sole encoder for bounded object-storage control namespaces. */
public final class ObjectControlKeyCodec {
private ObjectControlKeyCodec() {}
public static String operation(ObjectRouteToken route, ObjectOperationKey operation) {
required(route, operation);
return prefix("operations", route)
+ "/"
+ operation.epoch().value()
+ "/"
+ CrockfordBase32.shard(operation.operationId().value())
+ "/"
+ operation.operationId().value();
}
public static String reference(ObjectRouteToken route, ObjectReference reference) {
required(route, reference);
requireMatchingRoute(route, reference.canonicalText());
return prefix("references", route)
+ "/"
+ CrockfordBase32.shard(referenceToken(reference))
+ "/"
+ reference.canonicalText();
}
public static String referenceLifecycle(ObjectRouteToken route, ObjectReference reference) {
return reference(route, reference) + "/lifecycle";
}
public static String manifest(
ObjectRouteToken route, ObjectId objectId, ManifestRevision revision) {
required(route, objectId);
Objects.requireNonNull(revision, "revision must be non-null");
return prefix("manifests", route)
+ "/"
+ CrockfordBase32.shard(objectId.canonicalText())
+ "/"
+ objectId.canonicalText()
+ "/"
+ revision.canonicalText();
}
public static String multipart(ObjectRouteToken route, DirectTransferSessionId sessionId) {
required(route, sessionId);
requireMatchingRoute(route, sessionId.canonicalText());
return prefix("multipart", route)
+ "/"
+ CrockfordBase32.shard(referenceToken(sessionId.canonicalText()))
+ "/"
+ sessionId.canonicalText();
}
public static String directSession(ObjectRouteToken route, DirectTransferSessionId sessionId) {
required(route, sessionId);
requireMatchingRoute(route, sessionId.canonicalText());
return prefix("direct-sessions", route)
+ "/"
+ CrockfordBase32.shard(referenceToken(sessionId.canonicalText()))
+ "/"
+ sessionId.canonicalText();
}
public static String stagedObject(ObjectRouteToken route, ObjectStageHandle stageHandle) {
required(route, stageHandle);
requireMatchingRoute(route, stageHandle.canonicalText());
return prefix("staged-objects", route)
+ "/"
+ CrockfordBase32.shard(referenceToken(stageHandle.canonicalText()))
+ "/"
+ stageHandle.canonicalText();
}
public static String publicationHandoff(ObjectRouteToken route, ObjectStageHandle stageHandle) {
return stagedObject(route, stageHandle) + "/handoff";
}
public static String multipartPart(
ObjectRouteToken route, DirectTransferSessionId sessionId, MultipartPartNumber partNumber) {
required(route, sessionId);
Objects.requireNonNull(partNumber, "partNumber must be non-null");
requireMatchingRoute(route, sessionId.canonicalText());
return multipart(route, sessionId)
+ "/parts/"
+ String.format(java.util.Locale.ROOT, "%05d", partNumber.value());
}
public static String directMultipartGrant(
ObjectRouteToken route, DirectTransferSessionId sessionId, MultipartPartNumber partNumber) {
required(route, sessionId);
Objects.requireNonNull(partNumber, "partNumber must be non-null");
requireMatchingRoute(route, sessionId.canonicalText());
return multipart(route, sessionId)
+ "/grants/"
+ String.format(java.util.Locale.ROOT, "%05d", partNumber.value());
}
public static String reaperCursor(ObjectRouteToken route, ReaperJobId jobId) {
required(route, jobId);
return prefix("reaper-cursors", route) + "/" + jobId.value();
}
public static String operationEpoch(ObjectRouteToken route, ObjectOperationEpoch epoch) {
required(route, epoch);
return prefix("operation-epochs", route) + "/" + epoch.value();
}
private static String prefix(String family, ObjectRouteToken route) {
return "control/v1/" + family + "/" + route.canonicalText();
}
private static String referenceToken(ObjectReference reference) {
return referenceToken(reference.canonicalText());
}
private static String referenceToken(String routedIdentity) {
return routedIdentity.split("\\.", -1)[2];
}
private static void requireMatchingRoute(ObjectRouteToken route, String routedIdentity) {
if (!route.canonicalText().equals(routedIdentity.split("\\.", -1)[1])) {
throw new IllegalArgumentException("routed identity belongs to a different route");
}
}
private static void required(Object first, Object second) {
Objects.requireNonNull(first, "first value must be non-null");
Objects.requireNonNull(second, "second value must be non-null");
}
/** Immutable manifest revision in the adapter-owned namespace. */
public record ManifestRevision(long value) {
public ManifestRevision {
if (value < 1) {
throw new IllegalArgumentException("manifest revision must be positive");
}
}
public static ManifestRevision of(long value) {
return new ManifestRevision(value);
}
String canonicalText() {
return String.format(java.util.Locale.ROOT, "%016x", value);
}
}
/** Bounded maintenance job identity; never a user-controlled path. */
public record ReaperJobId(String value) {
public ReaperJobId {
value = CrockfordBase32.requireNamespaceSegment("reaper job id", value, 48);
}
public static ReaperJobId of(String value) {
return new ReaperJobId(value);
}
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import java.util.Objects;
/** Sole encoder for immutable provider data keys. */
public final class ObjectDataKeyCodec {
private ObjectDataKeyCodec() {}
public static String encode(ObjectRouteToken route, ObjectId objectId, Generation generation) {
Objects.requireNonNull(route, "route must be non-null");
Objects.requireNonNull(objectId, "objectId must be non-null");
Objects.requireNonNull(generation, "generation must be non-null");
return "data/v1/"
+ route.canonicalText()
+ "/"
+ CrockfordBase32.shard(objectId.canonicalText())
+ "/"
+ objectId.canonicalText()
+ "/"
+ generation.canonicalText();
}
/** Immutable, monotonically selected data generation; it is not a provider version. */
public record Generation(long value) {
public Generation {
if (value < 1) {
throw new IllegalArgumentException("data generation must be positive");
}
}
public static Generation of(long value) {
return new Generation(value);
}
String canonicalText() {
return String.format(java.util.Locale.ROOT, "%016x", value);
}
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Objects;
/** Mints type-separated unpublished and direct-transfer handles. */
public final class ObjectHandleCodec {
private ObjectHandleCodec() {}
public static ObjectStageHandle stage(ObjectRouteToken route, ObjectId objectId) {
return ObjectStageHandle.parse(routed("osh1", route, objectId));
}
public static DirectTransferSessionId directUpload(ObjectRouteToken route, ObjectId objectId) {
return DirectTransferSessionId.parse(routed("osu1", route, objectId));
}
public static DirectTransferSessionId multipart(ObjectRouteToken route, ObjectId objectId) {
return DirectTransferSessionId.parse(routed("osm1", route, objectId));
}
public static ObjectVersionToken version(ObjectRouteToken route, ObjectId objectId) {
return ObjectVersionToken.parse(routed("osv1", route, objectId));
}
public static PartReceiptToken partReceipt(
DirectTransferSessionId sessionId, MultipartPartNumber partNumber, ObjectDigest digest) {
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(partNumber, "partNumber must be non-null");
Objects.requireNonNull(digest, "digest must be non-null");
String evidence =
sessionId.canonicalText()
+ ":"
+ partNumber.value()
+ ":"
+ digest.algorithm().name()
+ ":"
+ digest.base64Value();
byte[] identityDigest;
try {
identityDigest =
MessageDigest.getInstance("SHA-256").digest(evidence.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
String token = CrockfordBase32.encode(Arrays.copyOf(identityDigest, 16), 26);
String payload = "osp1." + token;
return PartReceiptToken.parse(
payload + "." + CrockfordBase32.sha256Hex(payload).substring(0, 10));
}
private static String routed(String family, ObjectRouteToken route, ObjectId objectId) {
Objects.requireNonNull(route, "route must be non-null");
Objects.requireNonNull(objectId, "objectId must be non-null");
return CrockfordBase32.routedIdentity(family, route.canonicalText(), objectId.canonicalText());
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.Objects;
import java.util.stream.Collectors;
/** Canonical encoder for frozen, secret-free operation policy snapshots. */
public final class ObjectPolicySnapshotCodec {
private ObjectPolicySnapshotCodec() {}
public static byte[] encode(ObjectPolicySnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot must be non-null");
StringBuilder result = new StringBuilder(384);
result.append("object-policy-snapshot-v1\n");
ObjectRequestFingerprintCodec.field(result, "binding", snapshot.bindingRevision().value());
ObjectRequestFingerprintCodec.field(result, "policy", snapshot.policyRevision().value());
ObjectRequestFingerprintCodec.field(
result, "publication", snapshot.publicationRequirement().name());
ObjectRequestFingerprintCodec.field(
result, "retention", snapshot.retentionRequirement().name());
ObjectRequestFingerprintCodec.field(
result, "encryption", snapshot.encryptionRequirement().name());
String capabilities =
snapshot.capabilityRequirements().stream()
.sorted(Comparator.comparing(Enum::name))
.map(Enum::name)
.collect(Collectors.joining(","));
ObjectRequestFingerprintCodec.field(result, "capabilities", capabilities);
ObjectRequestFingerprintCodec.field(
result, "max-object-bytes", Long.toString(snapshot.maximumObjectBytes()));
ObjectRequestFingerprintCodec.field(
result, "chunk-bytes", Integer.toString(snapshot.chunkBytes()));
ObjectRequestFingerprintCodec.field(
result,
"minimum-replay-seconds",
Long.toString(snapshot.minimumReplayHorizon().toSeconds()));
return result.toString().getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import java.util.Objects;
/** Mints and validates provider-opaque published references. */
public final class ObjectReferenceCodec {
private ObjectReferenceCodec() {}
public static ObjectReference published(ObjectRouteToken route, ObjectId objectId) {
Objects.requireNonNull(route, "route must be non-null");
Objects.requireNonNull(objectId, "objectId must be non-null");
return ObjectReference.parse(
CrockfordBase32.routedIdentity("osr1", route.canonicalText(), objectId.canonicalText()));
}
public static ObjectRouteToken routeOf(ObjectReference reference) {
Objects.requireNonNull(reference, "reference must be non-null");
return ObjectRouteToken.parse(reference.canonicalText().split("\\.", -1)[1]);
}
}
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.objectstorage.codec;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Canonical, provider-neutral SHA-256 fingerprint of immutable request intent. */
public final class ObjectRequestFingerprintCodec {
private ObjectRequestFingerprintCodec() {}
public static byte[] canonicalBytes(RequestIntent intent) {
Objects.requireNonNull(intent, "intent must be non-null");
StringBuilder result = new StringBuilder(512);
result.append("object-request-fingerprint-v1\n");
field(result, "destination", intent.destination().value());
field(result, "operation", intent.operationKind().name());
field(result, "media-type", intent.mediaType().canonicalText());
field(result, "size", Long.toString(intent.expectedSize()));
field(result, "content-mode", intent.contentMode().name());
optionalField(result, "content-digest", contentDigest(intent.contentIdentity()));
field(result, "publication", intent.publicationRequirement().name());
field(result, "retention", intent.retentionRequirement().name());
field(result, "encryption", intent.encryptionRequirement().name());
optionalField(result, "direct-parameter", intent.directParameter());
optionalField(
result,
"prior-reference",
intent.priorReference() == null ? null : intent.priorReference().canonicalText());
optionalField(
result,
"prior-version",
intent.priorVersion() == null ? null : intent.priorVersion().canonicalText());
return result.toString().getBytes(StandardCharsets.UTF_8);
}
public static String sha256Hex(RequestIntent intent) {
return CrockfordBase32.sha256Hex(new String(canonicalBytes(intent), StandardCharsets.UTF_8));
}
private static String contentDigest(ObjectContentIdentity identity) {
return identity == null
? null
: identity.fullDigest().algorithm().name() + ":" + identity.fullDigest().base64Value();
}
static void field(StringBuilder target, String name, String value) {
target.append(name).append('=').append(value.length()).append(':').append(value).append('\n');
}
static void optionalField(StringBuilder target, String name, String nullableValue) {
if (nullableValue == null) {
target.append(name).append("=-\n");
} else {
field(target, name, nullableValue);
}
}
public enum ContentMode {
EXACT_R2_CONTENT,
R1_UNVERIFIED_CONTENT
}
public enum OperationKind {
PUBLISH,
STAGE,
FINALIZE,
RETIRE,
PURGE,
DIRECT_UPLOAD,
DIRECT_DOWNLOAD,
MULTIPART_START,
MULTIPART_COMPLETE,
MULTIPART_ABORT
}
/**
* Closed schema-v1 intent. Runtime budget, trace, retry, provider locator, and time are absent.
*/
public record RequestIntent(
int schemaVersion,
ObjectDestinationId destination,
OperationKind operationKind,
ObjectMediaType mediaType,
long expectedSize,
ContentMode contentMode,
ObjectContentIdentity contentIdentity,
ObjectPublicationRequirement publicationRequirement,
ObjectRetentionRequirement retentionRequirement,
ObjectEncryptionRequirement encryptionRequirement,
String directParameter,
ObjectReference priorReference,
ObjectVersionToken priorVersion) {
public RequestIntent {
if (schemaVersion != 1) {
throw new IllegalArgumentException("only fingerprint schema version 1 is writable");
}
Objects.requireNonNull(destination, "destination must be non-null");
Objects.requireNonNull(operationKind, "operationKind must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
if (expectedSize < 0) {
throw new IllegalArgumentException("expectedSize must be non-negative");
}
Objects.requireNonNull(contentMode, "contentMode must be non-null");
Objects.requireNonNull(publicationRequirement, "publicationRequirement must be non-null");
Objects.requireNonNull(retentionRequirement, "retentionRequirement must be non-null");
Objects.requireNonNull(encryptionRequirement, "encryptionRequirement must be non-null");
if (contentMode == ContentMode.EXACT_R2_CONTENT
&& (contentIdentity == null || contentIdentity.exactLength() != expectedSize)) {
throw new IllegalArgumentException("exact R2 content identity is required");
}
if (contentMode == ContentMode.R1_UNVERIFIED_CONTENT && contentIdentity != null) {
throw new IllegalArgumentException("R1 marker and exact content identity are exclusive");
}
if (directParameter != null
&& (directParameter.length() > 128
|| directParameter
.chars()
.anyMatch(character -> character < 0x20 || character > 0x7e))) {
throw new IllegalArgumentException("directParameter is invalid");
}
}
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import java.util.Objects;
/** Immutable exact destination, retained route, provider, and frozen policy binding. */
public record CompiledObjectStorageDestination(
ObjectDestinationId destinationId,
String providerId,
ObjectRouteToken route,
ObjectPolicySnapshot policySnapshot) {
public CompiledObjectStorageDestination {
Objects.requireNonNull(destinationId, "destinationId must be non-null");
Objects.requireNonNull(providerId, "providerId must be non-null");
Objects.requireNonNull(route, "route must be non-null");
Objects.requireNonNull(policySnapshot, "policySnapshot must be non-null");
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
import java.nio.file.Path;
import java.util.Objects;
/** Fully validated provider declaration safe to pass to the selected contribution factory. */
public record CompiledObjectStorageProvider(
String providerId,
String providerType,
String providerVersion,
Path localRoot,
long maximumObjectBytes,
int chunkBytes,
ObjectStorageProviderDescriptor descriptor,
Object providerSpecificBinding) {
public CompiledObjectStorageProvider(
String providerId,
String providerType,
String providerVersion,
Path localRoot,
long maximumObjectBytes,
int chunkBytes,
ObjectStorageProviderDescriptor descriptor) {
this(
providerId,
providerType,
providerVersion,
localRoot,
maximumObjectBytes,
chunkBytes,
descriptor,
null);
}
public CompiledObjectStorageProvider {
Objects.requireNonNull(providerId, "providerId must be non-null");
Objects.requireNonNull(providerType, "providerType must be non-null");
Objects.requireNonNull(providerVersion, "providerVersion must be non-null");
Objects.requireNonNull(descriptor, "descriptor must be non-null");
if (maximumObjectBytes < 1 || chunkBytes < 1) {
throw new IllegalArgumentException("compiled provider bounds must be positive");
}
}
public <T> T requireProviderBinding(Class<T> bindingType) {
if (!bindingType.isInstance(providerSpecificBinding)) {
throw new IllegalArgumentException("compiled provider binding has an unexpected type");
}
return bindingType.cast(providerSpecificBinding);
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest;
import java.nio.file.Path;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Explicit isolated legacy-adoption settings; normal runtime remains disabled. */
@ConfigurationProperties(prefix = "app.object-storage.legacy-adoption")
@SuppressWarnings("removal")
public record LegacyObjectAdoptionSettings(
boolean enabled,
LegacyObjectAdoptionRequest.Mode mode,
Path reportPath,
Path reviewedManifestPath,
String reviewedManifestSha256,
int batchSize,
Duration operationTimeout) {
public LegacyObjectAdoptionSettings {
mode = mode == null ? LegacyObjectAdoptionRequest.Mode.REPORT_ONLY : mode;
reviewedManifestSha256 = reviewedManifestSha256 == null ? "" : reviewedManifestSha256;
batchSize = batchSize == 0 ? 100 : batchSize;
operationTimeout = operationTimeout == null ? Duration.ofSeconds(30) : operationTimeout;
if (enabled
&& (reportPath == null
|| batchSize < 1
|| batchSize > 1000
|| operationTimeout.isZero()
|| operationTimeout.isNegative()
|| operationTimeout.compareTo(Duration.ofMinutes(5)) > 0)) {
throw new IllegalArgumentException("legacy adoption settings are invalid");
}
if (enabled
&& mode == LegacyObjectAdoptionRequest.Mode.APPLY
&& (reviewedManifestPath == null || !reviewedManifestSha256.matches("[0-9a-f]{64}"))) {
throw new IllegalArgumentException("legacy adoption APPLY requires a reviewed manifest");
}
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import java.util.Arrays;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.type.AnnotatedTypeMetadata;
/** Activates the deprecated alias only when explicitly present and rejects mixed namespaces. */
public final class LegacyObjectStorageActivationGuard implements Condition {
private static final String LEGACY_PREFIX = "ca-skeleton.objectstorage.";
private static final String CANONICAL_PREFIX = "app.object-storage.";
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
boolean legacy = hasPrefix(context, LEGACY_PREFIX);
boolean canonical = hasPrefix(context, CANONICAL_PREFIX);
if (legacy && canonical) {
throw new IllegalStateException(
"legacy and canonical object-storage configuration cannot be combined");
}
return legacy;
}
private static boolean hasPrefix(ConditionContext context, String prefix) {
if (context.getEnvironment() instanceof ConfigurableEnvironment environment) {
for (var propertySource : environment.getPropertySources()) {
if (propertySource instanceof EnumerablePropertySource<?> enumerable
&& Arrays.stream(enumerable.getPropertyNames())
.anyMatch(name -> name.startsWith(prefix))) {
return true;
}
}
}
return knownProperties(prefix).stream().anyMatch(context.getEnvironment()::containsProperty);
}
private static java.util.List<String> knownProperties(String prefix) {
if (LEGACY_PREFIX.equals(prefix)) {
return java.util.List.of(
prefix + "backend",
prefix + "base-path",
prefix + "bucket",
prefix + "endpoint",
prefix + "region",
prefix + "access-key",
prefix + "secret-key",
prefix + "path-style-access",
prefix + "auto-create-bucket");
}
return java.util.List.of(
prefix + "enabled",
prefix + "default-destination",
prefix + "legacy.enabled",
prefix + "legacy.backend",
prefix + "legacy.base-path");
}
}
@@ -0,0 +1,276 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicyRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Fail-closed compiler from mutable binding input to exact immutable provider/route policy. */
public final class ObjectStorageBindingCompiler {
private final Map<String, ObjectStorageProviderContribution> contributions;
private final Set<String> activeProfiles;
private final boolean scanSeamAvailable;
private final Clock clock;
public ObjectStorageBindingCompiler(
List<ObjectStorageProviderContribution> contributions,
Set<String> activeProfiles,
boolean scanSeamAvailable) {
this(contributions, activeProfiles, scanSeamAvailable, Clock.systemUTC());
}
public ObjectStorageBindingCompiler(
List<ObjectStorageProviderContribution> contributions,
Set<String> activeProfiles,
boolean scanSeamAvailable,
Clock clock) {
Objects.requireNonNull(contributions, "contributions must be non-null");
Map<String, ObjectStorageProviderContribution> indexed = new LinkedHashMap<>();
for (ObjectStorageProviderContribution contribution : contributions) {
for (String contributedType : contribution.providerTypes()) {
String type = canonicalId("provider type", contributedType);
if (indexed.put(type, contribution) != null) {
throw new IllegalArgumentException("duplicate object-storage provider contribution");
}
}
}
this.contributions = Map.copyOf(indexed);
this.activeProfiles = Set.copyOf(activeProfiles);
this.scanSeamAvailable = scanSeamAvailable;
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
public CompiledBindings compile(ObjectStorageCapabilitySettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
if (!settings.enabled()) {
return new CompiledBindings(null, Map.of(), Map.of());
}
if (settings.providers().isEmpty()
|| settings.destinations().isEmpty()
|| settings.defaultDestination() == null) {
throw invalid();
}
Map<String, CompiledObjectStorageProvider> providers = compileProviders(settings);
Map<String, CompiledObjectStorageDestination> destinations =
compileDestinations(settings, providers);
String defaultId = canonicalId("default destination", settings.defaultDestination());
CompiledObjectStorageDestination defaultDestination = destinations.get(defaultId);
if (defaultDestination == null) {
throw invalid();
}
rejectLegacyOverlap(settings, providers);
return new CompiledBindings(defaultDestination.destinationId(), providers, destinations);
}
private Map<String, CompiledObjectStorageProvider> compileProviders(
ObjectStorageCapabilitySettings settings) {
Map<String, CompiledObjectStorageProvider> compiled = new LinkedHashMap<>();
for (Map.Entry<String, ObjectStorageProviderSettings> entry : settings.providers().entrySet()) {
String providerId = canonicalId("provider id", entry.getKey());
ObjectStorageProviderSettings source =
Objects.requireNonNull(entry.getValue(), "provider settings must be non-null");
String providerType = canonicalId("provider type", source.type());
String version = bounded("provider version", source.version(), 128);
ObjectStorageProviderContribution contribution = contributions.get(providerType);
if (contribution == null
|| source.maximumObjectBytes() == null
|| source.chunkBytes() == null) {
throw invalid();
}
long maximumObjectBytes = source.maximumObjectBytes().toBytes();
long rawChunkBytes = source.chunkBytes().toBytes();
if (maximumObjectBytes < 1
|| rawChunkBytes < 1
|| rawChunkBytes > Integer.MAX_VALUE
|| rawChunkBytes > maximumObjectBytes
|| rawChunkBytes > 16L * 1024 * 1024) {
throw invalid();
}
ObjectStorageProviderDescriptor descriptor = contribution.describe(source);
if (!providerType.equals(descriptor.providerType())
|| !version.equals(descriptor.providerVersion())
|| maximumObjectBytes > descriptor.maximumObjectBytes()
|| rawChunkBytes > descriptor.maximumChunkBytes()) {
throw invalid();
}
Path localRoot =
source.root() == null ? null : Path.of(source.root()).toAbsolutePath().normalize();
CompiledObjectStorageProvider provider =
new CompiledObjectStorageProvider(
providerId,
providerType,
version,
localRoot,
maximumObjectBytes,
(int) rawChunkBytes,
descriptor,
contribution.compileProviderBinding(providerId, source));
if (compiled.put(providerId, provider) != null) {
throw invalid();
}
if ("filesystem-local-dev".equals(providerType) && productionProfileActive()) {
throw new IllegalArgumentException("filesystem-local-dev is rejected in production");
}
}
return Map.copyOf(compiled);
}
private Map<String, CompiledObjectStorageDestination> compileDestinations(
ObjectStorageCapabilitySettings settings,
Map<String, CompiledObjectStorageProvider> providers) {
Map<String, CompiledObjectStorageDestination> compiled = new LinkedHashMap<>();
Set<String> routes = new java.util.HashSet<>();
for (Map.Entry<String, ObjectStorageDestinationSettings> entry :
settings.destinations().entrySet()) {
String destinationId = canonicalId("destination id", entry.getKey());
ObjectStorageDestinationSettings source =
Objects.requireNonNull(entry.getValue(), "destination settings must be non-null");
String providerId = canonicalId("provider ref", source.providerRef());
CompiledObjectStorageProvider provider = providers.get(providerId);
if (provider == null) {
throw invalid();
}
ObjectRouteToken route = ObjectRouteToken.parse(source.routeToken());
if (!routes.add(route.canonicalText())) {
throw invalid();
}
if (source.publicationRequirement() == ObjectPublicationRequirement.SCAN_CLEAN
&& !scanSeamAvailable) {
throw new IllegalArgumentException("scan-required destination has no scanner seam");
}
requireCapabilities(source.capabilityRequirements(), provider.descriptor());
requireNamedProfile(source.operationProfile(), provider.descriptor());
ObjectPolicySnapshot policy =
new ObjectPolicySnapshot(
ObjectBindingRevision.of(source.bindingRevision()),
ObjectPolicyRevision.of(source.policyRevision()),
source.publicationRequirement(),
source.retentionRequirement(),
source.encryptionRequirement(),
source.capabilityRequirements(),
provider.maximumObjectBytes(),
provider.chunkBytes(),
source.minimumReplayHorizon());
CompiledObjectStorageDestination destination =
new CompiledObjectStorageDestination(
ObjectDestinationId.of(destinationId), providerId, route, policy);
if (compiled.put(destinationId, destination) != null) {
throw invalid();
}
}
return Map.copyOf(compiled);
}
private void requireNamedProfile(String profileName, ObjectStorageProviderDescriptor descriptor) {
if (descriptor.capabilityDescriptor().isEmpty()) {
if (profileName != null && !profileName.isBlank()) {
throw new IllegalArgumentException(
"provider does not expose named operation-profile evidence");
}
return;
}
if (profileName == null || profileName.isBlank()) {
throw new IllegalArgumentException("exact operation profile is required");
}
descriptor.capabilityDescriptor().orElseThrow().requireSupported(profileName, clock.instant());
}
private static void requireCapabilities(
Set<ObjectCapabilityRequirement> requirements, ObjectStorageProviderDescriptor descriptor) {
if (requirements == null) {
throw invalid();
}
for (ObjectCapabilityRequirement requirement : requirements) {
ObjectStorageProvider.Capability capability =
switch (requirement) {
case IMMUTABLE_CREATE -> ObjectStorageProvider.Capability.IMMUTABLE_CREATE;
case EXACT_VERSION_READ, SHA_256_VERIFICATION ->
ObjectStorageProvider.Capability.EXACT_INSPECT;
case CONDITIONAL_RETIREMENT -> ObjectStorageProvider.Capability.CONDITIONAL_RETIREMENT;
case RESPONSE_LOSS_RECONCILIATION ->
ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION;
case DIRECT_UPLOAD -> ObjectStorageProvider.Capability.DIRECT_UPLOAD;
case DIRECT_MULTIPART -> ObjectStorageProvider.Capability.DIRECT_MULTIPART;
case SCAN_GATED_PUBLICATION, RETENTION_HOLD, SERVER_SIDE_ENCRYPTION -> null;
};
if (capability == null
|| descriptor.support().get(capability) != ObjectStorageProvider.Support.SUPPORTED) {
throw new IllegalArgumentException("provider does not satisfy destination requirements");
}
}
}
private static void rejectLegacyOverlap(
ObjectStorageCapabilitySettings settings,
Map<String, CompiledObjectStorageProvider> providers) {
ObjectStorageCapabilitySettings.Legacy legacy = settings.legacy();
if (legacy == null
|| !legacy.enabled()
|| !"filesystem".equals(legacy.backend())
|| legacy.basePath() == null) {
return;
}
Path legacyRoot = Path.of(legacy.basePath()).toAbsolutePath().normalize();
for (CompiledObjectStorageProvider provider : providers.values()) {
Path providerRoot = provider.localRoot();
if (providerRoot != null
&& (providerRoot.startsWith(legacyRoot) || legacyRoot.startsWith(providerRoot))) {
throw new IllegalArgumentException("legacy and canonical namespaces overlap");
}
}
}
private boolean productionProfileActive() {
return activeProfiles.stream()
.map(profile -> profile.toLowerCase(Locale.ROOT))
.anyMatch(profile -> profile.equals("prod") || profile.equals("production"));
}
private static String canonicalId(String label, String value) {
String bounded = bounded(label, value, 64);
if (!bounded.matches("[a-z0-9][a-z0-9_-]*")
|| !bounded.equals(bounded.toLowerCase(Locale.ROOT))) {
throw invalid();
}
return bounded;
}
private static String bounded(String label, String value, int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength
|| value.chars().anyMatch(character -> character < 0x20 || character > 0x7e)) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
private static IllegalArgumentException invalid() {
return new IllegalArgumentException("object-storage binding is incomplete or invalid");
}
public record CompiledBindings(
ObjectDestinationId defaultDestination,
Map<String, CompiledObjectStorageProvider> providers,
Map<String, CompiledObjectStorageDestination> destinations) {
public CompiledBindings {
providers = Map.copyOf(providers);
destinations = Map.copyOf(destinations);
}
}
}
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Compiles all bindings before constructing only the exact selected provider contributions. */
public final class ObjectStorageCapabilityAssembler {
private final List<ObjectStorageProviderContribution> contributions;
private final ObjectStorageBindingCompiler compiler;
public ObjectStorageCapabilityAssembler(
List<ObjectStorageProviderContribution> contributions,
ObjectStorageBindingCompiler compiler) {
this.contributions = List.copyOf(contributions);
this.compiler = Objects.requireNonNull(compiler, "compiler must be non-null");
}
public AssembledCapability assemble(ObjectStorageCapabilitySettings settings) {
ObjectStorageBindingCompiler.CompiledBindings bindings = compiler.compile(settings);
if (bindings.destinations().isEmpty()) {
throw new IllegalArgumentException("disabled capability cannot be assembled");
}
Map<String, ObjectStorageProviderContribution> byType = new LinkedHashMap<>();
for (ObjectStorageProviderContribution contribution : contributions) {
for (String providerType : contribution.providerTypes()) {
byType.put(providerType, contribution);
}
}
Map<String, SelectedObjectStorageProviderFactory> selected = new LinkedHashMap<>();
List<SelectedObjectStorageProviderFactory> constructed = new ArrayList<>();
try {
for (CompiledObjectStorageDestination destination : bindings.destinations().values()) {
if (selected.containsKey(destination.providerId())) {
continue;
}
CompiledObjectStorageProvider provider =
Objects.requireNonNull(bindings.providers().get(destination.providerId()));
ObjectStorageProviderContribution contribution = byType.get(provider.providerType());
if (contribution == null) {
throw new IllegalArgumentException("selected provider contribution is unavailable");
}
SelectedObjectStorageProviderFactory factory = contribution.create(provider);
constructed.add(factory);
selected.put(provider.providerId(), factory);
}
return new AssembledCapability(bindings, selected);
} catch (RuntimeException exception) {
for (int index = constructed.size() - 1; index >= 0; index--) {
constructed.get(index).close();
}
throw exception;
}
}
/** Immutable routing state plus owned provider lifecycle. */
public static final class AssembledCapability implements AutoCloseable {
private final ObjectStorageBindingCompiler.CompiledBindings bindings;
private final Map<String, SelectedObjectStorageProviderFactory> providers;
private final Map<String, DestinationRuntime> byDestination;
private final Map<String, DestinationRuntime> byRoute;
private final AtomicBoolean closed = new AtomicBoolean();
private AssembledCapability(
ObjectStorageBindingCompiler.CompiledBindings bindings,
Map<String, SelectedObjectStorageProviderFactory> providers) {
this.bindings = bindings;
this.providers = Map.copyOf(providers);
Map<String, DestinationRuntime> destinations = new LinkedHashMap<>();
Map<String, DestinationRuntime> routes = new LinkedHashMap<>();
for (CompiledObjectStorageDestination destination : bindings.destinations().values()) {
DestinationRuntime runtime =
new DestinationRuntime(
destination,
bindings.providers().get(destination.providerId()),
providers.get(destination.providerId()));
destinations.put(destination.destinationId().value(), runtime);
routes.put(destination.route().canonicalText(), runtime);
}
byDestination = Map.copyOf(destinations);
byRoute = Map.copyOf(routes);
}
public DestinationRuntime destination(ObjectDestinationId destination) {
DestinationRuntime runtime = byDestination.get(destination.value());
if (runtime == null) {
throw new IllegalArgumentException("unknown object-storage destination");
}
return runtime;
}
public DestinationRuntime route(String routeToken) {
DestinationRuntime runtime = byRoute.get(routeToken);
if (runtime == null) {
throw new IllegalArgumentException("unknown or retired object-storage route");
}
return runtime;
}
public ObjectStorageBindingCompiler.CompiledBindings bindings() {
return bindings;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
List<SelectedObjectStorageProviderFactory> reverse = new ArrayList<>(providers.values());
for (int index = reverse.size() - 1; index >= 0; index--) {
reverse.get(index).close();
}
}
}
}
public record DestinationRuntime(
CompiledObjectStorageDestination destination,
CompiledObjectStorageProvider providerBinding,
SelectedObjectStorageProviderFactory providerFactory) {
public DestinationRuntime {
Objects.requireNonNull(destination, "destination must be non-null");
Objects.requireNonNull(providerBinding, "providerBinding must be non-null");
Objects.requireNonNull(providerFactory, "providerFactory must be non-null");
}
}
}
@@ -0,0 +1,158 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.FilesystemObjectStorageAdapter;
import dev.caskeleton.adapter.outbound.objectstorage.ObjectStorageConfig;
import dev.caskeleton.adapter.outbound.objectstorage.ObjectStorageSettings;
import dev.caskeleton.adapter.outbound.objectstorage.filesystem.FilesystemLocalDevProviderContribution;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPublicationHandoffService;
import dev.caskeleton.adapter.outbound.objectstorage.s3.S3ObjectStorageProviderContribution;
import dev.caskeleton.application.storage.ObjectStoragePort;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import software.amazon.awssdk.services.s3.S3Client;
/** Disabled-by-default canonical composition root for normal object-storage semantic ports. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ObjectStorageCapabilitySettings.class)
@SuppressWarnings("removal")
public class ObjectStorageCapabilityConfig {
@Bean
@ConditionalOnMissingBean(name = "filesystemLocalDevProviderContribution")
public ObjectStorageProviderContribution filesystemLocalDevProviderContribution() {
return new FilesystemLocalDevProviderContribution();
}
@Bean
@ConditionalOnMissingBean(name = "s3ObjectStorageProviderContribution")
public ObjectStorageProviderContribution s3ObjectStorageProviderContribution() {
return new S3ObjectStorageProviderContribution();
}
@Bean
public ObjectStorageBindingCompiler objectStorageBindingCompiler(
List<ObjectStorageProviderContribution> contributions, Environment environment) {
Set<String> profiles =
Set.copyOf(
Arrays.asList(
environment.getActiveProfiles().length == 0
? environment.getDefaultProfiles()
: environment.getActiveProfiles()));
boolean scannerWorkflowEnabled =
environment.getProperty(
"app.object-storage.scan-maintenance.enabled", Boolean.class, false);
return new ObjectStorageBindingCompiler(contributions, profiles, scannerWorkflowEnabled);
}
@Bean
public ObjectStorageCapabilityAssembler objectStorageCapabilityAssembler(
List<ObjectStorageProviderContribution> contributions,
ObjectStorageBindingCompiler compiler) {
return new ObjectStorageCapabilityAssembler(contributions, compiler);
}
@Bean(destroyMethod = "close")
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
public ObjectStorageCapabilityAssembler.AssembledCapability objectStorageCapability(
ObjectStorageCapabilitySettings settings, ObjectStorageCapabilityAssembler assembler) {
return assembler.assemble(settings);
}
@Bean
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
public RoutingObjectMutationAdapter routingObjectMutationAdapter(
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
return new RoutingObjectMutationAdapter(capability);
}
@Bean
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
public ObjectPublicationHandoffService objectPublicationHandoffService(
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
return new ObjectPublicationHandoffService(capability, java.time.Clock.systemUTC());
}
@Bean
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
public RoutingObjectReadAdapter routingObjectReadAdapter(
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
return new RoutingObjectReadAdapter(capability);
}
@Bean(destroyMethod = "close")
@ConditionalOnProperty(
prefix = "app.object-storage.legacy",
name = "enabled",
havingValue = "true")
public LegacyRuntime canonicalLegacyObjectStorageRuntime(
ObjectStorageCapabilitySettings settings) {
ObjectStorageCapabilitySettings.Legacy legacy = settings.legacy();
if ("filesystem".equals(legacy.backend())) {
if (legacy.basePath() == null || legacy.basePath().isBlank()) {
throw new IllegalArgumentException("legacy filesystem base path is required");
}
return new LegacyRuntime(new FilesystemObjectStorageAdapter(legacy.basePath()), null);
}
if ("s3".equals(legacy.backend())) {
ObjectStorageSettings old = legacySettings(legacy);
ObjectStorageConfig helper = new ObjectStorageConfig();
S3Client client = helper.objectStorageS3Client(old);
return new LegacyRuntime(helper.s3ObjectStoragePort(client, old), client);
}
throw new IllegalArgumentException("explicit legacy backend is required");
}
@Bean
@ConditionalOnProperty(
prefix = "app.object-storage.legacy",
name = "enabled",
havingValue = "true")
public ObjectStoragePort canonicalLegacyObjectStoragePort(LegacyRuntime runtime) {
return runtime.port();
}
@SuppressWarnings("removal")
private static ObjectStorageSettings legacySettings(
ObjectStorageCapabilitySettings.Legacy source) {
ObjectStorageSettings target = new ObjectStorageSettings();
target.setBackend(source.backend());
target.setBasePath(source.basePath());
target.setBucket(source.bucket());
target.setEndpoint(source.endpoint());
target.setRegion(source.region());
target.setAccessKey(source.accessKey());
target.setSecretKey(source.secretKey());
target.setPathStyleAccess(source.pathStyleAccess());
target.setAutoCreateBucket(source.autoCreateBucket());
return target;
}
public static final class LegacyRuntime implements AutoCloseable {
private final ObjectStoragePort port;
private final S3Client client;
private LegacyRuntime(ObjectStoragePort port, S3Client client) {
this.port = port;
this.client = client;
}
public ObjectStoragePort port() {
return port;
}
@Override
public void close() {
if (client != null) {
client.close();
}
}
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Canonical disabled-by-default constructor-bound object-storage capability declaration. */
@ConfigurationProperties(prefix = "app.object-storage")
public record ObjectStorageCapabilitySettings(
boolean enabled,
String defaultDestination,
Map<String, ObjectStorageProviderSettings> providers,
Map<String, ObjectStorageDestinationSettings> destinations,
Legacy legacy) {
public ObjectStorageCapabilitySettings {
providers =
providers == null
? Map.of()
: java.util.Collections.unmodifiableMap(new LinkedHashMap<>(providers));
destinations =
destinations == null
? Map.of()
: java.util.Collections.unmodifiableMap(new LinkedHashMap<>(destinations));
legacy = legacy == null ? Legacy.disabled() : legacy;
}
/** Explicit compatibility seam; it is not part of the new data/control namespace. */
public record Legacy(
boolean enabled,
String backend,
String basePath,
String bucket,
String endpoint,
String region,
String accessKey,
String secretKey,
Boolean pathStyleAccess,
boolean autoCreateBucket) {
public Legacy {
pathStyleAccess = pathStyleAccess == null ? Boolean.TRUE : pathStyleAccess;
}
static Legacy disabled() {
return new Legacy(false, null, null, null, null, null, null, null, true, false);
}
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Set;
/** Constructor-bound logical destination-to-route/provider/policy declaration. */
public record ObjectStorageDestinationSettings(
String providerRef,
String routeToken,
String bindingRevision,
String policyRevision,
ObjectPublicationRequirement publicationRequirement,
ObjectRetentionRequirement retentionRequirement,
ObjectEncryptionRequirement encryptionRequirement,
Set<ObjectCapabilityRequirement> capabilityRequirements,
Duration minimumReplayHorizon,
String operationProfile) {
public ObjectStorageDestinationSettings {
publicationRequirement =
publicationRequirement == null
? ObjectPublicationRequirement.INTEGRITY_VERIFIED
: publicationRequirement;
retentionRequirement =
retentionRequirement == null ? ObjectRetentionRequirement.NONE : retentionRequirement;
encryptionRequirement =
encryptionRequirement == null
? ObjectEncryptionRequirement.PROVIDER_MANAGED
: encryptionRequirement;
capabilityRequirements =
capabilityRequirements == null
? EnumSet.of(
ObjectCapabilityRequirement.IMMUTABLE_CREATE,
ObjectCapabilityRequirement.EXACT_VERSION_READ,
ObjectCapabilityRequirement.CONDITIONAL_RETIREMENT,
ObjectCapabilityRequirement.SHA_256_VERIFICATION,
ObjectCapabilityRequirement.RESPONSE_LOSS_RECONCILIATION)
: Set.copyOf(capabilityRequirements);
minimumReplayHorizon = minimumReplayHorizon == null ? Duration.ofDays(7) : minimumReplayHorizon;
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyAdoptionApprovalReplayStore;
import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyObjectAdoptionService;
import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyObjectInspector;
import dev.caskeleton.application.objectstorage.port.ManagedObjectPublicationPort;
import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionPort;
import java.time.Clock;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Explicit maintenance-only raw-locator composition.
*
* <p>Enabling without an isolated inspector and durable replay store fails startup.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(LegacyObjectAdoptionSettings.class)
@ConditionalOnProperty(
prefix = "app.object-storage.legacy-adoption",
name = "enabled",
havingValue = "true")
@SuppressWarnings("removal")
public class ObjectStorageLegacyMigrationConfig {
@Bean
public LegacyObjectAdoptionPort legacyObjectAdoptionPort(
LegacyObjectInspector inspector,
ManagedObjectPublicationPort publications,
LegacyAdoptionApprovalReplayStore replayStore,
Clock clock) {
return new LegacyObjectAdoptionService(inspector, publications, replayStore, clock);
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import org.springframework.context.annotation.Configuration;
/**
* Separate privileged composition boundary. Batch B intentionally contributes no purge or scanner
* maintenance bean.
*/
@Configuration(proxyBeanMethods = false)
public class ObjectStorageMaintenanceCapabilityConfig {}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
import java.util.Set;
/**
* Side-effect-free descriptor plus an explicitly selected provider construction seam.
*
* <p>{@link #describe} must not resolve credentials, create files, clients, threads, or schedulers.
* {@link #create} owns cleanup of every partial allocation before it throws; after a successful
* return the assembler owns the returned lifecycle exactly once.
*/
public interface ObjectStorageProviderContribution {
String providerType();
default Set<String> providerTypes() {
return Set.of(providerType());
}
ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings);
default Object compileProviderBinding(String providerId, ObjectStorageProviderSettings settings) {
return null;
}
SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider);
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.s3.S3ProviderSettings;
import org.springframework.util.unit.DataSize;
/** Constructor-bound provider declaration; physical namespaces have no implicit default. */
public record ObjectStorageProviderSettings(
String type,
String version,
String root,
DataSize maximumObjectBytes,
DataSize chunkBytes,
S3ProviderSettings s3) {}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Explicit scanner-workflow composition; absent from the normal application context. */
@Configuration(proxyBeanMethods = false)
public class ObjectStorageScanMaintenanceConfig {
@Bean
@ConditionalOnProperty(
prefix = "app.object-storage.scan-maintenance",
name = "enabled",
havingValue = "true")
public RoutingObjectScanMaintenanceAdapter routingObjectScanMaintenanceAdapter(
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
return new RoutingObjectScanMaintenanceAdapter(capability, java.time.Clock.systemUTC());
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
/**
* Reserved composition surface for qualified direct providers. Local R1 does not register this type
* as an application port because direct grants are explicitly unsupported.
*/
public final class RoutingObjectDirectGrantAdapter {
private RoutingObjectDirectGrantAdapter() {}
}
@@ -0,0 +1,523 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.codec.CrockfordBase32;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectReferenceCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectRequestFingerprintCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectManifestRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferencePointerRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectEffectCertainty;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectOperationKernel;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectOperationKernelResult;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PendingObjectEffect;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublicationOperationState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
import dev.caskeleton.application.objectstorage.model.ObjectOperationResolution;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationState;
import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt;
import dev.caskeleton.application.objectstorage.port.ManagedObjectPublicationPort;
import dev.caskeleton.application.objectstorage.port.ObjectOperationResolutionPort;
import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort;
import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort;
import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest;
import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest;
import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest;
import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest;
import dev.caskeleton.application.objectstorage.request.ObjectStageRequest;
import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
/** Normal mutation router. It never implements privileged purge or scanner-maintenance ports. */
public final class RoutingObjectMutationAdapter
implements ManagedObjectPublicationPort,
ObjectRetirementPort,
ObjectOperationResolutionPort,
StagedObjectPublicationPort {
private final ObjectStorageCapabilityAssembler.AssembledCapability capability;
private final Clock clock;
public RoutingObjectMutationAdapter(
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
this(capability, Clock.systemUTC());
}
public RoutingObjectMutationAdapter(
ObjectStorageCapabilityAssembler.AssembledCapability capability, Clock clock) {
this.capability = java.util.Objects.requireNonNull(capability);
this.clock = java.util.Objects.requireNonNull(clock);
}
@Override
public dev.caskeleton.application.objectstorage.model.ObjectStageReceipt stage(
ObjectStageRequest request, ObjectContentProducer producer) {
return staged(request.operationKey()).stage(request, producer);
}
@Override
public dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt verifyIntegrity(
ObjectVerifyRequest request) {
return staged(request.operationKey()).verifyIntegrity(request);
}
@Override
public ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request) {
return staged(request.operationKey()).finalizePublication(request);
}
@Override
public ObjectMutationReceipt abort(ObjectAbortRequest request) {
return staged(request.operationKey()).abort(request);
}
@Override
public ObjectPublishReceipt publish(
ObjectPublishRequest request, ObjectContentProducer producer) {
ObjectStorageCapabilityAssembler.DestinationRuntime runtime =
capability.destination(request.operationKey().destination());
requirePolicy(request, runtime.destination());
String fingerprint = fingerprint(request);
ObjectId objectId = objectId(request, fingerprint);
String dataKey =
ObjectDataKeyCodec.encode(
runtime.destination().route(), objectId, ObjectDataKeyCodec.Generation.of(1));
ObjectReference reference =
ObjectReferenceCodec.published(runtime.destination().route(), objectId);
ObjectStorageProviderOperation providerOperation =
new ObjectStorageProviderOperation(
request.operationKey(),
dataKey,
request.contentIdentity(),
request.declaredMediaType(),
request.budget(),
request.cancellation(),
runtime.providerBinding().chunkBytes(),
runtime.providerBinding().maximumObjectBytes());
ObjectOperationKernel kernel =
new ObjectOperationKernel(runtime.providerFactory().controlStore());
ObjectOperationKernelResult reserved =
kernel.reserve(
request.operationKey(),
fingerprint,
"PUBLISH",
runtime.destination().route(),
runtime.destination().policySnapshot(),
request.contentIdentity(),
Instant.now());
if (reserved.decision() == ObjectOperationKernelResult.Decision.CONFLICT) {
throw new IllegalArgumentException("operation identity conflicts with a different intent");
}
boolean replay = reserved.decision() != ObjectOperationKernelResult.Decision.RESERVED;
ObjectOperationRecord operation = reserved.record();
ObjectStorageProvider.StoredData stored =
continueDataPublication(
kernel, operation, providerOperation, runtime.providerFactory().provider(), producer);
operation =
loadOperation(runtime, request)
.orElseThrow(() -> new IllegalStateException("operation evidence disappeared"));
if (operation.stablePhase() != PublicationOperationState.PUBLISHED) {
operation = advanceToReferencePublication(kernel, operation);
persistPublishedReference(
runtime, request, reference, objectId, stored, operation.updatedAt());
operation =
loadOperation(runtime, request)
.orElseThrow(() -> new IllegalStateException("operation evidence disappeared"));
if (operation.stablePhase() == PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS) {
kernel.advancePublication(
operation,
PublicationOperationState.PUBLISHED,
stored.contentIdentity(),
Instant.now());
}
}
ObjectOperationRecord terminal =
loadOperation(runtime, request)
.orElseThrow(() -> new IllegalStateException("operation evidence disappeared"));
return new ObjectPublishReceipt(
request.operationKey(),
new RequestFingerprint(fingerprint),
reference,
stored.exactVersion(),
stored.contentIdentity(),
request.declaredMediaType(),
replay ? ObjectMutationOutcome.REPLAYED : ObjectMutationOutcome.APPLIED,
terminal.updatedAt(),
runtime.destination().policySnapshot().policyRevision().value());
}
@Override
public ObjectMutationReceipt retire(ObjectRetireRequest request) {
String routeToken = routedToken(request.reference());
ObjectStorageCapabilityAssembler.DestinationRuntime runtime = capability.route(routeToken);
if (!runtime.destination().destinationId().equals(request.operationKey().destination())) {
throw new IllegalArgumentException("retirement destination does not match reference route");
}
String key =
ObjectControlKeyCodec.referenceLifecycle(
runtime.destination().route(), request.reference());
ObjectControlStore store = runtime.providerFactory().controlStore();
ObjectControlStore.StoredControlRecord current =
store
.read(key)
.filter(stored -> stored.record() instanceof ObjectReferenceRecord)
.orElseThrow(() -> new IllegalArgumentException("published reference is absent"));
ObjectReferenceRecord record = (ObjectReferenceRecord) current.record();
if (!record.immutableDataVersion().equals(request.exactVersion())) {
return new ObjectMutationReceipt(
request.operationKey(),
ObjectMutationOutcome.REJECTED,
ObjectOperationError.CONFLICT,
Instant.now());
}
if (record.state() == PublishedReferenceState.RETIRED
|| record.state() == PublishedReferenceState.PURGE_ELIGIBLE
|| record.state() == PublishedReferenceState.PURGED) {
return new ObjectMutationReceipt(
request.operationKey(),
ObjectMutationOutcome.REPLAYED,
ObjectOperationError.NONE,
Instant.now());
}
ObjectReferenceRecord pending =
new ObjectReferenceRecord(
1,
record.recordId(),
record.revision() + 1,
record.reference(),
record.objectId(),
record.immutableDataVersion(),
PublishedReferenceState.RETIREMENT_PENDING,
Instant.now());
current = store.compareAndSet(key, new ObjectControlMutation(current.version(), pending));
ObjectReferenceRecord retired =
new ObjectReferenceRecord(
1,
record.recordId(),
pending.revision() + 1,
record.reference(),
record.objectId(),
record.immutableDataVersion(),
PublishedReferenceState.RETIRED,
Instant.now());
store.compareAndSet(key, new ObjectControlMutation(current.version(), retired));
return new ObjectMutationReceipt(
request.operationKey(),
ObjectMutationOutcome.APPLIED,
ObjectOperationError.NONE,
Instant.now());
}
@Override
public ObjectOperationResolution resolve(
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey) {
ObjectStorageCapabilityAssembler.DestinationRuntime runtime =
capability.destination(operationKey.destination());
String key = ObjectControlKeyCodec.operation(runtime.destination().route(), operationKey);
Optional<ObjectOperationRecord> record =
runtime
.providerFactory()
.controlStore()
.read(key)
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectOperationRecord.class::isInstance)
.map(ObjectOperationRecord.class::cast);
if (record.isEmpty()) {
return new ObjectOperationResolution(
operationKey,
ObjectPublicationState.ABORTED,
ObjectMutationOutcome.REJECTED,
ObjectOperationError.NOT_FOUND,
Optional.empty());
}
ObjectOperationRecord operation = record.get();
ObjectPublicationState state = publicationState(operation.stablePhase());
Optional<ObjectReference> reference =
operation.stablePhase() == PublicationOperationState.PUBLISHED
? Optional.of(
ObjectReferenceCodec.published(
operation.route(),
objectId(operation.operationKey(), operation.requestFingerprint())))
: Optional.empty();
return new ObjectOperationResolution(
operationKey,
state,
operation.stablePhase() == PublicationOperationState.PUBLISHED
? ObjectMutationOutcome.APPLIED
: ObjectMutationOutcome.INDETERMINATE,
ObjectOperationError.NONE,
reference);
}
private static ObjectStorageProvider.StoredData continueDataPublication(
ObjectOperationKernel kernel,
ObjectOperationRecord initial,
ObjectStorageProviderOperation providerOperation,
ObjectStorageProvider provider,
ObjectContentProducer producer) {
ObjectOperationRecord operation = initial;
if (operation.stablePhase() == PublicationOperationState.RESERVED) {
operation =
kernel.advancePublication(
operation, PublicationOperationState.DATA_UPLOAD_IN_PROGRESS, null, Instant.now());
}
ObjectStorageProvider.StoredData stored;
if (operation.stablePhase() == PublicationOperationState.DATA_UPLOAD_IN_PROGRESS) {
if (operation.pendingEffect() == null) {
operation =
kernel.markEffectSent(
operation,
new PendingObjectEffect(
PendingObjectEffect.Kind.DATA_PUT,
operation.operationKey().operationId().value(),
sha256Hex(providerOperation.canonicalDataKey()),
PublicationOperationState.DATA_UPLOADED.name(),
"create-if-absent",
operation.requestFingerprint()));
}
ObjectStorageProvider.Resolution resolution = provider.resolveCreate(providerOperation);
if (resolution == ObjectStorageProvider.Resolution.NOT_APPLIED) {
stored = provider.create(providerOperation, producer);
} else if (resolution == ObjectStorageProvider.Resolution.APPLIED) {
stored =
provider
.inspect(providerOperation.canonicalDataKey())
.orElseThrow(() -> new IllegalStateException("resolved local data disappeared"));
} else {
throw new IllegalStateException("provider create cannot be resolved safely");
}
if (operation.effectCertainty() != ObjectEffectCertainty.CONFIRMED) {
operation = kernel.confirmEffect(operation, Instant.now());
}
returnStoredAdvance(kernel, operation, stored);
return stored;
}
return provider
.inspect(providerOperation.canonicalDataKey())
.orElseThrow(() -> new IllegalStateException("published operation data is absent"));
}
private static void returnStoredAdvance(
ObjectOperationKernel kernel,
ObjectOperationRecord operation,
ObjectStorageProvider.StoredData stored) {
kernel.advancePublication(
operation,
PublicationOperationState.DATA_UPLOADED,
stored.contentIdentity(),
Instant.now());
}
private ObjectOperationRecord advanceToReferencePublication(
ObjectOperationKernel kernel, ObjectOperationRecord initial) {
ObjectOperationRecord operation = initial;
if (operation.stablePhase() == PublicationOperationState.DATA_UPLOADED) {
operation =
kernel.advancePublication(
operation,
PublicationOperationState.INTEGRITY_VERIFIED,
operation.observedContentIdentity(),
Instant.now());
}
if (operation.stablePhase() == PublicationOperationState.INTEGRITY_VERIFIED) {
operation =
kernel.advancePublication(
operation,
PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS,
operation.observedContentIdentity(),
Instant.now());
}
return operation;
}
private void persistPublishedReference(
ObjectStorageCapabilityAssembler.DestinationRuntime runtime,
ObjectPublishRequest request,
ObjectReference reference,
ObjectId objectId,
ObjectStorageProvider.StoredData stored,
Instant now) {
ObjectControlStore control = runtime.providerFactory().controlStore();
ObjectManifestRecord manifest =
new ObjectManifestRecord(
1,
"manifest:" + objectId.canonicalText(),
1,
objectId,
runtime.destination().route(),
runtime.providerBinding().providerType(),
runtime.providerBinding().providerVersion(),
runtime.destination().policySnapshot().bindingRevision(),
stored.contentIdentity().exactLength(),
stored.contentIdentity().fullDigest(),
request.declaredMediaType(),
stored.exactVersion(),
request.encryptionRequirement().name(),
request.retentionRequirement().name(),
now);
ObjectReferencePointerRecord pointer =
new ObjectReferencePointerRecord(
1, "pointer:" + reference.canonicalText(), 1, reference, 1, stored.exactVersion(), now);
ObjectReferenceRecord lifecycle =
new ObjectReferenceRecord(
1,
"reference:" + reference.canonicalText(),
1,
reference,
objectId,
stored.exactVersion(),
PublishedReferenceState.PUBLISHED,
now);
createOrRequireSame(
control,
ObjectControlKeyCodec.manifest(
runtime.destination().route(), objectId, ObjectControlKeyCodec.ManifestRevision.of(1)),
manifest);
createOrRequireSame(
control,
ObjectControlKeyCodec.reference(runtime.destination().route(), reference),
pointer);
createOrRequireSame(
control,
ObjectControlKeyCodec.referenceLifecycle(runtime.destination().route(), reference),
lifecycle);
}
private static void createOrRequireSame(
ObjectControlStore control, String key, ObjectControlRecord record) {
try {
control.create(key, record);
} catch (ObjectControlConflictException conflict) {
ObjectControlRecord existing =
control
.read(key)
.map(ObjectControlStore.StoredControlRecord::record)
.orElseThrow(() -> conflict);
if (!existing.equals(record)) {
throw conflict;
}
}
}
private Optional<ObjectOperationRecord> loadOperation(
ObjectStorageCapabilityAssembler.DestinationRuntime runtime, ObjectPublishRequest request) {
return runtime
.providerFactory()
.controlStore()
.read(
ObjectControlKeyCodec.operation(runtime.destination().route(), request.operationKey()))
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectOperationRecord.class::isInstance)
.map(ObjectOperationRecord.class::cast);
}
private static void requirePolicy(
ObjectPublishRequest request, CompiledObjectStorageDestination destination) {
if (request.publicationRequirement() != destination.policySnapshot().publicationRequirement()
|| request.retentionRequirement() != destination.policySnapshot().retentionRequirement()
|| request.encryptionRequirement() != destination.policySnapshot().encryptionRequirement()
|| !destination
.policySnapshot()
.capabilityRequirements()
.containsAll(request.capabilityRequirements())
|| request.contentIdentity().exactLength()
> destination.policySnapshot().maximumObjectBytes()) {
throw new IllegalArgumentException(
"request does not satisfy the compiled destination policy");
}
}
private static String fingerprint(ObjectPublishRequest request) {
return ObjectRequestFingerprintCodec.sha256Hex(
new ObjectRequestFingerprintCodec.RequestIntent(
1,
request.operationKey().destination(),
ObjectRequestFingerprintCodec.OperationKind.PUBLISH,
request.declaredMediaType(),
request.contentIdentity().exactLength(),
ObjectRequestFingerprintCodec.ContentMode.EXACT_R2_CONTENT,
request.contentIdentity(),
request.publicationRequirement(),
request.retentionRequirement(),
request.encryptionRequirement(),
null,
null,
null));
}
private static ObjectId objectId(ObjectPublishRequest request, String fingerprint) {
return objectId(request.operationKey(), fingerprint);
}
private static ObjectId objectId(
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey,
String fingerprint) {
String seed =
operationKey.destination().value()
+ "\n"
+ operationKey.epoch().value()
+ "\n"
+ operationKey.operationId().value()
+ "\n"
+ fingerprint;
try {
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8));
return ObjectId.parse(CrockfordBase32.encode(Arrays.copyOf(digest, 16), 26));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static String sha256Hex(String value) {
try {
return java.util.HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static String routedToken(ObjectReference reference) {
return reference.canonicalText().split("\\.", -1)[1];
}
private static ObjectPublicationState publicationState(PublicationOperationState state) {
return switch (state) {
case PUBLISHED -> ObjectPublicationState.PUBLISHED;
case INTEGRITY_VERIFIED, SCAN_PENDING, SCAN_CLEAN, REFERENCE_PUBLISH_IN_PROGRESS ->
ObjectPublicationState.VERIFIED;
case QUARANTINED -> ObjectPublicationState.QUARANTINED;
case ABORTED, EXPIRED, FAILED, CORRUPT -> ObjectPublicationState.ABORTED;
default -> ObjectPublicationState.STAGED;
};
}
private dev.caskeleton.adapter.outbound.objectstorage.kernel.StagedObjectPublicationKernel staged(
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation) {
return new dev.caskeleton.adapter.outbound.objectstorage.kernel.StagedObjectPublicationKernel(
capability.destination(operation.destination()), clock);
}
}
@@ -0,0 +1,147 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectManifestRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferencePointerRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.model.ObjectDescriptor;
import dev.caskeleton.application.objectstorage.model.ObjectDigestVerification;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationState;
import dev.caskeleton.application.objectstorage.model.ObjectReadReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
import dev.caskeleton.application.objectstorage.port.ObjectInspectionPort;
import dev.caskeleton.application.objectstorage.port.ObjectTransferPort;
import dev.caskeleton.application.objectstorage.request.ObjectReadRequest;
import java.util.Optional;
/** Published-reference-only read router over retained exact route revisions. */
public final class RoutingObjectReadAdapter implements ObjectInspectionPort, ObjectTransferPort {
private final ObjectStorageCapabilityAssembler.AssembledCapability capability;
public RoutingObjectReadAdapter(ObjectStorageCapabilityAssembler.AssembledCapability capability) {
this.capability = java.util.Objects.requireNonNull(capability);
}
@Override
public Optional<ObjectDescriptor> inspect(ObjectReference reference) {
Optional<LoadedReference> candidate = load(reference);
if (candidate.isEmpty()) {
return Optional.empty();
}
LoadedReference loaded = candidate.orElseThrow();
if (loaded.lifecycle().state() != PublishedReferenceState.PUBLISHED) {
return Optional.empty();
}
ObjectManifestRecord manifest = loaded.manifest();
return Optional.of(
new ObjectDescriptor(
reference,
manifest.immutableDataVersion(),
new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity(
manifest.logicalSize(), manifest.logicalDigest()),
manifest.mediaType(),
Optional.empty(),
ObjectPublicationState.PUBLISHED,
ObjectScanState.NOT_REQUIRED,
loaded.runtime().destination().policySnapshot().encryptionRequirement(),
loaded.runtime().destination().policySnapshot().retentionRequirement(),
manifest.updatedAt(),
Optional.of(manifest.updatedAt()),
1));
}
@Override
public ObjectReadReceipt transfer(ObjectReadRequest request, ObjectContentConsumer consumer) {
LoadedReference loaded =
load(request.reference())
.orElseThrow(() -> new IllegalArgumentException("object reference is absent"));
if (loaded.lifecycle().state() != PublishedReferenceState.PUBLISHED) {
throw new IllegalArgumentException("object reference is not published");
}
ObjectManifestRecord manifest = loaded.manifest();
if (request.expectedVersion().isPresent()
&& !request.expectedVersion().orElseThrow().equals(manifest.immutableDataVersion())) {
throw new IllegalArgumentException("object version precondition failed");
}
ObjectId objectId = loaded.lifecycle().objectId();
String dataKey =
ObjectDataKeyCodec.encode(
loaded.runtime().destination().route(), objectId, ObjectDataKeyCodec.Generation.of(1));
ObjectStorageProvider.ReadOperation providerRead =
new ObjectStorageProvider.ReadOperation(
dataKey,
manifest.immutableDataVersion(),
new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity(
manifest.logicalSize(), manifest.logicalDigest()),
request.range(),
request.budget(),
request.cancellation(),
loaded.runtime().providerBinding().chunkBytes(),
request.maximumDeliveredBytes());
loaded.runtime().providerFactory().provider().transfer(providerRead, consumer);
boolean wholeObject =
request.range().offset() == 0 && request.range().length() == manifest.logicalSize();
return new ObjectReadReceipt(
request.reference(),
manifest.immutableDataVersion(),
request.range(),
request.range().length(),
wholeObject && request.digestVerification() == ObjectDigestVerification.FULL_CONTENT
? ObjectDigestVerification.FULL_CONTENT
: ObjectDigestVerification.NONE);
}
private Optional<LoadedReference> load(ObjectReference reference) {
String routeToken = reference.canonicalText().split("\\.", -1)[1];
ObjectStorageCapabilityAssembler.DestinationRuntime runtime = capability.route(routeToken);
ObjectControlStore control = runtime.providerFactory().controlStore();
ObjectReferencePointerRecord pointer =
control
.read(ObjectControlKeyCodec.reference(runtime.destination().route(), reference))
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectReferencePointerRecord.class::isInstance)
.map(ObjectReferencePointerRecord.class::cast)
.orElse(null);
if (pointer == null) {
return Optional.empty();
}
ObjectReferenceRecord lifecycle =
control
.read(
ObjectControlKeyCodec.referenceLifecycle(runtime.destination().route(), reference))
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectReferenceRecord.class::isInstance)
.map(ObjectReferenceRecord.class::cast)
.orElseThrow(() -> new IllegalArgumentException("object lifecycle is absent"));
ObjectManifestRecord manifest =
control
.read(
ObjectControlKeyCodec.manifest(
runtime.destination().route(),
lifecycle.objectId(),
ObjectControlKeyCodec.ManifestRevision.of(pointer.manifestRevision())))
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectManifestRecord.class::isInstance)
.map(ObjectManifestRecord.class::cast)
.orElseThrow(() -> new IllegalArgumentException("object manifest is absent"));
if (!pointer.immutableDataVersion().equals(lifecycle.immutableDataVersion())
|| !pointer.immutableDataVersion().equals(manifest.immutableDataVersion())) {
throw new IllegalStateException("object reference evidence is corrupt");
}
return Optional.of(new LoadedReference(runtime, pointer, lifecycle, manifest));
}
private record LoadedReference(
ObjectStorageCapabilityAssembler.DestinationRuntime runtime,
ObjectReferencePointerRecord pointer,
ObjectReferenceRecord lifecycle,
ObjectManifestRecord manifest) {}
}
@@ -0,0 +1,147 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectStagedObjectRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectOperationKernel;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublicationOperationState;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.model.ObjectDigestVerification;
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
import dev.caskeleton.application.objectstorage.model.ObjectScanReadReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
import dev.caskeleton.application.objectstorage.port.ObjectScanMaintenancePort;
import dev.caskeleton.application.objectstorage.request.ObjectScanReadRequest;
import dev.caskeleton.application.objectstorage.request.ObjectScanVerdictRequest;
import java.time.Clock;
import java.util.Objects;
/** Privileged scanner-only exact unpublished read and verdict router. */
public final class RoutingObjectScanMaintenanceAdapter implements ObjectScanMaintenancePort {
private final ObjectStorageCapabilityAssembler.AssembledCapability capability;
private final Clock clock;
public RoutingObjectScanMaintenanceAdapter(
ObjectStorageCapabilityAssembler.AssembledCapability capability, Clock clock) {
this.capability = Objects.requireNonNull(capability, "capability must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public ObjectScanReadReceipt transferForScan(
ObjectScanReadRequest request, ObjectContentConsumer consumer) {
Loaded loaded = load(request.operationKey(), request.stageHandle());
requireVersion(loaded.stage(), request.exactVersion());
if (!loaded.stage().integrityVerified()
|| loaded.stage().scanState() != ObjectScanState.PENDING) {
throw new IllegalStateException("staged object is not awaiting scan");
}
loaded
.runtime()
.providerFactory()
.provider()
.transfer(
new ObjectStorageProvider.ReadOperation(
loaded.stage().canonicalDataKey(),
loaded.stage().exactVersion(),
loaded.stage().contentIdentity(),
request.range(),
request.budget(),
request.cancellation(),
loaded.runtime().providerBinding().chunkBytes(),
request.maximumDeliveredBytes()),
consumer);
boolean full =
request.range().offset() == 0
&& request.range().length() == loaded.stage().contentIdentity().exactLength();
return new ObjectScanReadReceipt(
request.stageHandle(),
request.exactVersion(),
request.range(),
request.range().length(),
full ? ObjectDigestVerification.FULL_CONTENT : ObjectDigestVerification.NONE);
}
@Override
public ObjectMutationReceipt recordScanVerdict(ObjectScanVerdictRequest request) {
Loaded loaded = load(request.operationKey(), request.stageHandle());
requireVersion(loaded.stage(), request.exactVersion());
ObjectStagedObjectRecord verdict =
loaded
.stage()
.recordVerdict(
request.scanOperationId(),
request.scannerPolicyRevision(),
request.verdict(),
request.observedAt());
ObjectMutationOutcome outcome =
verdict.equals(loaded.stage())
? ObjectMutationOutcome.REPLAYED
: ObjectMutationOutcome.APPLIED;
if (!verdict.equals(loaded.stage())) {
loaded
.store()
.compareAndSet(
loaded.stageKey(), new ObjectControlMutation(loaded.stored().version(), verdict));
ObjectOperationRecord operation =
loaded
.store()
.read(
ObjectControlKeyCodec.operation(
loaded.runtime().destination().route(), request.operationKey()))
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectOperationRecord.class::isInstance)
.map(ObjectOperationRecord.class::cast)
.orElseThrow(() -> new IllegalStateException("scan operation is absent"));
ObjectOperationKernel kernel = new ObjectOperationKernel(loaded.store());
if (request.verdict() == ObjectScanState.CLEAN) {
kernel.advancePublication(
operation,
PublicationOperationState.SCAN_CLEAN,
verdict.contentIdentity(),
clock.instant());
} else {
kernel.terminate(operation, PublicationOperationState.QUARANTINED, clock.instant());
}
}
return new ObjectMutationReceipt(
request.operationKey(), outcome, ObjectOperationError.NONE, clock.instant());
}
private Loaded load(
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
dev.caskeleton.application.objectstorage.identity.ObjectStageHandle handle) {
ObjectStorageCapabilityAssembler.DestinationRuntime runtime =
capability.destination(operation.destination());
ObjectControlStore store = runtime.providerFactory().controlStore();
String key = ObjectControlKeyCodec.stagedObject(runtime.destination().route(), handle);
ObjectControlStore.StoredControlRecord stored =
store.read(key).orElseThrow(() -> new IllegalArgumentException("staged object is absent"));
if (!(stored.record() instanceof ObjectStagedObjectRecord stage)
|| !stage.operationKey().equals(operation)) {
throw new IllegalArgumentException("scan operation conflicts");
}
return new Loaded(runtime, store, key, stored, stage);
}
private static void requireVersion(
ObjectStagedObjectRecord stage,
dev.caskeleton.application.objectstorage.identity.ObjectVersionToken version) {
if (!stage.exactVersion().equals(version)) {
throw new IllegalArgumentException("scan exact version conflicts");
}
}
private record Loaded(
ObjectStorageCapabilityAssembler.DestinationRuntime runtime,
ObjectControlStore store,
String stageKey,
ObjectControlStore.StoredControlRecord stored,
ObjectStagedObjectRecord stage) {}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.objectstorage.config;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
/** Selected provider resources with exactly-once close semantics. */
public final class SelectedObjectStorageProviderFactory implements AutoCloseable {
private final ObjectStorageProvider provider;
private final ObjectControlStore controlStore;
private final Object managedMultipartProvider;
private final Object directTransferProvider;
private final Object directMultipartProvider;
private final Runnable closeAction;
private final AtomicBoolean closed = new AtomicBoolean();
public SelectedObjectStorageProviderFactory(
ObjectStorageProvider provider, ObjectControlStore controlStore, Runnable closeAction) {
this(provider, controlStore, null, null, null, closeAction);
}
public SelectedObjectStorageProviderFactory(
ObjectStorageProvider provider,
ObjectControlStore controlStore,
Object managedMultipartProvider,
Runnable closeAction) {
this(provider, controlStore, managedMultipartProvider, null, null, closeAction);
}
public SelectedObjectStorageProviderFactory(
ObjectStorageProvider provider,
ObjectControlStore controlStore,
Object managedMultipartProvider,
Object directTransferProvider,
Runnable closeAction) {
this(
provider,
controlStore,
managedMultipartProvider,
directTransferProvider,
null,
closeAction);
}
public SelectedObjectStorageProviderFactory(
ObjectStorageProvider provider,
ObjectControlStore controlStore,
Object managedMultipartProvider,
Object directTransferProvider,
Object directMultipartProvider,
Runnable closeAction) {
this.provider = Objects.requireNonNull(provider, "provider must be non-null");
this.controlStore = Objects.requireNonNull(controlStore, "controlStore must be non-null");
this.managedMultipartProvider = managedMultipartProvider;
this.directTransferProvider = directTransferProvider;
this.directMultipartProvider = directMultipartProvider;
this.closeAction = Objects.requireNonNull(closeAction, "closeAction must be non-null");
}
public ObjectStorageProvider provider() {
return provider;
}
public ObjectControlStore controlStore() {
return controlStore;
}
public <T> Optional<T> managedMultipartProvider(Class<T> providerType) {
Objects.requireNonNull(providerType, "providerType must be non-null");
return providerType.isInstance(managedMultipartProvider)
? Optional.of(providerType.cast(managedMultipartProvider))
: Optional.empty();
}
public <T> Optional<T> directTransferProvider(Class<T> providerType) {
Objects.requireNonNull(providerType, "providerType must be non-null");
return providerType.isInstance(directTransferProvider)
? Optional.of(providerType.cast(directTransferProvider))
: Optional.empty();
}
public <T> Optional<T> directMultipartProvider(Class<T> providerType) {
Objects.requireNonNull(providerType, "providerType must be non-null");
return providerType.isInstance(directMultipartProvider)
? Optional.of(providerType.cast(directMultipartProvider))
: Optional.empty();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
closeAction.run();
}
}
}
@@ -0,0 +1,898 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectTransferSessionRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectEffectCertainty;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicyRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PendingObjectEffect;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublicationOperationState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm;
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HexFormat;
import java.util.Objects;
import java.util.stream.Collectors;
/** JDK-only canonical-json-v1 codec with an outer SHA-256 corruption digest. */
public final class CanonicalJsonObjectControlRecordCodec implements ObjectControlRecordCodec {
private static final int MAXIMUM_ENVELOPE_BYTES = 64 * 1024;
private static final int MAXIMUM_TERMINAL_RECEIPT_BYTES = 16 * 1024;
private static final int MAXIMUM_PART_BYTES = 4 * 1024;
@Override
public byte[] encode(ObjectControlRecord record) {
Objects.requireNonNull(record, "record must be non-null");
byte[] payload = payload(record);
enforceFamilySize(record, payload.length);
return envelope(payload);
}
@Override
public ObjectControlRecord decode(byte[] encoded) {
try {
return decodeUnchecked(encoded);
} catch (UnsupportedObjectControlSchemaException | ObjectControlCorruptionException exception) {
throw exception;
} catch (IllegalArgumentException exception) {
throw new ObjectControlCorruptionException(
"control record violates schema invariants", exception);
}
}
private ObjectControlRecord decodeUnchecked(byte[] encoded) {
if (encoded == null || encoded.length == 0 || encoded.length > MAXIMUM_ENVELOPE_BYTES) {
throw corrupt();
}
CanonicalJsonReader outer = new CanonicalJsonReader(encoded);
String digest = outer.string("digest");
String base64Payload = outer.string("payload");
outer.end();
if (!digest.matches("[0-9a-f]{64}")) {
throw corrupt();
}
byte[] payload;
try {
payload = Base64.getDecoder().decode(base64Payload);
} catch (IllegalArgumentException exception) {
throw new ObjectControlCorruptionException(
"control payload is not canonical Base64", exception);
}
if (!Base64.getEncoder().encodeToString(payload).equals(base64Payload)
|| !MessageDigest.isEqual(
digest.getBytes(StandardCharsets.US_ASCII),
sha256Hex(payload).getBytes(StandardCharsets.US_ASCII))) {
throw corrupt();
}
ObjectControlRecord record = decodePayload(payload);
enforceFamilySize(record, payload.length);
return record;
}
public byte[] envelope(byte[] canonicalPayload) {
Objects.requireNonNull(canonicalPayload, "canonicalPayload must be non-null");
byte[] encoded =
new CanonicalJsonWriter()
.string("digest", sha256Hex(canonicalPayload))
.string("payload", Base64.getEncoder().encodeToString(canonicalPayload))
.bytes();
if (encoded.length > MAXIMUM_ENVELOPE_BYTES) {
throw corrupt();
}
return encoded;
}
public String sha256Hex(byte[] value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static byte[] payload(ObjectControlRecord record) {
CanonicalJsonWriter writer =
new CanonicalJsonWriter()
.string("family", record.family().name())
.number("schema", record.schemaVersion())
.string("recordId", record.recordId())
.number("revision", record.revision());
return switch (record) {
case ObjectDirectSessionRecord direct -> directSession(writer, direct);
case ObjectDirectMultipartSessionRecord session -> directMultipartSession(writer, session);
case ObjectDirectMultipartGrantRecord grant -> directMultipartGrant(writer, grant);
case ObjectOperationRecord operation -> operation(writer, operation);
case ObjectManifestRecord manifest -> manifest(writer, manifest);
case ObjectReferencePointerRecord pointer -> pointer(writer, pointer);
case ObjectReferenceRecord reference -> reference(writer, reference);
case ObjectStagedObjectRecord staged -> staged(writer, staged);
case ObjectPublicationHandoffRecord handoff -> handoff(writer, handoff);
case ObjectMultipartSessionRecord session -> session(writer, session);
case ObjectMultipartPartRecord part -> part(writer, part);
};
}
private static byte[] staged(CanonicalJsonWriter writer, ObjectStagedObjectRecord record) {
return writer
.string("destination", record.operationKey().destination().value())
.string("operationEpoch", record.operationKey().epoch().value())
.string("operationId", record.operationKey().operationId().value())
.string("stageHandle", record.stageHandle().canonicalText())
.string("objectId", record.objectId().canonicalText())
.string("canonicalDataKey", record.canonicalDataKey())
.string("exactVersion", record.exactVersion().canonicalText())
.number("contentSize", record.contentIdentity().exactLength())
.string("digestAlgorithm", record.contentIdentity().fullDigest().algorithm().name())
.string("digestValue", record.contentIdentity().fullDigest().base64Value())
.string("mediaType", record.mediaType().canonicalText())
.string("publicationRequirement", record.publicationRequirement().name())
.bool("integrityVerified", record.integrityVerified())
.nullableString(
"scanOperationId",
record.scanOperationId() == null ? null : record.scanOperationId().value())
.nullableString("scannerPolicyRevision", record.scannerPolicyRevision())
.string("scanState", record.scanState().name())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] handoff(CanonicalJsonWriter writer, ObjectPublicationHandoffRecord record) {
return writer
.string("destination", record.operationKey().destination().value())
.string("operationEpoch", record.operationKey().epoch().value())
.string("operationId", record.operationKey().operationId().value())
.string("stageHandle", record.stageHandle().canonicalText())
.string("exactVersion", record.exactVersion().canonicalText())
.number("fence", record.fence())
.string("claimantDigest", record.claimantDigest())
.string("leaseExpiresAt", record.leaseExpiresAt().toString())
.bool("released", record.released())
.bool("abortAuthorized", record.abortAuthorized())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] directMultipartSession(
CanonicalJsonWriter writer, ObjectDirectMultipartSessionRecord record) {
return writer
.string("destination", record.operationKey().destination().value())
.string("operationEpoch", record.operationKey().epoch().value())
.string("operationId", record.operationKey().operationId().value())
.string("sessionId", record.sessionId().canonicalText())
.string("multipartState", record.multipartState().name())
.string("canonicalDataKey", record.canonicalDataKey())
.number("expectedSize", record.expectedContent().exactLength())
.string("expectedDigestAlgorithm", record.expectedContent().fullDigest().algorithm().name())
.string("expectedDigestValue", record.expectedContent().fullDigest().base64Value())
.string("mediaType", record.mediaType().canonicalText())
.nullableString("providerSessionEvidence", record.providerSessionEvidence())
.number("maximumParts", record.maximumParts())
.number("acknowledgedPartCount", record.acknowledgedPartCount())
.string("sessionExpiresAt", record.sessionExpiresAt().toString())
.nullableString(
"latestGrantExpiresAt",
record.latestGrantExpiresAt() == null ? null : record.latestGrantExpiresAt().toString())
.nullableString(
"admissionClosedAt",
record.admissionClosedAt() == null ? null : record.admissionClosedAt().toString())
.nullableString(
"completedVersion",
record.completedVersion() == null ? null : record.completedVersion().canonicalText())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] directMultipartGrant(
CanonicalJsonWriter writer, ObjectDirectMultipartGrantRecord record) {
return writer
.string("sessionId", record.sessionId().canonicalText())
.number("partNumber", record.partNumber().value())
.number("grantGeneration", record.grantGeneration())
.string("grantState", record.grantState().name())
.number("exactPartLength", record.exactPartLength())
.string("expectedDigestAlgorithm", record.expectedPartDigest().algorithm().name())
.string("expectedDigestValue", record.expectedPartDigest().base64Value())
.string("signingTime", record.signingTime().toString())
.string("expiresAt", record.expiresAt().toString())
.nullableString("providerPartEvidence", record.providerPartEvidence())
.nullableString(
"receiptToken",
record.receiptToken() == null ? null : record.receiptToken().canonicalText())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] directSession(
CanonicalJsonWriter writer, ObjectDirectSessionRecord record) {
DirectTransferSessionRecord session = record.session();
return writer
.string("destination", session.operationKey().destination().value())
.string("operationEpoch", session.operationKey().epoch().value())
.string("operationId", session.operationKey().operationId().value())
.string("sessionId", session.sessionId().canonicalText())
.string("state", session.state().name())
.number("grantGeneration", session.grantGeneration())
.nullableString("constraintsDigest", session.constraintsDigest())
.nullableString(
"signingTime", session.signingTime() == null ? null : session.signingTime().toString())
.nullableString(
"expiresAt", session.expiresAt() == null ? null : session.expiresAt().toString())
.nullableString("credentialRevision", session.credentialRevision())
.nullableString("referenceRevision", session.referenceRevision())
.string("canonicalDataKey", session.canonicalDataKey())
.number("expectedSize", session.expectedContent().exactLength())
.string(
"expectedDigestAlgorithm", session.expectedContent().fullDigest().algorithm().name())
.string("expectedDigestValue", session.expectedContent().fullDigest().base64Value())
.string("mediaType", session.mediaType().canonicalText())
.nullableString(
"exactVersion",
session.exactVersion() == null ? null : session.exactVersion().canonicalText())
.string("updatedAt", session.updatedAt().toString())
.bytes();
}
private static byte[] operation(CanonicalJsonWriter writer, ObjectOperationRecord record) {
PendingObjectEffect effect = record.pendingEffect();
return writer
.string("destination", record.operationKey().destination().value())
.string("operationEpoch", record.operationKey().epoch().value())
.string("operationId", record.operationKey().operationId().value())
.string("requestFingerprint", record.requestFingerprint())
.string("operationKind", record.operationKind())
.string("route", record.route().canonicalText())
.string("bindingRevision", record.bindingRevision().value())
.string("policyRevision", record.policyRevision().value())
.string("publicationRequirement", record.policySnapshot().publicationRequirement().name())
.string("retentionRequirement", record.policySnapshot().retentionRequirement().name())
.string("encryptionRequirement", record.policySnapshot().encryptionRequirement().name())
.string(
"capabilityRequirements",
record.policySnapshot().capabilityRequirements().stream()
.sorted(Comparator.comparing(Enum::name))
.map(Enum::name)
.collect(Collectors.joining(",")))
.number("maximumObjectBytes", record.policySnapshot().maximumObjectBytes())
.number("chunkBytes", record.policySnapshot().chunkBytes())
.number("minimumReplaySeconds", record.policySnapshot().minimumReplayHorizon().toSeconds())
.string("policySnapshotDigest", record.policySnapshotDigest())
.nullableNumber(
"expectedSize",
record.expectedContentIdentity() == null
? null
: record.expectedContentIdentity().exactLength())
.nullableString(
"expectedDigestAlgorithm",
record.expectedContentIdentity() == null
? null
: record.expectedContentIdentity().fullDigest().algorithm().name())
.nullableString(
"expectedDigestValue",
record.expectedContentIdentity() == null
? null
: record.expectedContentIdentity().fullDigest().base64Value())
.nullableNumber(
"observedSize",
record.observedContentIdentity() == null
? null
: record.observedContentIdentity().exactLength())
.nullableString(
"observedDigestAlgorithm",
record.observedContentIdentity() == null
? null
: record.observedContentIdentity().fullDigest().algorithm().name())
.nullableString(
"observedDigestValue",
record.observedContentIdentity() == null
? null
: record.observedContentIdentity().fullDigest().base64Value())
.string("stablePhase", record.stablePhase().name())
.number("stateVersion", record.stateVersion())
.nullableString("pendingKind", effect == null ? null : effect.kind().name())
.nullableString("attemptId", effect == null ? null : effect.attemptId())
.nullableString("exactTargetEvidence", effect == null ? null : effect.exactTargetEvidence())
.nullableString("desiredState", effect == null ? null : effect.desiredState())
.nullableString("precondition", effect == null ? null : effect.precondition())
.nullableString(
"requestEvidenceDigest", effect == null ? null : effect.requestEvidenceDigest())
.string("effectCertainty", record.effectCertainty().name())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] manifest(CanonicalJsonWriter writer, ObjectManifestRecord record) {
return writer
.string("objectId", record.objectId().canonicalText())
.string("route", record.route().canonicalText())
.string("providerType", record.providerType())
.string("providerVersion", record.providerVersion())
.string("bindingRevision", record.bindingRevision().value())
.number("logicalSize", record.logicalSize())
.string("digestAlgorithm", record.logicalDigest().algorithm().name())
.string("digestValue", record.logicalDigest().base64Value())
.string("mediaType", record.mediaType().canonicalText())
.string("immutableDataVersion", record.immutableDataVersion().canonicalText())
.string("encryptionEvidence", record.encryptionEvidence())
.string("retentionEvidence", record.retentionEvidence())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] pointer(CanonicalJsonWriter writer, ObjectReferencePointerRecord record) {
return writer
.string("reference", record.reference().canonicalText())
.number("manifestRevision", record.manifestRevision())
.string("immutableDataVersion", record.immutableDataVersion().canonicalText())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] reference(CanonicalJsonWriter writer, ObjectReferenceRecord record) {
return writer
.string("reference", record.reference().canonicalText())
.string("objectId", record.objectId().canonicalText())
.string("immutableDataVersion", record.immutableDataVersion().canonicalText())
.string("state", record.state().name())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] session(CanonicalJsonWriter writer, ObjectMultipartSessionRecord record) {
return writer
.string("sessionId", record.sessionId().canonicalText())
.string("multipartState", record.multipartState().name())
.string("grantState", record.grantState().name())
.string("providerSessionEvidence", record.providerSessionEvidence())
.number("partCount", record.partCount())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static byte[] part(CanonicalJsonWriter writer, ObjectMultipartPartRecord record) {
return writer
.string("sessionId", record.sessionId().canonicalText())
.number("partNumber", record.partNumber().value())
.string("receiptToken", record.receiptToken().canonicalText())
.number("logicalSize", record.logicalSize())
.string("digestAlgorithm", record.digest().algorithm().name())
.string("digestValue", record.digest().base64Value())
.string("providerPartEvidence", record.providerPartEvidence())
.string("updatedAt", record.updatedAt().toString())
.bytes();
}
private static ObjectControlRecord decodePayload(byte[] payload) {
CanonicalJsonReader reader = new CanonicalJsonReader(payload);
ObjectControlRecord.Family family;
try {
family = ObjectControlRecord.Family.valueOf(reader.string("family"));
} catch (IllegalArgumentException exception) {
throw new UnsupportedObjectControlSchemaException("control record family is unsupported");
}
long schema = reader.number("schema");
if (schema != 1) {
throw new UnsupportedObjectControlSchemaException("control schema is unsupported");
}
String recordId = reader.string("recordId");
long revision = reader.number("revision");
return switch (family) {
case OPERATION -> readOperation(reader, recordId, revision);
case MANIFEST -> readManifest(reader, recordId, revision);
case REFERENCE_POINTER -> readPointer(reader, recordId, revision);
case REFERENCE -> readReference(reader, recordId, revision);
case DIRECT_SESSION -> readDirectSession(reader, recordId, revision);
case DIRECT_MULTIPART_SESSION -> readDirectMultipartSession(reader, recordId, revision);
case DIRECT_MULTIPART_GRANT -> readDirectMultipartGrant(reader, recordId, revision);
case STAGED_OBJECT -> readStaged(reader, recordId, revision);
case PUBLICATION_HANDOFF -> readHandoff(reader, recordId, revision);
case MULTIPART_SESSION -> readSession(reader, recordId, revision);
case MULTIPART_PART -> readPart(reader, recordId, revision);
};
}
private static ObjectStagedObjectRecord readStaged(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectOperationKey operation =
new ObjectOperationKey(
ObjectDestinationId.of(reader.string("destination")),
ObjectOperationEpoch.of(reader.string("operationEpoch")),
ObjectOperationId.of(reader.string("operationId")));
ObjectStageHandle stage = ObjectStageHandle.parse(reader.string("stageHandle"));
ObjectId objectId = ObjectId.parse(reader.string("objectId"));
String dataKey = reader.string("canonicalDataKey");
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("exactVersion"));
ObjectContentIdentity content =
readContentIdentity(
reader.number("contentSize"),
reader.string("digestAlgorithm"),
reader.string("digestValue"));
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
ObjectPublicationRequirement requirement =
enumValue(ObjectPublicationRequirement.class, reader.string("publicationRequirement"));
boolean verified = reader.bool("integrityVerified");
String scanOperation = reader.nullableString("scanOperationId");
String scannerPolicy = reader.nullableString("scannerPolicyRevision");
ObjectScanState scanState = enumValue(ObjectScanState.class, reader.string("scanState"));
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectStagedObjectRecord(
1,
recordId,
revision,
operation,
stage,
objectId,
dataKey,
version,
content,
mediaType,
requirement,
verified,
scanOperation == null ? null : ObjectOperationId.of(scanOperation),
scannerPolicy,
scanState,
updatedAt);
}
private static ObjectPublicationHandoffRecord readHandoff(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectOperationKey operation =
new ObjectOperationKey(
ObjectDestinationId.of(reader.string("destination")),
ObjectOperationEpoch.of(reader.string("operationEpoch")),
ObjectOperationId.of(reader.string("operationId")));
ObjectStageHandle stage = ObjectStageHandle.parse(reader.string("stageHandle"));
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("exactVersion"));
long fence = reader.number("fence");
String claimantDigest = reader.string("claimantDigest");
Instant expiry = instant(reader.string("leaseExpiresAt"));
boolean released = reader.bool("released");
boolean abortAuthorized = reader.bool("abortAuthorized");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectPublicationHandoffRecord(
1,
recordId,
revision,
operation,
stage,
version,
fence,
claimantDigest,
expiry,
released,
abortAuthorized,
updatedAt);
}
private static ObjectDirectMultipartSessionRecord readDirectMultipartSession(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectOperationKey operationKey =
new ObjectOperationKey(
ObjectDestinationId.of(reader.string("destination")),
ObjectOperationEpoch.of(reader.string("operationEpoch")),
ObjectOperationId.of(reader.string("operationId")));
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
MultipartUploadState state =
enumValue(MultipartUploadState.class, reader.string("multipartState"));
String dataKey = reader.string("canonicalDataKey");
ObjectContentIdentity expected =
readContentIdentity(
reader.number("expectedSize"),
reader.string("expectedDigestAlgorithm"),
reader.string("expectedDigestValue"));
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
String provider = reader.nullableString("providerSessionEvidence");
long rawMaximumParts = reader.number("maximumParts");
long acknowledged = reader.number("acknowledgedPartCount");
if (rawMaximumParts > Integer.MAX_VALUE || rawMaximumParts < Integer.MIN_VALUE) {
throw corrupt();
}
Instant sessionExpiry = instant(reader.string("sessionExpiresAt"));
String latestGrant = reader.nullableString("latestGrantExpiresAt");
String closed = reader.nullableString("admissionClosedAt");
String completedVersion = reader.nullableString("completedVersion");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectDirectMultipartSessionRecord(
1,
recordId,
revision,
operationKey,
sessionId,
state,
dataKey,
expected,
mediaType,
provider,
Math.toIntExact(rawMaximumParts),
acknowledged,
sessionExpiry,
latestGrant == null ? null : instant(latestGrant),
closed == null ? null : instant(closed),
completedVersion == null ? null : ObjectVersionToken.parse(completedVersion),
updatedAt);
}
private static ObjectDirectMultipartGrantRecord readDirectMultipartGrant(
CanonicalJsonReader reader, String recordId, long revision) {
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
long rawPart = reader.number("partNumber");
long generation = reader.number("grantGeneration");
DirectGrantSessionState state =
enumValue(DirectGrantSessionState.class, reader.string("grantState"));
long length = reader.number("exactPartLength");
ObjectDigest digest =
ObjectDigest.of(
enumValue(ObjectDigestAlgorithm.class, reader.string("expectedDigestAlgorithm")),
reader.string("expectedDigestValue"));
Instant signing = instant(reader.string("signingTime"));
Instant expiry = instant(reader.string("expiresAt"));
String provider = reader.nullableString("providerPartEvidence");
String receipt = reader.nullableString("receiptToken");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
if (rawPart > Integer.MAX_VALUE || rawPart < Integer.MIN_VALUE) {
throw corrupt();
}
return new ObjectDirectMultipartGrantRecord(
1,
recordId,
revision,
sessionId,
MultipartPartNumber.of((int) rawPart),
generation,
state,
length,
digest,
signing,
expiry,
provider,
receipt == null ? null : PartReceiptToken.parse(receipt),
updatedAt);
}
private static ObjectDirectSessionRecord readDirectSession(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectOperationKey operationKey =
new ObjectOperationKey(
ObjectDestinationId.of(reader.string("destination")),
ObjectOperationEpoch.of(reader.string("operationEpoch")),
ObjectOperationId.of(reader.string("operationId")));
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
DirectGrantSessionState state =
enumValue(DirectGrantSessionState.class, reader.string("state"));
long generation = reader.number("grantGeneration");
String constraints = reader.nullableString("constraintsDigest");
String signing = reader.nullableString("signingTime");
String expiry = reader.nullableString("expiresAt");
String credentials = reader.nullableString("credentialRevision");
String reference = reader.nullableString("referenceRevision");
String dataKey = reader.string("canonicalDataKey");
ObjectContentIdentity expected =
readContentIdentity(
reader.number("expectedSize"),
reader.string("expectedDigestAlgorithm"),
reader.string("expectedDigestValue"));
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
String exactVersion = reader.nullableString("exactVersion");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
if ((signing == null) != (expiry == null)) {
throw corrupt();
}
DirectTransferSessionRecord session =
new DirectTransferSessionRecord(
operationKey,
sessionId,
state,
generation,
constraints,
signing == null ? null : instant(signing),
expiry == null ? null : instant(expiry),
credentials,
reference,
dataKey,
expected,
mediaType,
exactVersion == null ? null : ObjectVersionToken.parse(exactVersion),
updatedAt);
return new ObjectDirectSessionRecord(1, recordId, revision, session);
}
private static ObjectOperationRecord readOperation(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectOperationKey operationKey =
new ObjectOperationKey(
ObjectDestinationId.of(reader.string("destination")),
ObjectOperationEpoch.of(reader.string("operationEpoch")),
ObjectOperationId.of(reader.string("operationId")));
String requestFingerprint = reader.string("requestFingerprint");
String operationKind = reader.string("operationKind");
ObjectRouteToken route = ObjectRouteToken.parse(reader.string("route"));
ObjectBindingRevision binding = ObjectBindingRevision.of(reader.string("bindingRevision"));
ObjectPolicyRevision policy = ObjectPolicyRevision.of(reader.string("policyRevision"));
ObjectPublicationRequirement publication =
enumValue(ObjectPublicationRequirement.class, reader.string("publicationRequirement"));
ObjectRetentionRequirement retention =
enumValue(ObjectRetentionRequirement.class, reader.string("retentionRequirement"));
ObjectEncryptionRequirement encryption =
enumValue(ObjectEncryptionRequirement.class, reader.string("encryptionRequirement"));
EnumSet<dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement>
capabilities = readCapabilities(reader.string("capabilityRequirements"));
long maximumObjectBytes = reader.number("maximumObjectBytes");
long rawChunkBytes = reader.number("chunkBytes");
if (rawChunkBytes > Integer.MAX_VALUE || rawChunkBytes < Integer.MIN_VALUE) {
throw corrupt();
}
long minimumReplaySeconds = reader.number("minimumReplaySeconds");
ObjectPolicySnapshot policySnapshot =
new ObjectPolicySnapshot(
binding,
policy,
publication,
retention,
encryption,
capabilities,
maximumObjectBytes,
(int) rawChunkBytes,
java.time.Duration.ofSeconds(minimumReplaySeconds));
String policyDigest = reader.string("policySnapshotDigest");
ObjectContentIdentity expected =
readContentIdentity(
reader.nullableNumber("expectedSize"),
reader.nullableString("expectedDigestAlgorithm"),
reader.nullableString("expectedDigestValue"));
ObjectContentIdentity observed =
readContentIdentity(
reader.nullableNumber("observedSize"),
reader.nullableString("observedDigestAlgorithm"),
reader.nullableString("observedDigestValue"));
PublicationOperationState state =
enumValue(PublicationOperationState.class, reader.string("stablePhase"));
long stateVersion = reader.number("stateVersion");
String pendingKind = reader.nullableString("pendingKind");
String attemptId = reader.nullableString("attemptId");
String target = reader.nullableString("exactTargetEvidence");
String desired = reader.nullableString("desiredState");
String precondition = reader.nullableString("precondition");
String evidence = reader.nullableString("requestEvidenceDigest");
ObjectEffectCertainty certainty =
enumValue(ObjectEffectCertainty.class, reader.string("effectCertainty"));
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
PendingObjectEffect pending =
pendingKind == null
? requireAllNull(attemptId, target, desired, precondition, evidence)
: new PendingObjectEffect(
enumValue(PendingObjectEffect.Kind.class, pendingKind),
attemptId,
target,
desired,
precondition,
evidence);
return new ObjectOperationRecord(
1,
recordId,
revision,
operationKey,
requestFingerprint,
operationKind,
route,
policySnapshot,
policyDigest,
expected,
observed,
state,
stateVersion,
pending,
certainty,
updatedAt);
}
private static ObjectManifestRecord readManifest(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectId objectId = ObjectId.parse(reader.string("objectId"));
ObjectRouteToken route = ObjectRouteToken.parse(reader.string("route"));
String providerType = reader.string("providerType");
String providerVersion = reader.string("providerVersion");
ObjectBindingRevision binding = ObjectBindingRevision.of(reader.string("bindingRevision"));
long logicalSize = reader.number("logicalSize");
ObjectDigest digest =
ObjectDigest.of(
enumValue(ObjectDigestAlgorithm.class, reader.string("digestAlgorithm")),
reader.string("digestValue"));
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("immutableDataVersion"));
String encryption = reader.string("encryptionEvidence");
String retention = reader.string("retentionEvidence");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectManifestRecord(
1,
recordId,
revision,
objectId,
route,
providerType,
providerVersion,
binding,
logicalSize,
digest,
mediaType,
version,
encryption,
retention,
updatedAt);
}
private static ObjectReferencePointerRecord readPointer(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectReference reference = ObjectReference.parse(reader.string("reference"));
long manifestRevision = reader.number("manifestRevision");
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("immutableDataVersion"));
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectReferencePointerRecord(
1, recordId, revision, reference, manifestRevision, version, updatedAt);
}
private static ObjectReferenceRecord readReference(
CanonicalJsonReader reader, String recordId, long revision) {
ObjectReference reference = ObjectReference.parse(reader.string("reference"));
ObjectId objectId = ObjectId.parse(reader.string("objectId"));
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("immutableDataVersion"));
PublishedReferenceState state =
enumValue(PublishedReferenceState.class, reader.string("state"));
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectReferenceRecord(
1, recordId, revision, reference, objectId, version, state, updatedAt);
}
private static ObjectMultipartSessionRecord readSession(
CanonicalJsonReader reader, String recordId, long revision) {
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
MultipartUploadState multipart =
enumValue(MultipartUploadState.class, reader.string("multipartState"));
DirectGrantSessionState grant =
enumValue(DirectGrantSessionState.class, reader.string("grantState"));
String provider = reader.string("providerSessionEvidence");
long count = reader.number("partCount");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectMultipartSessionRecord(
1, recordId, revision, sessionId, multipart, grant, provider, count, updatedAt);
}
private static ObjectMultipartPartRecord readPart(
CanonicalJsonReader reader, String recordId, long revision) {
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
long rawPartNumber = reader.number("partNumber");
if (rawPartNumber > Integer.MAX_VALUE || rawPartNumber < Integer.MIN_VALUE) {
throw corrupt();
}
MultipartPartNumber partNumber = MultipartPartNumber.of((int) rawPartNumber);
PartReceiptToken token = PartReceiptToken.parse(reader.string("receiptToken"));
long logicalSize = reader.number("logicalSize");
ObjectDigest digest =
ObjectDigest.of(
enumValue(ObjectDigestAlgorithm.class, reader.string("digestAlgorithm")),
reader.string("digestValue"));
String provider = reader.string("providerPartEvidence");
Instant updatedAt = instant(reader.string("updatedAt"));
reader.end();
return new ObjectMultipartPartRecord(
1,
recordId,
revision,
sessionId,
partNumber,
token,
logicalSize,
digest,
provider,
updatedAt);
}
private static PendingObjectEffect requireAllNull(String... values) {
for (String value : values) {
if (value != null) {
throw corrupt();
}
}
return null;
}
private static ObjectContentIdentity readContentIdentity(
Long size, String algorithm, String digestValue) {
if (size == null && algorithm == null && digestValue == null) {
return null;
}
if (size == null || algorithm == null || digestValue == null) {
throw corrupt();
}
return new ObjectContentIdentity(
size, ObjectDigest.of(enumValue(ObjectDigestAlgorithm.class, algorithm), digestValue));
}
private static EnumSet<dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement>
readCapabilities(String canonical) {
EnumSet<dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement> result =
EnumSet.noneOf(
dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement.class);
if (canonical.isEmpty()) {
return result;
}
String previous = null;
for (String value : canonical.split(",", -1)) {
if (previous != null && previous.compareTo(value) >= 0) {
throw corrupt();
}
result.add(
enumValue(
dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement.class,
value));
previous = value;
}
return result;
}
private static Instant instant(String value) {
try {
return Instant.parse(value);
} catch (RuntimeException exception) {
throw new ObjectControlCorruptionException("control timestamp is invalid", exception);
}
}
private static <E extends Enum<E>> E enumValue(Class<E> type, String value) {
try {
return Enum.valueOf(type, value);
} catch (IllegalArgumentException exception) {
throw new ObjectControlCorruptionException("control enum value is invalid", exception);
}
}
private static void enforceFamilySize(ObjectControlRecord record, int payloadLength) {
int maximum =
record instanceof ObjectMultipartPartRecord
? MAXIMUM_PART_BYTES
: record.terminal() ? MAXIMUM_TERMINAL_RECEIPT_BYTES : MAXIMUM_ENVELOPE_BYTES;
if (payloadLength > maximum) {
throw corrupt();
}
}
private static ObjectControlCorruptionException corrupt() {
return new ObjectControlCorruptionException("control record is corrupt");
}
}
@@ -0,0 +1,144 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import java.nio.charset.StandardCharsets;
/** Strict fixed-order JSON reader. Whitespace, reordered, duplicate, and unknown fields fail. */
public final class CanonicalJsonReader {
private final String input;
private int cursor;
private boolean first = true;
public CanonicalJsonReader(byte[] bytes) {
if (bytes == null) {
throw corrupt();
}
input = new String(bytes, StandardCharsets.UTF_8);
expect('{');
}
public String string(String expectedName) {
field(expectedName);
return quoted();
}
public String nullableString(String expectedName) {
field(expectedName);
if (startsWith("null")) {
cursor += 4;
return null;
}
return quoted();
}
public long number(String expectedName) {
field(expectedName);
return rawNumber();
}
public Long nullableNumber(String expectedName) {
field(expectedName);
if (startsWith("null")) {
cursor += 4;
return null;
}
return rawNumber();
}
private long rawNumber() {
int start = cursor;
boolean negative = false;
if (peek('-')) {
negative = true;
cursor++;
}
int digitStart = cursor;
while (cursor < input.length() && input.charAt(cursor) >= '0' && input.charAt(cursor) <= '9') {
cursor++;
}
if (digitStart == cursor
|| (input.charAt(digitStart) == '0' && cursor - digitStart > 1)
|| (negative && input.charAt(digitStart) == '0')) {
throw corrupt();
}
try {
return Long.parseLong(input.substring(start, cursor));
} catch (NumberFormatException exception) {
throw new ObjectControlCorruptionException("control JSON number is invalid", exception);
}
}
public boolean bool(String expectedName) {
field(expectedName);
if (startsWith("true")) {
cursor += 4;
return true;
}
if (startsWith("false")) {
cursor += 5;
return false;
}
throw corrupt();
}
public void end() {
expect('}');
if (cursor != input.length()) {
throw corrupt();
}
}
private void field(String expectedName) {
if (first) {
first = false;
} else {
expect(',');
}
if (!quoted().equals(expectedName)) {
throw corrupt();
}
expect(':');
}
private String quoted() {
expect('"');
StringBuilder decoded = new StringBuilder();
while (cursor < input.length()) {
char character = input.charAt(cursor++);
if (character == '"') {
return decoded.toString();
}
if (character == '\\') {
if (cursor >= input.length()) {
throw corrupt();
}
character = input.charAt(cursor++);
if (character != '"' && character != '\\') {
throw corrupt();
}
} else if (character < 0x20 || character > 0x7e) {
throw corrupt();
}
decoded.append(character);
}
throw corrupt();
}
private void expect(char expected) {
if (cursor >= input.length() || input.charAt(cursor++) != expected) {
throw corrupt();
}
}
private boolean startsWith(String value) {
return input.startsWith(value, cursor);
}
private boolean peek(char value) {
return cursor < input.length() && input.charAt(cursor) == value;
}
private static ObjectControlCorruptionException corrupt() {
return new ObjectControlCorruptionException("control JSON is not canonical");
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** Minimal fixed-order JSON writer for the closed control schema. */
public final class CanonicalJsonWriter {
private final StringBuilder output = new StringBuilder(1024);
private boolean first = true;
private boolean closed;
public CanonicalJsonWriter() {
output.append('{');
}
public CanonicalJsonWriter string(String name, String value) {
name(name);
quoted(value);
return this;
}
public CanonicalJsonWriter nullableString(String name, String value) {
name(name);
if (value == null) {
output.append("null");
} else {
quoted(value);
}
return this;
}
public CanonicalJsonWriter number(String name, long value) {
name(name);
output.append(value);
return this;
}
public CanonicalJsonWriter nullableNumber(String name, Long value) {
name(name);
if (value == null) {
output.append("null");
} else {
output.append(value);
}
return this;
}
public CanonicalJsonWriter bool(String name, boolean value) {
name(name);
output.append(value);
return this;
}
public byte[] bytes() {
if (!closed) {
output.append('}');
closed = true;
}
return output.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private void name(String name) {
if (closed) {
throw new IllegalStateException("canonical JSON writer is closed");
}
if (first) {
first = false;
} else {
output.append(',');
}
quoted(name);
output.append(':');
}
private void quoted(String value) {
if (value == null) {
throw new IllegalArgumentException("canonical JSON string must be non-null");
}
output.append('"');
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (character == '"' || character == '\\') {
output.append('\\').append(character);
} else if (character < 0x20 || character > 0x7e) {
throw new IllegalArgumentException("canonical JSON supports printable ASCII only");
} else {
output.append(character);
}
}
output.append('"');
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import java.time.Instant;
import java.util.Objects;
final class ControlRecordSupport {
private ControlRecordSupport() {}
static void header(int schemaVersion, String recordId, long revision, Instant updatedAt) {
if (schemaVersion != 1) {
throw new IllegalArgumentException("only control schema version 1 is writable");
}
bounded("recordId", recordId, 128);
if (revision < 1) {
throw new IllegalArgumentException("revision must be positive");
}
Objects.requireNonNull(updatedAt, "updatedAt must be non-null");
}
static String bounded(String label, String value, int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength
|| value.chars().anyMatch(character -> character < 0x20 || character > 0x7e)) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
static String hexDigest(String label, String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** Conditional create or exact-version CAS conflict. */
public final class ObjectControlConflictException extends RuntimeException {
public ObjectControlConflictException(String message) {
super(message);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** Malformed, non-canonical, oversized, or digest-mismatched control evidence. */
public final class ObjectControlCorruptionException extends RuntimeException {
public ObjectControlCorruptionException(String message) {
super(message);
}
public ObjectControlCorruptionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import java.util.Objects;
/** Exact-version replacement request for a control record. */
public record ObjectControlMutation(
ObjectControlVersion expectedVersion, ObjectControlRecord replacement) {
public ObjectControlMutation {
Objects.requireNonNull(expectedVersion, "expectedVersion must be non-null");
Objects.requireNonNull(replacement, "replacement must be non-null");
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** Closed durable control-record family. Unknown families and schemas fail closed. */
public sealed interface ObjectControlRecord
permits ObjectDirectSessionRecord,
ObjectDirectMultipartGrantRecord,
ObjectDirectMultipartSessionRecord,
ObjectManifestRecord,
ObjectMultipartPartRecord,
ObjectMultipartSessionRecord,
ObjectOperationRecord,
ObjectPublicationHandoffRecord,
ObjectReferencePointerRecord,
ObjectReferenceRecord,
ObjectStagedObjectRecord {
int schemaVersion();
String recordId();
long revision();
Family family();
default boolean terminal() {
return false;
}
enum Family {
OPERATION,
MANIFEST,
REFERENCE_POINTER,
REFERENCE,
DIRECT_SESSION,
DIRECT_MULTIPART_SESSION,
DIRECT_MULTIPART_GRANT,
STAGED_OBJECT,
PUBLICATION_HANDOFF,
MULTIPART_SESSION,
MULTIPART_PART
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** Strict codec for the closed canonical control-record family. */
public interface ObjectControlRecordCodec {
byte[] encode(ObjectControlRecord record);
ObjectControlRecord decode(byte[] encoded);
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import java.util.Arrays;
import java.util.Objects;
/** Defensive in-memory representation of a checksummed canonical record payload. */
public final class ObjectControlRecordEnvelope {
private final String sha256Digest;
private final byte[] canonicalPayload;
public ObjectControlRecordEnvelope(String sha256Digest, byte[] canonicalPayload) {
if (sha256Digest == null || !sha256Digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("envelope digest is invalid");
}
this.sha256Digest = sha256Digest;
this.canonicalPayload =
Objects.requireNonNull(canonicalPayload, "canonicalPayload must be non-null").clone();
}
public String sha256Digest() {
return sha256Digest;
}
public byte[] canonicalPayload() {
return canonicalPayload.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof ObjectControlRecordEnvelope that
&& sha256Digest.equals(that.sha256Digest)
&& Arrays.equals(canonicalPayload, that.canonicalPayload);
}
@Override
public int hashCode() {
return 31 * sha256Digest.hashCode() + Arrays.hashCode(canonicalPayload);
}
@Override
public String toString() {
return "ObjectControlRecordEnvelope[digest=" + sha256Digest + ", payload=redacted]";
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import java.util.Objects;
import java.util.Optional;
/** Provider-neutral exact-lookup/create/CAS control storage. LIST is deliberately absent. */
public interface ObjectControlStore {
Optional<StoredControlRecord> read(String key);
StoredControlRecord create(String key, ObjectControlRecord record);
StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation);
record StoredControlRecord(ObjectControlVersion version, ObjectControlRecord record) {
public StoredControlRecord {
Objects.requireNonNull(version, "version must be non-null");
Objects.requireNonNull(record, "record must be non-null");
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** Private exact control-object version used only as a CAS precondition. */
public record ObjectControlVersion(String value) {
public ObjectControlVersion {
value = ControlRecordSupport.bounded("control version", value, 256);
}
public static ObjectControlVersion of(String value) {
return new ObjectControlVersion(value);
}
@Override
public String toString() {
return "ObjectControlVersion[redacted]";
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import java.time.Instant;
import java.util.Objects;
/** Durable, non-secret direct multipart part-grant generation and acknowledgement evidence. */
public record ObjectDirectMultipartGrantRecord(
int schemaVersion,
String recordId,
long revision,
DirectTransferSessionId sessionId,
MultipartPartNumber partNumber,
long grantGeneration,
DirectGrantSessionState grantState,
long exactPartLength,
ObjectDigest expectedPartDigest,
Instant signingTime,
Instant expiresAt,
String providerPartEvidence,
PartReceiptToken receiptToken,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectDirectMultipartGrantRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(partNumber, "partNumber must be non-null");
if (grantGeneration < 1 || grantGeneration > 16 || exactPartLength < 1) {
throw new IllegalArgumentException("direct multipart grant bounds are invalid");
}
Objects.requireNonNull(grantState, "grantState must be non-null");
Objects.requireNonNull(expectedPartDigest, "expectedPartDigest must be non-null");
if (signingTime == null || expiresAt == null || !expiresAt.isAfter(signingTime)) {
throw new IllegalArgumentException("direct multipart grant window is invalid");
}
if (providerPartEvidence != null
&& (providerPartEvidence.isBlank() || providerPartEvidence.length() > 512)) {
throw new IllegalArgumentException("direct provider part evidence is invalid");
}
if ((providerPartEvidence == null) != (receiptToken == null)) {
throw new IllegalArgumentException("direct part receipt evidence is incomplete");
}
}
@Override
public Family family() {
return Family.DIRECT_MULTIPART_GRANT;
}
@Override
public boolean terminal() {
return grantState == DirectGrantSessionState.DATA_UPLOADED
|| grantState == DirectGrantSessionState.EXPIRED
|| grantState == DirectGrantSessionState.ABORTED
|| grantState == DirectGrantSessionState.FAILED;
}
public ObjectDirectMultipartGrantRecord issue(Instant now) {
if (grantState != DirectGrantSessionState.GRANT_PREPARED) {
throw new IllegalStateException("direct multipart part grant is not prepared");
}
return copy(DirectGrantSessionState.GRANT_ISSUED, null, null, now);
}
public ObjectDirectMultipartGrantRecord acknowledge(
String providerEvidence, PartReceiptToken token, Instant now) {
if (grantState != DirectGrantSessionState.GRANT_ISSUED) {
throw new IllegalStateException("direct multipart part grant is not issued");
}
return copy(DirectGrantSessionState.DATA_UPLOADED, providerEvidence, token, now);
}
private ObjectDirectMultipartGrantRecord copy(
DirectGrantSessionState state, String providerEvidence, PartReceiptToken token, Instant now) {
return new ObjectDirectMultipartGrantRecord(
schemaVersion,
recordId,
revision + 1,
sessionId,
partNumber,
grantGeneration,
state,
exactPartLength,
expectedPartDigest,
signingTime,
expiresAt,
providerEvidence,
token,
now);
}
}
@@ -0,0 +1,198 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import java.time.Instant;
import java.util.Objects;
/** Durable direct-multipart session with bounded grant-admission and completion fences. */
public record ObjectDirectMultipartSessionRecord(
int schemaVersion,
String recordId,
long revision,
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
MultipartUploadState multipartState,
String canonicalDataKey,
ObjectContentIdentity expectedContent,
ObjectMediaType mediaType,
String providerSessionEvidence,
int maximumParts,
long acknowledgedPartCount,
Instant sessionExpiresAt,
Instant latestGrantExpiresAt,
Instant admissionClosedAt,
ObjectVersionToken completedVersion,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectDirectMultipartSessionRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(multipartState, "multipartState must be non-null");
if (canonicalDataKey == null
|| canonicalDataKey.length() > 1024
|| !canonicalDataKey.startsWith("data/v1/")) {
throw new IllegalArgumentException("direct multipart data key is invalid");
}
Objects.requireNonNull(expectedContent, "expectedContent must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
if (providerSessionEvidence != null
&& (providerSessionEvidence.isBlank() || providerSessionEvidence.length() > 512)) {
throw new IllegalArgumentException("provider multipart session evidence is invalid");
}
if (maximumParts < 1
|| maximumParts > 10_000
|| acknowledgedPartCount < 0
|| acknowledgedPartCount > maximumParts) {
throw new IllegalArgumentException("direct multipart part bounds are invalid");
}
Objects.requireNonNull(sessionExpiresAt, "sessionExpiresAt must be non-null");
if (!sessionExpiresAt.isAfter(updatedAt)
&& multipartState != MultipartUploadState.COMPLETED
&& multipartState != MultipartUploadState.ABORTED
&& multipartState != MultipartUploadState.EXPIRED) {
throw new IllegalArgumentException("active direct multipart session is expired");
}
if (latestGrantExpiresAt != null && latestGrantExpiresAt.isAfter(sessionExpiresAt)) {
throw new IllegalArgumentException("part grant exceeds its multipart session");
}
if ((multipartState == MultipartUploadState.COMPLETE_IN_PROGRESS
|| multipartState == MultipartUploadState.COMPLETED
|| multipartState == MultipartUploadState.ABORT_IN_PROGRESS
|| multipartState == MultipartUploadState.ABORTED)
&& admissionClosedAt == null) {
throw new IllegalArgumentException("closed direct multipart session has no fence");
}
if ((multipartState == MultipartUploadState.COMPLETED) != (completedVersion != null)) {
throw new IllegalArgumentException("direct multipart terminal version is incomplete");
}
}
@Override
public Family family() {
return Family.DIRECT_MULTIPART_SESSION;
}
@Override
public boolean terminal() {
return switch (multipartState) {
case COMPLETED, ABORTED, EXPIRED, FAILED, CORRUPT -> true;
default -> false;
};
}
public ObjectDirectMultipartSessionRecord withProviderSession(
String providerEvidence, Instant now) {
if (multipartState != MultipartUploadState.INITIATE_IN_PROGRESS) {
throw new IllegalStateException("direct multipart initiation is not in progress");
}
return copy(
MultipartUploadState.ACCEPTING_PARTS,
providerEvidence,
acknowledgedPartCount,
latestGrantExpiresAt,
admissionClosedAt,
completedVersion,
now);
}
public ObjectDirectMultipartSessionRecord recordGrantExpiry(Instant expiry, Instant now) {
if (multipartState != MultipartUploadState.ACCEPTING_PARTS) {
throw new IllegalStateException("direct multipart part admission is closed");
}
Instant latest =
latestGrantExpiresAt == null || expiry.isAfter(latestGrantExpiresAt)
? expiry
: latestGrantExpiresAt;
return copy(
multipartState,
providerSessionEvidence,
acknowledgedPartCount,
latest,
admissionClosedAt,
completedVersion,
now);
}
public ObjectDirectMultipartSessionRecord acknowledgePart(Instant now) {
if (acknowledgedPartCount >= maximumParts) {
throw new IllegalStateException("direct multipart acknowledgement bound is exhausted");
}
return copy(
multipartState,
providerSessionEvidence,
acknowledgedPartCount + 1,
latestGrantExpiresAt,
admissionClosedAt,
completedVersion,
now);
}
public ObjectDirectMultipartSessionRecord fence(MultipartUploadState state, Instant now) {
if (multipartState != MultipartUploadState.ACCEPTING_PARTS
|| (state != MultipartUploadState.COMPLETE_IN_PROGRESS
&& state != MultipartUploadState.ABORT_IN_PROGRESS)) {
throw new IllegalStateException("direct multipart fence transition is invalid");
}
return copy(
state,
providerSessionEvidence,
acknowledgedPartCount,
latestGrantExpiresAt,
now,
completedVersion,
now);
}
public ObjectDirectMultipartSessionRecord finish(
MultipartUploadState state, ObjectVersionToken exactVersion, Instant now) {
if ((multipartState != MultipartUploadState.COMPLETE_IN_PROGRESS
|| state != MultipartUploadState.COMPLETED)
&& (multipartState != MultipartUploadState.ABORT_IN_PROGRESS
|| state != MultipartUploadState.ABORTED)) {
throw new IllegalStateException("direct multipart terminal transition is invalid");
}
return copy(
state,
providerSessionEvidence,
acknowledgedPartCount,
latestGrantExpiresAt,
admissionClosedAt,
exactVersion,
now);
}
private ObjectDirectMultipartSessionRecord copy(
MultipartUploadState state,
String providerEvidence,
long acknowledged,
Instant grantExpiry,
Instant closedAt,
ObjectVersionToken exactVersion,
Instant now) {
return new ObjectDirectMultipartSessionRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
sessionId,
state,
canonicalDataKey,
expectedContent,
mediaType,
providerEvidence,
maximumParts,
acknowledged,
sessionExpiresAt,
grantExpiry,
closedAt,
exactVersion,
now);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectTransferSessionRecord;
import java.util.Objects;
/** Closed durable control wrapper for one non-secret direct-transfer session. */
public record ObjectDirectSessionRecord(
int schemaVersion, String recordId, long revision, DirectTransferSessionRecord session)
implements ObjectControlRecord {
public ObjectDirectSessionRecord {
Objects.requireNonNull(session, "session must be non-null");
ControlRecordSupport.header(schemaVersion, recordId, revision, session.updatedAt());
}
@Override
public Family family() {
return Family.DIRECT_SESSION;
}
@Override
public boolean terminal() {
return switch (session.state()) {
case DATA_UPLOADED, EXPIRED, ABORTED, FAILED -> true;
default -> false;
};
}
public ObjectDirectSessionRecord replace(DirectTransferSessionRecord replacement) {
return new ObjectDirectSessionRecord(schemaVersion, recordId, revision + 1, replacement);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import java.time.Instant;
import java.util.Objects;
/** Immutable manifest revision binding logical content to exact qualified provider evidence. */
public record ObjectManifestRecord(
int schemaVersion,
String recordId,
long revision,
ObjectId objectId,
ObjectRouteToken route,
String providerType,
String providerVersion,
ObjectBindingRevision bindingRevision,
long logicalSize,
ObjectDigest logicalDigest,
ObjectMediaType mediaType,
ObjectVersionToken immutableDataVersion,
String encryptionEvidence,
String retentionEvidence,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectManifestRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(objectId, "objectId must be non-null");
Objects.requireNonNull(route, "route must be non-null");
providerType = ControlRecordSupport.bounded("providerType", providerType, 64);
providerVersion = ControlRecordSupport.bounded("providerVersion", providerVersion, 128);
Objects.requireNonNull(bindingRevision, "bindingRevision must be non-null");
if (logicalSize < 0) {
throw new IllegalArgumentException("logicalSize must be non-negative");
}
Objects.requireNonNull(logicalDigest, "logicalDigest must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
Objects.requireNonNull(immutableDataVersion, "immutableDataVersion must be non-null");
encryptionEvidence =
ControlRecordSupport.bounded("encryptionEvidence", encryptionEvidence, 128);
retentionEvidence = ControlRecordSupport.bounded("retentionEvidence", retentionEvidence, 128);
}
@Override
public Family family() {
return Family.MANIFEST;
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import java.time.Instant;
import java.util.Objects;
/** Immutable, independently addressed multipart part receipt. */
public record ObjectMultipartPartRecord(
int schemaVersion,
String recordId,
long revision,
DirectTransferSessionId sessionId,
MultipartPartNumber partNumber,
PartReceiptToken receiptToken,
long logicalSize,
ObjectDigest digest,
String providerPartEvidence,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectMultipartPartRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(partNumber, "partNumber must be non-null");
Objects.requireNonNull(receiptToken, "receiptToken must be non-null");
if (logicalSize < 0) {
throw new IllegalArgumentException("logicalSize must be non-negative");
}
Objects.requireNonNull(digest, "digest must be non-null");
providerPartEvidence =
ControlRecordSupport.bounded("providerPartEvidence", providerPartEvidence, 512);
}
@Override
public Family family() {
return Family.MULTIPART_PART;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import java.time.Instant;
import java.util.Objects;
/** Bounded multipart session; part receipts remain separate records. */
public record ObjectMultipartSessionRecord(
int schemaVersion,
String recordId,
long revision,
DirectTransferSessionId sessionId,
MultipartUploadState multipartState,
DirectGrantSessionState grantState,
String providerSessionEvidence,
long partCount,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectMultipartSessionRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(multipartState, "multipartState must be non-null");
Objects.requireNonNull(grantState, "grantState must be non-null");
providerSessionEvidence =
ControlRecordSupport.bounded("providerSessionEvidence", providerSessionEvidence, 512);
if (partCount < 0 || partCount > 10_000) {
throw new IllegalArgumentException("partCount is outside the supported range");
}
}
@Override
public Family family() {
return Family.MULTIPART_SESSION;
}
@Override
public boolean terminal() {
return switch (multipartState) {
case COMPLETED, ABORTED, EXPIRED, FAILED, CORRUPT -> true;
default -> false;
};
}
}
@@ -0,0 +1,210 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectPolicySnapshotCodec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectEffectCertainty;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectOperationStateMachine;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicyRevision;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PendingObjectEffect;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublicationOperationState;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import java.time.Instant;
import java.util.Objects;
/** Durable publication operation identity, frozen revisions, state, and pending-effect evidence. */
public record ObjectOperationRecord(
int schemaVersion,
String recordId,
long revision,
ObjectOperationKey operationKey,
String requestFingerprint,
String operationKind,
ObjectRouteToken route,
ObjectPolicySnapshot policySnapshot,
String policySnapshotDigest,
ObjectContentIdentity expectedContentIdentity,
ObjectContentIdentity observedContentIdentity,
PublicationOperationState stablePhase,
long stateVersion,
PendingObjectEffect pendingEffect,
ObjectEffectCertainty effectCertainty,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectOperationRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(operationKey, "operationKey must be non-null");
requestFingerprint = ControlRecordSupport.hexDigest("requestFingerprint", requestFingerprint);
operationKind = ControlRecordSupport.bounded("operationKind", operationKind, 64);
Objects.requireNonNull(route, "route must be non-null");
Objects.requireNonNull(policySnapshot, "policySnapshot must be non-null");
policySnapshotDigest =
ControlRecordSupport.hexDigest("policySnapshotDigest", policySnapshotDigest);
String actualPolicyDigest =
new CanonicalJsonObjectControlRecordCodec()
.sha256Hex(ObjectPolicySnapshotCodec.encode(policySnapshot));
if (!actualPolicyDigest.equals(policySnapshotDigest)) {
throw new IllegalArgumentException("policySnapshotDigest does not match the snapshot");
}
if (expectedContentIdentity != null
&& expectedContentIdentity.exactLength() > policySnapshot.maximumObjectBytes()) {
throw new IllegalArgumentException("expected content exceeds the frozen policy");
}
Objects.requireNonNull(stablePhase, "stablePhase must be non-null");
if (stateVersion < 1) {
throw new IllegalArgumentException("stateVersion must be positive");
}
Objects.requireNonNull(effectCertainty, "effectCertainty must be non-null");
if ((pendingEffect == null) != (effectCertainty == ObjectEffectCertainty.NOT_SENT)) {
throw new IllegalArgumentException("pending effect and certainty do not agree");
}
}
public static ObjectOperationRecord reserved(
ObjectOperationKey operationKey,
String requestFingerprint,
String operationKind,
ObjectRouteToken route,
ObjectPolicySnapshot policySnapshot,
ObjectContentIdentity expectedContentIdentity,
String policySnapshotDigest,
Instant now) {
return new ObjectOperationRecord(
1,
operationKey.epoch().value() + ":" + operationKey.operationId().value(),
1,
operationKey,
requestFingerprint,
operationKind,
route,
policySnapshot,
policySnapshotDigest,
expectedContentIdentity,
null,
PublicationOperationState.RESERVED,
1,
null,
ObjectEffectCertainty.NOT_SENT,
now);
}
public ObjectOperationRecord withPendingEffect(PendingObjectEffect effect, Instant now) {
Objects.requireNonNull(effect, "effect must be non-null");
if (pendingEffect != null) {
throw new IllegalStateException("an unresolved pending effect already exists");
}
return copy(revision + 1, effect, ObjectEffectCertainty.SENT, now);
}
public ObjectOperationRecord withEffectCertainty(ObjectEffectCertainty certainty, Instant now) {
if (pendingEffect == null || certainty == ObjectEffectCertainty.NOT_SENT) {
throw new IllegalStateException("pending effect certainty transition is invalid");
}
return copy(revision + 1, pendingEffect, certainty, now);
}
public ObjectOperationRecord advancePublication(
PublicationOperationState next,
ObjectContentIdentity nextObservedContentIdentity,
Instant now) {
ObjectOperationStateMachine.advancePublication(stablePhase, next);
if (next == PublicationOperationState.DATA_UPLOADED
&& (pendingEffect == null
|| effectCertainty != ObjectEffectCertainty.CONFIRMED
|| nextObservedContentIdentity == null)) {
throw new IllegalStateException("uploaded evidence must be confirmed before phase advance");
}
if (pendingEffect != null && effectCertainty != ObjectEffectCertainty.CONFIRMED) {
throw new IllegalStateException("pending effect is not confirmed");
}
return new ObjectOperationRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
requestFingerprint,
operationKind,
route,
policySnapshot,
policySnapshotDigest,
expectedContentIdentity,
nextObservedContentIdentity == null ? observedContentIdentity : nextObservedContentIdentity,
next,
stateVersion + 1,
null,
ObjectEffectCertainty.NOT_SENT,
now);
}
public ObjectOperationRecord terminate(PublicationOperationState terminal, Instant now) {
if (stablePhase.terminal()
|| (terminal != PublicationOperationState.ABORTED
&& terminal != PublicationOperationState.QUARANTINED
&& terminal != PublicationOperationState.FAILED)
|| pendingEffect != null) {
throw new IllegalStateException("publication terminal transition is invalid");
}
return new ObjectOperationRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
requestFingerprint,
operationKind,
route,
policySnapshot,
policySnapshotDigest,
expectedContentIdentity,
observedContentIdentity,
terminal,
stateVersion + 1,
null,
ObjectEffectCertainty.NOT_SENT,
now);
}
private ObjectOperationRecord copy(
long nextRevision,
PendingObjectEffect nextEffect,
ObjectEffectCertainty nextCertainty,
Instant now) {
return new ObjectOperationRecord(
schemaVersion,
recordId,
nextRevision,
operationKey,
requestFingerprint,
operationKind,
route,
policySnapshot,
policySnapshotDigest,
expectedContentIdentity,
observedContentIdentity,
stablePhase,
stateVersion,
nextEffect,
nextCertainty,
now);
}
@Override
public Family family() {
return Family.OPERATION;
}
@Override
public boolean terminal() {
return stablePhase.terminal();
}
public ObjectBindingRevision bindingRevision() {
return policySnapshot.bindingRevision();
}
public ObjectPolicyRevision policyRevision() {
return policySnapshot.policyRevision();
}
}
@@ -0,0 +1,188 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Objects;
/** Durable monotonically fenced application-to-publication handoff lease. */
public record ObjectPublicationHandoffRecord(
int schemaVersion,
String recordId,
long revision,
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
long fence,
String claimantDigest,
Instant leaseExpiresAt,
boolean released,
boolean abortAuthorized,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectPublicationHandoffRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
if (fence < 1) {
throw new IllegalArgumentException("handoff fence must be positive");
}
claimantDigest = ControlRecordSupport.hexDigest("claimantDigest", claimantDigest);
Objects.requireNonNull(leaseExpiresAt, "leaseExpiresAt must be non-null");
if (!released && !abortAuthorized && !leaseExpiresAt.isAfter(updatedAt)) {
throw new IllegalArgumentException("active handoff lease is expired at write time");
}
if (released && abortAuthorized) {
throw new IllegalArgumentException("handoff cannot be released and abort-authorized");
}
}
public static ObjectPublicationHandoffRecord claimed(
ObjectOperationKey operation,
ObjectStageHandle stage,
ObjectVersionToken version,
String claimant,
Instant now,
Instant expiry) {
if (!expiry.isAfter(now)) {
throw new IllegalArgumentException("handoff expiry must be in the future");
}
return new ObjectPublicationHandoffRecord(
1,
operation.epoch().value() + ":" + operation.operationId().value(),
1,
operation,
stage,
version,
1,
digest(claimant),
expiry,
false,
false,
now);
}
public ObjectPublicationHandoffRecord claim(
String claimant, Instant now, Duration leaseDuration) {
Objects.requireNonNull(leaseDuration, "leaseDuration must be non-null");
String requested = digest(claimant);
if (!released && !abortAuthorized && leaseExpiresAt.isAfter(now)) {
if (claimantDigest.equals(requested)) {
return this;
}
throw new IllegalStateException("publication handoff is actively claimed");
}
if (abortAuthorized) {
throw new IllegalStateException("publication handoff is abort-authorized");
}
return new ObjectPublicationHandoffRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
stageHandle,
exactVersion,
fence + 1,
requested,
now.plus(leaseDuration),
false,
false,
now);
}
public ObjectPublicationHandoffRecord renew(
long expectedFence, String claimant, Instant now, Duration leaseDuration) {
requireOwner(expectedFence, claimant, now);
return new ObjectPublicationHandoffRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
stageHandle,
exactVersion,
fence,
claimantDigest,
now.plus(leaseDuration),
false,
false,
now);
}
public ObjectPublicationHandoffRecord release(long expectedFence, String claimant, Instant now) {
requireOwner(expectedFence, claimant, now);
return new ObjectPublicationHandoffRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
stageHandle,
exactVersion,
fence,
claimantDigest,
leaseExpiresAt,
true,
false,
now);
}
public ObjectPublicationHandoffRecord authorizeAbort(long expectedFence, Instant now) {
if (expectedFence != fence || (!released && leaseExpiresAt.isAfter(now))) {
throw new IllegalStateException("active or stale handoff cannot authorize abort");
}
return new ObjectPublicationHandoffRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
stageHandle,
exactVersion,
fence,
claimantDigest,
leaseExpiresAt,
false,
true,
now);
}
@Override
public Family family() {
return Family.PUBLICATION_HANDOFF;
}
@Override
public boolean terminal() {
return released || abortAuthorized;
}
private void requireOwner(long expectedFence, String claimant, Instant now) {
if (released
|| abortAuthorized
|| expectedFence != fence
|| !claimantDigest.equals(digest(claimant))
|| !leaseExpiresAt.isAfter(now)) {
throw new IllegalStateException("publication handoff owner/fence is stale");
}
}
private static String digest(String claimant) {
if (claimant == null || claimant.isBlank() || claimant.length() > 128) {
throw new IllegalArgumentException("handoff claimant is invalid");
}
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(claimant.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.time.Instant;
import java.util.Objects;
/** Small CAS pointer to the current immutable manifest revision. */
public record ObjectReferencePointerRecord(
int schemaVersion,
String recordId,
long revision,
ObjectReference reference,
long manifestRevision,
ObjectVersionToken immutableDataVersion,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectReferencePointerRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(reference, "reference must be non-null");
if (manifestRevision < 1) {
throw new IllegalArgumentException("manifestRevision must be positive");
}
Objects.requireNonNull(immutableDataVersion, "immutableDataVersion must be non-null");
}
@Override
public Family family() {
return Family.REFERENCE_POINTER;
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.time.Instant;
import java.util.Objects;
/** Published-reference lifecycle, independent from immutable publication receipts. */
public record ObjectReferenceRecord(
int schemaVersion,
String recordId,
long revision,
ObjectReference reference,
ObjectId objectId,
ObjectVersionToken immutableDataVersion,
PublishedReferenceState state,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectReferenceRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(reference, "reference must be non-null");
Objects.requireNonNull(objectId, "objectId must be non-null");
Objects.requireNonNull(immutableDataVersion, "immutableDataVersion must be non-null");
Objects.requireNonNull(state, "state must be non-null");
}
@Override
public Family family() {
return Family.REFERENCE;
}
@Override
public boolean terminal() {
return state == PublishedReferenceState.PURGED;
}
}
@@ -0,0 +1,180 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
import java.time.Instant;
import java.util.Objects;
/** Durable exact unpublished stage and its integrity/scan evidence. */
public record ObjectStagedObjectRecord(
int schemaVersion,
String recordId,
long revision,
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectId objectId,
String canonicalDataKey,
ObjectVersionToken exactVersion,
ObjectContentIdentity contentIdentity,
ObjectMediaType mediaType,
ObjectPublicationRequirement publicationRequirement,
boolean integrityVerified,
ObjectOperationId scanOperationId,
String scannerPolicyRevision,
ObjectScanState scanState,
Instant updatedAt)
implements ObjectControlRecord {
public ObjectStagedObjectRecord {
ControlRecordSupport.header(schemaVersion, recordId, revision, updatedAt);
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(objectId, "objectId must be non-null");
if (canonicalDataKey == null
|| canonicalDataKey.length() > 1024
|| !canonicalDataKey.startsWith("data/v1/")) {
throw new IllegalArgumentException("staged object data key is invalid");
}
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
Objects.requireNonNull(publicationRequirement, "publicationRequirement must be non-null");
Objects.requireNonNull(scanState, "scanState must be non-null");
if (scannerPolicyRevision != null
&& (scannerPolicyRevision.isBlank() || scannerPolicyRevision.length() > 128)) {
throw new IllegalArgumentException("scanner policy revision is invalid");
}
if ((scanOperationId == null) != (scannerPolicyRevision == null)
|| (scanOperationId == null
&& scanState != ObjectScanState.NOT_REQUIRED
&& scanState != ObjectScanState.PENDING)
|| (scanOperationId != null
&& scanState != ObjectScanState.CLEAN
&& scanState != ObjectScanState.MALICIOUS
&& scanState != ObjectScanState.INDETERMINATE)) {
throw new IllegalArgumentException("scan verdict evidence is incomplete");
}
}
public static ObjectStagedObjectRecord staged(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectId objectId,
String canonicalDataKey,
ObjectVersionToken exactVersion,
ObjectContentIdentity contentIdentity,
ObjectMediaType mediaType,
ObjectPublicationRequirement requirement,
Instant now) {
return new ObjectStagedObjectRecord(
1,
operationKey.epoch().value() + ":" + operationKey.operationId().value(),
1,
operationKey,
stageHandle,
objectId,
canonicalDataKey,
exactVersion,
contentIdentity,
mediaType,
requirement,
false,
null,
null,
ObjectScanState.NOT_REQUIRED,
now);
}
public ObjectStagedObjectRecord verified(Instant now) {
if (integrityVerified) {
return this;
}
return copy(
true,
null,
null,
publicationRequirement == ObjectPublicationRequirement.SCAN_CLEAN
? ObjectScanState.PENDING
: ObjectScanState.NOT_REQUIRED,
now);
}
public ObjectStagedObjectRecord scanPending(Instant now) {
if (!integrityVerified || publicationRequirement != ObjectPublicationRequirement.SCAN_CLEAN) {
throw new IllegalStateException("staged object is not eligible for scanning");
}
return scanState == ObjectScanState.PENDING
? this
: copy(true, null, null, ObjectScanState.PENDING, now);
}
public ObjectStagedObjectRecord recordVerdict(
ObjectOperationId scanOperation,
String policyRevision,
ObjectScanState verdict,
Instant now) {
Objects.requireNonNull(scanOperation, "scanOperation must be non-null");
if (policyRevision == null || policyRevision.isBlank() || policyRevision.length() > 128) {
throw new IllegalArgumentException("scanner policy revision is invalid");
}
if (verdict != ObjectScanState.CLEAN
&& verdict != ObjectScanState.MALICIOUS
&& verdict != ObjectScanState.INDETERMINATE) {
throw new IllegalArgumentException("scanner verdict is not terminal");
}
if (scanOperationId != null) {
if (scanOperationId.equals(scanOperation)
&& scannerPolicyRevision.equals(policyRevision)
&& scanState == verdict) {
return this;
}
throw new IllegalArgumentException("scanner verdict conflicts with durable evidence");
}
if (!integrityVerified || scanState != ObjectScanState.PENDING) {
throw new IllegalStateException("staged object is not awaiting a scanner verdict");
}
return copy(true, scanOperation, policyRevision, verdict, now);
}
@Override
public Family family() {
return Family.STAGED_OBJECT;
}
@Override
public boolean terminal() {
return scanState == ObjectScanState.MALICIOUS;
}
private ObjectStagedObjectRecord copy(
boolean verified,
ObjectOperationId scanOperation,
String policyRevision,
ObjectScanState state,
Instant now) {
return new ObjectStagedObjectRecord(
schemaVersion,
recordId,
revision + 1,
operationKey,
stageHandle,
objectId,
canonicalDataKey,
exactVersion,
contentIdentity,
mediaType,
publicationRequirement,
verified,
scanOperation,
policyRevision,
state,
now);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.objectstorage.control;
/** A newer or unknown durable schema that must be quarantined rather than overwritten. */
public final class UnsupportedObjectControlSchemaException extends RuntimeException {
public UnsupportedObjectControlSchemaException(String message) {
super(message);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import java.time.Instant;
/** Non-secret persisted generation policy for one exact bearer grant. */
public record DirectGrantGeneration(
long value,
String constraintsDigest,
Instant signingTime,
Instant expiresAt,
String credentialRevision,
String referenceRevision) {
public DirectGrantGeneration {
if (value < 1 || value > 16) {
throw new IllegalArgumentException("direct grant generation is outside the supported range");
}
if (constraintsDigest != null && !constraintsDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("direct grant constraints digest is invalid");
}
if (signingTime == null || expiresAt == null || !expiresAt.isAfter(signingTime)) {
throw new IllegalArgumentException("direct grant time window is invalid");
}
if (credentialRevision == null
|| credentialRevision.isBlank()
|| credentialRevision.length() > 128
|| referenceRevision == null
|| referenceRevision.isBlank()
|| referenceRevision.length() > 128) {
throw new IllegalArgumentException("direct grant revision evidence is invalid");
}
}
public DirectGrantGeneration bind(String digest, String exactReferenceRevision) {
return new DirectGrantGeneration(
value, digest, signingTime, expiresAt, credentialRevision, exactReferenceRevision);
}
}
@@ -0,0 +1,91 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/** Provider-private signer, verifier, and published-object resolution seam. */
public interface DirectGrantProvider {
DirectGrantMaterial signUpload(DirectTransferSessionRecord session);
DirectGrantMaterial signDownload(DirectTransferSessionRecord session, PublishedObject published);
VerifiedUpload verifyUpload(DirectTransferSessionRecord session);
PublishedObject resolvePublished(DirectDownloadGrantRequest request);
final class DirectGrantMaterial {
private final URI requestUri;
private final Map<String, String> signedHeaders;
private final Instant expiresAt;
public DirectGrantMaterial(
URI requestUri, Map<String, String> signedHeaders, Instant expiresAt) {
this.requestUri = Objects.requireNonNull(requestUri, "requestUri must be non-null");
this.signedHeaders =
Map.copyOf(Objects.requireNonNull(signedHeaders, "signedHeaders must be non-null"));
this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt must be non-null");
}
public URI requestUri() {
return requestUri;
}
public Map<String, String> signedHeaders() {
return signedHeaders;
}
public Instant expiresAt() {
return expiresAt;
}
@Override
public String toString() {
return "DirectGrantMaterial[uri="
+ PresignedGrantRedactor.redact(requestUri)
+ ", headers="
+ PresignedGrantRedactor.describeHeaders(signedHeaders)
+ "]";
}
}
record VerifiedUpload(ObjectContentIdentity contentIdentity, ObjectVersionToken exactVersion) {
public VerifiedUpload {
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
}
}
record PublishedObject(
String canonicalDataKey,
ObjectContentIdentity contentIdentity,
ObjectMediaType mediaType,
ObjectVersionToken exactVersion,
String referenceRevision,
boolean active) {
public PublishedObject {
if (canonicalDataKey == null
|| canonicalDataKey.length() > 1024
|| !canonicalDataKey.startsWith("data/v1/")) {
throw new IllegalArgumentException("published canonical data key is invalid");
}
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
if (referenceRevision == null
|| referenceRevision.isBlank()
|| referenceRevision.length() > 128) {
throw new IllegalArgumentException("published reference revision is invalid");
}
}
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/** Admission-horizon and exact server-issued token checks before provider completion. */
public final class DirectMultipartCompletionVerifier {
private DirectMultipartCompletionVerifier() {}
public static void requireAdmissionDrained(
Instant now,
Instant latestGrantExpiresAt,
Duration qualifiedClockSkew,
Duration maximumInFlightHorizon,
boolean controlledIngressDrained) {
Objects.requireNonNull(now, "now must be non-null");
Objects.requireNonNull(qualifiedClockSkew, "qualifiedClockSkew must be non-null");
Objects.requireNonNull(maximumInFlightHorizon, "maximumInFlightHorizon must be non-null");
if (controlledIngressDrained || latestGrantExpiresAt == null) {
return;
}
Instant safeAfter = latestGrantExpiresAt.plus(qualifiedClockSkew).plus(maximumInFlightHorizon);
if (now.isBefore(safeAfter)) {
throw new IllegalStateException("direct multipart part requests may still be in flight");
}
}
public static void requireExactLedger(
List<PartReceiptToken> requested,
List<ObjectMultipartPartRecord> records,
ObjectContentIdentity expected) {
Objects.requireNonNull(requested, "requested tokens must be non-null");
Objects.requireNonNull(records, "part records must be non-null");
Objects.requireNonNull(expected, "expected content must be non-null");
if (requested.size() != records.size()) {
throw new IllegalArgumentException("direct multipart token ledger size conflicts");
}
long total = 0;
for (int index = 0; index < records.size(); index++) {
ObjectMultipartPartRecord part = records.get(index);
if (part.partNumber().value() != index + 1
|| !part.receiptToken().equals(requested.get(index))) {
throw new IllegalArgumentException("direct multipart token ledger is not exact");
}
total = Math.addExact(total, part.logicalSize());
}
if (total != expected.exactLength()) {
throw new IllegalArgumentException("direct multipart logical length conflicts");
}
}
}
@@ -0,0 +1,509 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.codec.CrockfordBase32;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartSessionRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.adapter.outbound.objectstorage.multipart.MultipartPartLedger;
import dev.caskeleton.adapter.outbound.objectstorage.s3.S3ClientPolicy;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.MultipartReceipt;
import dev.caskeleton.application.objectstorage.model.MultipartSession;
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
import dev.caskeleton.application.objectstorage.model.PartUploadGrant;
import dev.caskeleton.application.objectstorage.port.DirectMultipartUploadPort;
import dev.caskeleton.application.objectstorage.request.MultipartAbortRequest;
import dev.caskeleton.application.objectstorage.request.MultipartCompleteRequest;
import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement;
import dev.caskeleton.application.objectstorage.request.MultipartStartRequest;
import dev.caskeleton.application.objectstorage.request.PartUploadGrantRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/** Durable direct multipart coordinator with explicit admission-close and provider fences. */
public final class DirectMultipartCoordinator implements DirectMultipartUploadPort {
private final ObjectRouteToken route;
private final ObjectControlStore store;
private final DirectMultipartProvider provider;
private final Clock clock;
private final Duration qualifiedClockSkew;
private final Duration maximumInFlightHorizon;
private final Map<String, DirectGrantProvider.DirectGrantMaterial> issued =
new ConcurrentHashMap<>();
public DirectMultipartCoordinator(
ObjectRouteToken route,
ObjectControlStore store,
DirectMultipartProvider provider,
Clock clock,
Duration qualifiedClockSkew,
Duration maximumInFlightHorizon) {
this.route = Objects.requireNonNull(route, "route must be non-null");
this.store = Objects.requireNonNull(store, "store must be non-null");
this.provider = Objects.requireNonNull(provider, "provider must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.qualifiedClockSkew = requirePositive(qualifiedClockSkew, "qualifiedClockSkew");
this.maximumInFlightHorizon = requirePositive(maximumInFlightHorizon, "maximumInFlightHorizon");
}
@Override
public MultipartSession startMultipart(MultipartStartRequest request) {
Objects.requireNonNull(request, "request must be non-null");
Instant now = clock.instant();
String fingerprint = fingerprint(request);
ObjectId objectId = objectId(request.operationKey().toString() + fingerprint);
DirectTransferSessionId sessionId = ObjectHandleCodec.multipart(route, objectId);
String key = ObjectControlKeyCodec.multipart(route, sessionId);
ObjectDirectMultipartSessionRecord initiating =
new ObjectDirectMultipartSessionRecord(
1,
sessionId.canonicalText(),
1,
request.operationKey(),
sessionId,
MultipartUploadState.INITIATE_IN_PROGRESS,
ObjectDataKeyCodec.encode(route, objectId, ObjectDataKeyCodec.Generation.of(1)),
request.contentIdentity(),
request.declaredMediaType(),
null,
request.maximumParts(),
0,
now.plus(request.sessionTtl()),
null,
null,
null,
now);
ObjectControlStore.StoredControlRecord stored;
boolean created = true;
try {
stored = store.create(key, initiating);
} catch (ObjectControlConflictException conflict) {
created = false;
stored =
store
.read(key)
.orElseThrow(() -> new IllegalStateException("multipart start conflict vanished"));
initiating = session(stored);
requireSameStart(initiating, request);
}
ObjectDirectMultipartSessionRecord active = initiating;
if (active.multipartState() == MultipartUploadState.INITIATE_IN_PROGRESS) {
DirectMultipartProvider.InitiateResult initiated;
if (created) {
try {
initiated = provider.initiate(active);
} catch (RuntimeException responseLoss) {
DirectMultipartProvider.InitiateResolution resolution = provider.resolveInitiate(active);
if (resolution.resolution() != DirectMultipartProvider.Resolution.APPLIED) {
throw new IllegalStateException(
"direct multipart initiation remains indeterminate", responseLoss);
}
initiated =
new DirectMultipartProvider.InitiateResult(resolution.providerSessionEvidence());
}
} else {
DirectMultipartProvider.InitiateResolution resolution = provider.resolveInitiate(active);
if (resolution.resolution() != DirectMultipartProvider.Resolution.APPLIED) {
throw new IllegalStateException(
"durable direct multipart initiation remains indeterminate");
}
initiated =
new DirectMultipartProvider.InitiateResult(resolution.providerSessionEvidence());
}
ObjectDirectMultipartSessionRecord accepting =
active.withProviderSession(initiated.providerSessionEvidence(), clock.instant());
stored = store.compareAndSet(key, new ObjectControlMutation(stored.version(), accepting));
active = session(stored);
}
if (active.multipartState() != MultipartUploadState.ACCEPTING_PARTS) {
throw new IllegalStateException("direct multipart session admission is closed");
}
return new MultipartSession(
request.operationKey(),
sessionId,
active.sessionExpiresAt(),
active.maximumParts(),
created ? ObjectMutationOutcome.APPLIED : ObjectMutationOutcome.REPLAYED);
}
@Override
public PartUploadGrant createPartGrant(PartUploadGrantRequest request) {
Objects.requireNonNull(request, "request must be non-null");
String sessionKey = ObjectControlKeyCodec.multipart(route, request.sessionId());
ObjectControlStore.StoredControlRecord stored = load(sessionKey);
ObjectDirectMultipartSessionRecord session = session(stored);
requireSameOperation(session, request.operationKey());
requireAcceptingParts(session, session.multipartState());
if (request.partNumber().value() > session.maximumParts()) {
throw new IllegalArgumentException("direct multipart part exceeds the session bound");
}
S3ClientPolicy.requirePartSize(request.exactPartLength(), false);
Instant now = clock.instant();
Instant expiresAt = now.plus(request.requestedTtl());
if (!expiresAt.isBefore(session.sessionExpiresAt())) {
throw new IllegalArgumentException("part grant exceeds the multipart session horizon");
}
DirectMultipartGrantLedger ledger =
new DirectMultipartGrantLedger(store, route, request.sessionId());
DirectMultipartGrantLedger.Stored grant =
ledger.prepare(
request.partNumber(),
request.exactPartLength(),
request.expectedPartDigest(),
now,
expiresAt);
requireSameGrant(grant.record(), request);
if (grant.record().grantState() == DirectGrantSessionState.GRANT_ISSUED) {
DirectGrantProvider.DirectGrantMaterial material = issued.get(grantCacheKey(grant.record()));
if (material == null) {
throw new IllegalStateException(
"issued direct multipart bearer is unavailable after process restart");
}
return partGrant(grant.record(), material);
}
ObjectDirectMultipartSessionRecord withExpiry = session.recordGrantExpiry(expiresAt, now);
stored =
store.compareAndSet(sessionKey, new ObjectControlMutation(stored.version(), withExpiry));
session = session(stored);
DirectGrantProvider.DirectGrantMaterial material = provider.signPart(session, grant.record());
grant = ledger.issue(grant, clock.instant());
issued.put(grantCacheKey(grant.record()), material);
return partGrant(grant.record(), material);
}
@Override
public PartReceiptToken acknowledgePart(MultipartPartAcknowledgement request) {
Objects.requireNonNull(request, "request must be non-null");
String sessionKey = ObjectControlKeyCodec.multipart(route, request.sessionId());
ObjectControlStore.StoredControlRecord stored = load(sessionKey);
ObjectDirectMultipartSessionRecord session = session(stored);
requireSameOperation(session, request.operationKey());
requireAcceptingParts(session, session.multipartState());
DirectMultipartGrantLedger ledger =
new DirectMultipartGrantLedger(store, route, request.sessionId());
DirectMultipartGrantLedger.Stored grant = ledger.load(request.partNumber());
if (grant.record().grantState() == DirectGrantSessionState.DATA_UPLOADED) {
return Objects.requireNonNull(grant.record().receiptToken());
}
if (grant.record().grantState() != DirectGrantSessionState.GRANT_ISSUED) {
throw new IllegalStateException("direct multipart part grant is not issued");
}
DirectMultipartProvider.VerifiedPart verified =
DirectPartAcknowledgementVerifier.verify(
grant.record(), request, provider.verifyPart(session, request));
PartReceiptToken token =
ObjectHandleCodec.partReceipt(
request.sessionId(), request.partNumber(), request.observedDigest());
ObjectMultipartPartRecord part =
new ObjectMultipartPartRecord(
1,
request.sessionId().canonicalText() + ":" + request.partNumber().value(),
1,
request.sessionId(),
request.partNumber(),
token,
request.observedLength(),
request.observedDigest(),
verified.boundedProviderEvidence(),
clock.instant());
new MultipartPartLedger(store, route, request.sessionId()).record(part);
ledger.acknowledge(grant, verified.boundedProviderEvidence(), token, clock.instant());
store.compareAndSet(
sessionKey,
new ObjectControlMutation(stored.version(), session.acknowledgePart(clock.instant())));
issued.remove(grantCacheKey(grant.record()));
return token;
}
@Override
public MultipartReceipt completeMultipart(MultipartCompleteRequest request) {
Objects.requireNonNull(request, "request must be non-null");
String key = ObjectControlKeyCodec.multipart(route, request.sessionId());
ObjectControlStore.StoredControlRecord stored = load(key);
ObjectDirectMultipartSessionRecord session = session(stored);
requireSameOperation(session, request.operationKey());
if (!session.expectedContent().equals(request.expectedContent())) {
throw new IllegalArgumentException("direct multipart full identity conflicts");
}
if (session.multipartState() == MultipartUploadState.COMPLETED) {
return receipt(
request.operationKey(),
request.sessionId(),
session,
Objects.requireNonNull(session.completedVersion()),
ObjectMutationOutcome.REPLAYED);
}
if (session.multipartState() == MultipartUploadState.COMPLETE_IN_PROGRESS) {
List<ObjectMultipartPartRecord> replayParts =
new MultipartPartLedger(store, route, request.sessionId())
.ordered(request.partTokens().size());
DirectMultipartCompletionVerifier.requireExactLedger(
request.partTokens(), replayParts, request.expectedContent());
DirectGrantProvider.VerifiedUpload replayVerified =
provider.resolveComplete(session, replayParts);
return persistCompleted(
key,
stored,
session,
replayVerified,
request.operationKey(),
request.sessionId(),
ObjectMutationOutcome.REPLAYED);
}
requireAcceptingParts(session, session.multipartState());
DirectMultipartCompletionVerifier.requireAdmissionDrained(
clock.instant(),
session.latestGrantExpiresAt(),
qualifiedClockSkew,
maximumInFlightHorizon,
provider.controlledIngressDrained(session));
List<ObjectMultipartPartRecord> parts =
new MultipartPartLedger(store, route, request.sessionId())
.ordered(request.partTokens().size());
DirectMultipartCompletionVerifier.requireExactLedger(
request.partTokens(), parts, request.expectedContent());
ObjectDirectMultipartSessionRecord completing =
session.fence(MultipartUploadState.COMPLETE_IN_PROGRESS, clock.instant());
stored = store.compareAndSet(key, new ObjectControlMutation(stored.version(), completing));
DirectGrantProvider.VerifiedUpload verified;
try {
verified = provider.complete(completing, parts);
} catch (RuntimeException responseLoss) {
try {
verified = provider.resolveComplete(completing, parts);
} catch (RuntimeException unresolved) {
unresolved.addSuppressed(responseLoss);
throw unresolved;
}
}
if (!verified.contentIdentity().equals(request.expectedContent())) {
throw new IllegalStateException("direct multipart final verification conflicts");
}
return persistCompleted(
key,
stored,
completing,
verified,
request.operationKey(),
request.sessionId(),
ObjectMutationOutcome.APPLIED);
}
@Override
public ObjectMutationReceipt abortMultipart(MultipartAbortRequest request) {
Objects.requireNonNull(request, "request must be non-null");
String key = ObjectControlKeyCodec.multipart(route, request.sessionId());
ObjectControlStore.StoredControlRecord stored = load(key);
ObjectDirectMultipartSessionRecord session = session(stored);
requireSameOperation(session, request.operationKey());
if (session.multipartState() == MultipartUploadState.ABORTED) {
return mutation(request.operationKey(), ObjectMutationOutcome.REPLAYED);
}
if (session.multipartState() == MultipartUploadState.ABORT_IN_PROGRESS) {
if (provider.resolveAbort(session) != DirectMultipartProvider.Resolution.APPLIED) {
throw new IllegalStateException("direct multipart abort remains indeterminate");
}
store.compareAndSet(
key,
new ObjectControlMutation(
stored.version(),
session.finish(MultipartUploadState.ABORTED, null, clock.instant())));
return mutation(request.operationKey(), ObjectMutationOutcome.REPLAYED);
}
requireAcceptingParts(session, session.multipartState());
ObjectDirectMultipartSessionRecord aborting =
session.fence(MultipartUploadState.ABORT_IN_PROGRESS, clock.instant());
stored = store.compareAndSet(key, new ObjectControlMutation(stored.version(), aborting));
try {
provider.abort(aborting);
} catch (RuntimeException responseLoss) {
if (provider.resolveAbort(aborting) != DirectMultipartProvider.Resolution.APPLIED) {
throw new IllegalStateException(
"direct multipart abort remains indeterminate", responseLoss);
}
}
store.compareAndSet(
key,
new ObjectControlMutation(
stored.version(),
aborting.finish(MultipartUploadState.ABORTED, null, clock.instant())));
return mutation(request.operationKey(), ObjectMutationOutcome.APPLIED);
}
public static void requireAcceptingParts(
ObjectDirectMultipartSessionRecord session, MultipartUploadState observedState) {
Objects.requireNonNull(session, "session must be non-null");
if (observedState != MultipartUploadState.ACCEPTING_PARTS) {
throw new IllegalStateException("direct multipart part admission is closed");
}
}
private ObjectControlStore.StoredControlRecord load(String key) {
return store
.read(key)
.orElseThrow(() -> new IllegalArgumentException("multipart session is absent"));
}
private static ObjectDirectMultipartSessionRecord session(
ObjectControlStore.StoredControlRecord stored) {
if (!(stored.record() instanceof ObjectDirectMultipartSessionRecord session)) {
throw new IllegalStateException("direct multipart session family is corrupt");
}
return session;
}
private static void requireSameStart(
ObjectDirectMultipartSessionRecord session, MultipartStartRequest request) {
requireSameOperation(session, request.operationKey());
if (!session.expectedContent().equals(request.contentIdentity())
|| !session.mediaType().equals(request.declaredMediaType())
|| session.maximumParts() != request.maximumParts()) {
throw new IllegalArgumentException("direct multipart start intent conflicts");
}
}
private static void requireSameGrant(
ObjectDirectMultipartGrantRecord grant, PartUploadGrantRequest request) {
if (!grant.sessionId().equals(request.sessionId())
|| !grant.partNumber().equals(request.partNumber())
|| grant.exactPartLength() != request.exactPartLength()
|| !grant.expectedPartDigest().equals(request.expectedPartDigest())) {
throw new IllegalArgumentException("direct multipart part grant intent conflicts");
}
}
private static void requireSameOperation(
ObjectDirectMultipartSessionRecord session,
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey) {
if (!session.operationKey().equals(operationKey)) {
throw new IllegalArgumentException("direct multipart operation identity conflicts");
}
}
private static PartUploadGrant partGrant(
ObjectDirectMultipartGrantRecord grant, DirectGrantProvider.DirectGrantMaterial material) {
return new PartUploadGrant(
grant.sessionId(),
grant.partNumber(),
material.requestUri(),
material.signedHeaders(),
grant.expiresAt());
}
private static String grantCacheKey(ObjectDirectMultipartGrantRecord grant) {
return grant.sessionId().canonicalText() + ":" + grant.partNumber().value();
}
private ObjectMutationReceipt mutation(
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
ObjectMutationOutcome outcome) {
return new ObjectMutationReceipt(
operation, outcome, ObjectOperationError.NONE, clock.instant());
}
private MultipartReceipt receipt(
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
DirectTransferSessionId sessionId,
ObjectDirectMultipartSessionRecord session,
dev.caskeleton.application.objectstorage.identity.ObjectVersionToken exactVersion,
ObjectMutationOutcome outcome) {
ObjectId objectId = objectIdFromDataKey(session.canonicalDataKey());
return new MultipartReceipt(
operation,
sessionId,
ObjectHandleCodec.stage(route, objectId),
exactVersion,
session.expectedContent(),
outcome);
}
private MultipartReceipt persistCompleted(
String key,
ObjectControlStore.StoredControlRecord stored,
ObjectDirectMultipartSessionRecord completing,
DirectGrantProvider.VerifiedUpload verified,
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
DirectTransferSessionId sessionId,
ObjectMutationOutcome outcome) {
if (!verified.contentIdentity().equals(completing.expectedContent())) {
throw new IllegalStateException("direct multipart final verification conflicts");
}
ObjectDirectMultipartSessionRecord completed =
completing.finish(MultipartUploadState.COMPLETED, verified.exactVersion(), clock.instant());
store.compareAndSet(key, new ObjectControlMutation(stored.version(), completed));
return receipt(operation, sessionId, completed, verified.exactVersion(), outcome);
}
private static String fingerprint(MultipartStartRequest request) {
return sha256(
request.operationKey()
+ "\n"
+ request.declaredMediaType().canonicalText()
+ "\n"
+ request.contentIdentity().exactLength()
+ "\n"
+ request.contentIdentity().fullDigest().base64Value()
+ "\n"
+ request.maximumParts()
+ "\n"
+ request.sessionTtl());
}
private static ObjectId objectId(String seed) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8));
return ObjectId.parse(CrockfordBase32.encode(Arrays.copyOf(digest, 16), 26));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static ObjectId objectIdFromDataKey(String key) {
String[] segments = key.split("/", -1);
if (segments.length != 6) {
throw new IllegalStateException("direct multipart data key is corrupt");
}
return ObjectId.parse(segments[4]);
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static Duration requirePositive(Duration value, String label) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(label + " must be positive");
}
return value;
}
}
@@ -0,0 +1,107 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import java.time.Instant;
import java.util.Objects;
/** Exact-key durable ledger for one bounded part-grant generation per part. */
public final class DirectMultipartGrantLedger {
private final ObjectControlStore store;
private final ObjectRouteToken route;
private final DirectTransferSessionId sessionId;
public DirectMultipartGrantLedger(
ObjectControlStore store, ObjectRouteToken route, DirectTransferSessionId sessionId) {
this.store = Objects.requireNonNull(store, "store must be non-null");
this.route = Objects.requireNonNull(route, "route must be non-null");
this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null");
}
public Stored prepare(
MultipartPartNumber partNumber,
long exactLength,
ObjectDigest digest,
Instant signingTime,
Instant expiresAt) {
String key = key(partNumber);
ObjectDirectMultipartGrantRecord prepared =
new ObjectDirectMultipartGrantRecord(
1,
sessionId.canonicalText() + ":" + partNumber.value(),
1,
sessionId,
partNumber,
1,
DirectGrantSessionState.GRANT_PREPARED,
exactLength,
digest,
signingTime,
expiresAt,
null,
null,
signingTime);
try {
ObjectControlStore.StoredControlRecord stored = store.create(key, prepared);
return new Stored(stored.version(), prepared);
} catch (ObjectControlConflictException conflict) {
return load(partNumber);
}
}
public Stored issue(Stored current, Instant now) {
ObjectDirectMultipartGrantRecord replacement = current.record().issue(now);
ObjectControlStore.StoredControlRecord stored =
store.compareAndSet(
key(replacement.partNumber()),
new ObjectControlMutation(current.version(), replacement));
return new Stored(stored.version(), (ObjectDirectMultipartGrantRecord) stored.record());
}
public Stored acknowledge(
Stored current, String providerEvidence, PartReceiptToken token, Instant now) {
ObjectDirectMultipartGrantRecord replacement =
current.record().acknowledge(providerEvidence, token, now);
ObjectControlStore.StoredControlRecord stored =
store.compareAndSet(
key(replacement.partNumber()),
new ObjectControlMutation(current.version(), replacement));
return new Stored(stored.version(), (ObjectDirectMultipartGrantRecord) stored.record());
}
public Stored load(MultipartPartNumber partNumber) {
ObjectControlStore.StoredControlRecord stored =
store
.read(key(partNumber))
.orElseThrow(() -> new IllegalArgumentException("direct part grant is absent"));
if (!(stored.record() instanceof ObjectDirectMultipartGrantRecord grant)
|| !grant.sessionId().equals(sessionId)) {
throw new IllegalStateException("direct part grant ledger is corrupt");
}
return new Stored(stored.version(), grant);
}
private String key(MultipartPartNumber partNumber) {
return ObjectControlKeyCodec.directMultipartGrant(route, sessionId, partNumber);
}
public record Stored(
dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlVersion version,
ObjectDirectMultipartGrantRecord record) {
public Stored {
Objects.requireNonNull(version, "version must be non-null");
Objects.requireNonNull(record, "record must be non-null");
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartSessionRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement;
import java.util.List;
import java.util.Objects;
/** Provider-private direct multipart calls and authoritative acknowledgement verification. */
public interface DirectMultipartProvider {
InitiateResult initiate(ObjectDirectMultipartSessionRecord session);
InitiateResolution resolveInitiate(ObjectDirectMultipartSessionRecord session);
DirectGrantProvider.DirectGrantMaterial signPart(
ObjectDirectMultipartSessionRecord session, ObjectDirectMultipartGrantRecord grant);
VerifiedPart verifyPart(
ObjectDirectMultipartSessionRecord session, MultipartPartAcknowledgement acknowledgement);
boolean controlledIngressDrained(ObjectDirectMultipartSessionRecord session);
DirectGrantProvider.VerifiedUpload complete(
ObjectDirectMultipartSessionRecord session, List<ObjectMultipartPartRecord> parts);
DirectGrantProvider.VerifiedUpload resolveComplete(
ObjectDirectMultipartSessionRecord session, List<ObjectMultipartPartRecord> parts);
void abort(ObjectDirectMultipartSessionRecord session);
Resolution resolveAbort(ObjectDirectMultipartSessionRecord session);
record InitiateResult(String providerSessionEvidence) {
public InitiateResult {
providerSessionEvidence =
requireEvidence("providerSessionEvidence", providerSessionEvidence, 512);
}
}
record InitiateResolution(Resolution resolution, String providerSessionEvidence) {
public InitiateResolution {
Objects.requireNonNull(resolution, "resolution must be non-null");
if (resolution == Resolution.APPLIED) {
providerSessionEvidence =
requireEvidence("providerSessionEvidence", providerSessionEvidence, 512);
} else if (providerSessionEvidence != null) {
throw new IllegalArgumentException("unapplied initiate resolution contains evidence");
}
}
public static InitiateResolution applied(String evidence) {
return new InitiateResolution(Resolution.APPLIED, evidence);
}
public static InitiateResolution indeterminate() {
return new InitiateResolution(Resolution.INDETERMINATE, null);
}
}
record VerifiedPart(
MultipartPartNumber partNumber,
long exactLength,
ObjectDigest digest,
String privateEtag,
String providerChecksum) {
public VerifiedPart {
Objects.requireNonNull(partNumber, "partNumber must be non-null");
if (exactLength < 1) {
throw new IllegalArgumentException("verified direct part length must be positive");
}
Objects.requireNonNull(digest, "digest must be non-null");
privateEtag = requireEvidence("privateEtag", privateEtag, 256);
providerChecksum = requireEvidence("providerChecksum", providerChecksum, 256);
}
public String boundedProviderEvidence() {
return privateEtag + ":" + providerChecksum;
}
}
enum Resolution {
APPLIED,
NOT_APPLIED,
INDETERMINATE
}
private static String requireEvidence(String label, String value, int maximum) {
if (value == null || value.isBlank() || value.length() > maximum) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord;
import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement;
import java.util.Objects;
/**
* Matches a bounded client completion claim to the issued grant and authoritative provider part.
*/
public final class DirectPartAcknowledgementVerifier {
private DirectPartAcknowledgementVerifier() {}
public static DirectMultipartProvider.VerifiedPart verify(
ObjectDirectMultipartGrantRecord grant,
MultipartPartAcknowledgement acknowledgement,
DirectMultipartProvider.VerifiedPart providerEvidence) {
Objects.requireNonNull(grant, "grant must be non-null");
Objects.requireNonNull(acknowledgement, "acknowledgement must be non-null");
Objects.requireNonNull(providerEvidence, "providerEvidence must be non-null");
if (!grant.sessionId().equals(acknowledgement.sessionId())
|| !grant.partNumber().equals(acknowledgement.partNumber())
|| !grant.partNumber().equals(providerEvidence.partNumber())
|| grant.exactPartLength() != acknowledgement.observedLength()
|| grant.exactPartLength() != providerEvidence.exactLength()
|| !grant.expectedPartDigest().equals(acknowledgement.observedDigest())
|| !grant.expectedPartDigest().equals(providerEvidence.digest())) {
throw new IllegalArgumentException("direct multipart acknowledgement evidence conflicts");
}
return providerEvidence;
}
}
@@ -0,0 +1,67 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectManifestRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferencePointerRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest;
import java.util.Objects;
/**
* Resolves an active published reference through exact lifecycle, pointer, and manifest records.
*/
public final class DirectPublishedObjectResolver {
private final ObjectControlStore store;
public DirectPublishedObjectResolver(ObjectControlStore store) {
this.store = Objects.requireNonNull(store, "store must be non-null");
}
public DirectGrantProvider.PublishedObject resolve(DirectDownloadGrantRequest request) {
Objects.requireNonNull(request, "request must be non-null");
ObjectRouteToken route =
ObjectRouteToken.parse(request.reference().canonicalText().split("\\.", -1)[1]);
ObjectControlStore.StoredControlRecord lifecycleStored =
store
.read(ObjectControlKeyCodec.referenceLifecycle(route, request.reference()))
.orElseThrow(() -> new IllegalArgumentException("published reference is absent"));
if (!(lifecycleStored.record() instanceof ObjectReferenceRecord lifecycle)) {
throw new IllegalStateException("published reference lifecycle is corrupt");
}
boolean active = lifecycle.state() == PublishedReferenceState.PUBLISHED;
ObjectControlStore.StoredControlRecord pointerStored =
store
.read(ObjectControlKeyCodec.reference(route, request.reference()))
.orElseThrow(() -> new IllegalStateException("published reference pointer is absent"));
if (!(pointerStored.record() instanceof ObjectReferencePointerRecord pointer)
|| !pointer.immutableDataVersion().equals(lifecycle.immutableDataVersion())) {
throw new IllegalStateException("published reference pointer is corrupt");
}
ObjectControlStore.StoredControlRecord manifestStored =
store
.read(
ObjectControlKeyCodec.manifest(
route,
lifecycle.objectId(),
ObjectControlKeyCodec.ManifestRevision.of(pointer.manifestRevision())))
.orElseThrow(() -> new IllegalStateException("published manifest is absent"));
if (!(manifestStored.record() instanceof ObjectManifestRecord manifest)
|| !manifest.immutableDataVersion().equals(lifecycle.immutableDataVersion())
|| !manifest.objectId().equals(lifecycle.objectId())) {
throw new IllegalStateException("published manifest is corrupt");
}
return new DirectGrantProvider.PublishedObject(
ObjectDataKeyCodec.encode(route, lifecycle.objectId(), ObjectDataKeyCodec.Generation.of(1)),
new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity(
manifest.logicalSize(), manifest.logicalDigest()),
manifest.mediaType(),
manifest.immutableDataVersion(),
lifecycleStored.version().value(),
active);
}
}
@@ -0,0 +1,335 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.codec.CrockfordBase32;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectSessionRecord;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.model.DirectDownloadGrant;
import dev.caskeleton.application.objectstorage.model.DirectUploadCompletionReceipt;
import dev.caskeleton.application.objectstorage.model.DirectUploadGrant;
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
import dev.caskeleton.application.objectstorage.port.DirectObjectDownloadGrantPort;
import dev.caskeleton.application.objectstorage.port.DirectObjectUploadPort;
import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest;
import dev.caskeleton.application.objectstorage.request.DirectUploadCompletionRequest;
import dev.caskeleton.application.objectstorage.request.DirectUploadGrantRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Direct grant state machine. It persists only non-secret generation evidence and retains bearer
* material in a bounded process-local replay cache.
*/
public final class DirectTransferCoordinator
implements DirectObjectUploadPort, DirectObjectDownloadGrantPort {
private final ObjectRouteToken route;
private final ObjectControlStore store;
private final DirectTransferPolicy policy;
private final DirectGrantProvider provider;
private final Clock clock;
private final Map<String, DirectGrantProvider.DirectGrantMaterial> issued =
new ConcurrentHashMap<>();
public DirectTransferCoordinator(
ObjectRouteToken route,
ObjectControlStore store,
DirectTransferPolicy policy,
DirectGrantProvider provider,
Clock clock) {
this.route = Objects.requireNonNull(route, "route must be non-null");
this.store = Objects.requireNonNull(store, "store must be non-null");
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
this.provider = Objects.requireNonNull(provider, "provider must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public DirectUploadGrant createUploadGrant(DirectUploadGrantRequest request) {
Objects.requireNonNull(request, "request must be non-null");
Instant now = clock.instant();
DirectGrantGeneration planned =
policy.planGrant(
policy.planningEndpoint(),
request.requestedTtl(),
request.contentIdentity().exactLength(),
now);
String constraints = constraints(request);
ObjectId objectId = objectId(request.operationKey().toString() + constraints);
DirectTransferSessionId sessionId = ObjectHandleCodec.directUpload(route, objectId);
String key = ObjectControlKeyCodec.directSession(route, sessionId);
ObjectDirectSessionRecord current = reserveUpload(key, sessionId, objectId, request, now);
requireSameUpload(current.session(), request);
if (current.session().state() == DirectGrantSessionState.SESSION_RESERVED) {
DirectTransferSessionRecord prepared =
current.session().prepare(planned, constraints, "upload-unpublished");
current = replace(key, current, prepared);
}
if (current.session().state() == DirectGrantSessionState.GRANT_ISSUED) {
DirectGrantProvider.DirectGrantMaterial cached = issued.get(sessionId.canonicalText());
if (cached == null) {
throw new IllegalStateException(
"issued direct grant bearer material is unavailable after process restart");
}
return uploadGrant(current.session(), cached);
}
if (current.session().state() != DirectGrantSessionState.GRANT_PREPARED) {
throw new IllegalStateException("direct upload grant admission is closed");
}
DirectGrantProvider.DirectGrantMaterial material = provider.signUpload(current.session());
policy.validateSignedGrant(material.requestUri(), current.session().expiresAt());
ObjectDirectSessionRecord issuedRecord =
replace(key, current, current.session().issue(clock.instant()));
issued.put(sessionId.canonicalText(), material);
return uploadGrant(issuedRecord.session(), material);
}
@Override
public DirectUploadCompletionReceipt completeUpload(DirectUploadCompletionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
String key = ObjectControlKeyCodec.directSession(route, request.sessionId());
ObjectControlStore.StoredControlRecord stored =
store.read(key).orElseThrow(() -> new IllegalArgumentException("direct session is absent"));
ObjectDirectSessionRecord current = direct(stored);
requireSameOperation(current.session(), request.operationKey());
if (!current.session().expectedContent().equals(request.expectedContent())) {
throw new IllegalArgumentException("direct upload completion identity conflicts");
}
if (current.session().state() == DirectGrantSessionState.DATA_UPLOADED) {
return completion(current.session(), ObjectMutationOutcome.REPLAYED);
}
if (current.session().state() != DirectGrantSessionState.GRANT_ISSUED) {
throw new IllegalStateException("direct upload is not ready for verification");
}
DirectGrantProvider.VerifiedUpload verified = provider.verifyUpload(current.session());
if (!verified.contentIdentity().equals(request.expectedContent())) {
throw new IllegalStateException("direct upload provider evidence does not match");
}
DirectTransferSessionRecord uploaded =
current.session().uploaded(verified.exactVersion(), clock.instant());
replace(key, stored, current.replace(uploaded));
issued.remove(request.sessionId().canonicalText());
return completion(uploaded, ObjectMutationOutcome.APPLIED);
}
@Override
public DirectDownloadGrant createDownloadGrant(DirectDownloadGrantRequest request) {
Objects.requireNonNull(request, "request must be non-null");
DirectGrantProvider.PublishedObject published = provider.resolvePublished(request);
if (!published.active()
|| (request.expectedVersion().isPresent()
&& !request.expectedVersion().orElseThrow().equals(published.exactVersion()))) {
throw new IllegalStateException("direct download requires an active exact publication");
}
Instant now = clock.instant();
DirectGrantGeneration planned =
policy.planGrant(
policy.planningEndpoint(),
request.requestedTtl(),
published.contentIdentity().exactLength(),
now);
String constraints = constraints(request, published);
ObjectId objectId = objectId(request.operationKey().toString() + constraints);
DirectTransferSessionId sessionId = ObjectHandleCodec.directUpload(route, objectId);
String key = ObjectControlKeyCodec.directSession(route, sessionId);
DirectTransferSessionRecord reserved =
new DirectTransferSessionRecord(
request.operationKey(),
sessionId,
DirectGrantSessionState.SESSION_RESERVED,
0,
null,
null,
null,
null,
null,
published.canonicalDataKey(),
published.contentIdentity(),
published.mediaType(),
published.exactVersion(),
now)
.prepare(planned, constraints, published.referenceRevision());
ObjectDirectSessionRecord prepared =
new ObjectDirectSessionRecord(1, sessionId.canonicalText(), 1, reserved);
ObjectControlStore.StoredControlRecord stored;
try {
stored = store.create(key, prepared);
} catch (ObjectControlConflictException conflict) {
stored =
store
.read(key)
.orElseThrow(() -> new IllegalStateException("direct download conflict vanished"));
prepared = direct(stored);
}
DirectGrantProvider.DirectGrantMaterial material =
provider.signDownload(prepared.session(), published);
DirectGrantProvider.PublishedObject fence = provider.resolvePublished(request);
if (!fence.active()
|| !fence.referenceRevision().equals(prepared.session().referenceRevision())) {
throw new IllegalStateException("publication retired while issuing direct download");
}
ObjectDirectSessionRecord issuedRecord =
replace(key, stored, prepared.replace(prepared.session().issue(clock.instant())));
issued.put(sessionId.canonicalText(), material);
return new DirectDownloadGrant(
sessionId,
material.requestUri(),
material.signedHeaders(),
issuedRecord.session().expiresAt());
}
private ObjectDirectSessionRecord reserveUpload(
String key,
DirectTransferSessionId sessionId,
ObjectId objectId,
DirectUploadGrantRequest request,
Instant now) {
DirectTransferSessionRecord reserved =
DirectTransferSessionRecord.reserved(
request.operationKey(),
sessionId,
ObjectDataKeyCodec.encode(route, objectId, ObjectDataKeyCodec.Generation.of(1)),
request.contentIdentity(),
request.declaredMediaType(),
now);
ObjectDirectSessionRecord record =
new ObjectDirectSessionRecord(1, sessionId.canonicalText(), 1, reserved);
try {
return direct(store.create(key, record));
} catch (ObjectControlConflictException conflict) {
return direct(
store
.read(key)
.orElseThrow(() -> new IllegalStateException("direct upload conflict vanished")));
}
}
private ObjectDirectSessionRecord replace(
String key, ObjectDirectSessionRecord current, DirectTransferSessionRecord replacement) {
ObjectControlStore.StoredControlRecord stored =
store.read(key).orElseThrow(() -> new IllegalStateException("direct session disappeared"));
return replace(key, stored, current.replace(replacement));
}
private ObjectDirectSessionRecord replace(
String key,
ObjectControlStore.StoredControlRecord current,
ObjectDirectSessionRecord replacement) {
return direct(
store.compareAndSet(key, new ObjectControlMutation(current.version(), replacement)));
}
private static ObjectDirectSessionRecord direct(ObjectControlStore.StoredControlRecord stored) {
if (!(stored.record() instanceof ObjectDirectSessionRecord direct)) {
throw new IllegalStateException("direct session control family is corrupt");
}
return direct;
}
private static DirectUploadGrant uploadGrant(
DirectTransferSessionRecord session, DirectGrantProvider.DirectGrantMaterial material) {
return new DirectUploadGrant(
session.sessionId(), material.requestUri(), material.signedHeaders(), session.expiresAt());
}
private DirectUploadCompletionReceipt completion(
DirectTransferSessionRecord session, ObjectMutationOutcome outcome) {
ObjectId objectId = objectIdFromDataKey(session.canonicalDataKey());
return new DirectUploadCompletionReceipt(
session.operationKey(),
session.sessionId(),
ObjectHandleCodec.stage(route, objectId),
Objects.requireNonNull(session.exactVersion()),
session.expectedContent(),
outcome);
}
private static void requireSameUpload(
DirectTransferSessionRecord session, DirectUploadGrantRequest request) {
requireSameOperation(session, request.operationKey());
if (!session.expectedContent().equals(request.contentIdentity())
|| !session.mediaType().equals(request.declaredMediaType())) {
throw new IllegalArgumentException("direct upload operation conflicts with reserved intent");
}
}
private static void requireSameOperation(
DirectTransferSessionRecord session,
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey) {
if (!session.operationKey().equals(operationKey)) {
throw new IllegalArgumentException("direct operation identity conflicts");
}
}
private static String constraints(DirectUploadGrantRequest request) {
return sha256(
request.operationKey()
+ "\n"
+ request.declaredMediaType().canonicalText()
+ "\n"
+ request.contentIdentity().exactLength()
+ "\n"
+ request.contentIdentity().fullDigest().base64Value()
+ "\n"
+ request.publicationRequirement()
+ "\n"
+ request.requestedTtl());
}
private static String constraints(
DirectDownloadGrantRequest request, DirectGrantProvider.PublishedObject published) {
return sha256(
request.operationKey()
+ "\n"
+ request.reference().canonicalText()
+ "\n"
+ published.exactVersion().canonicalText()
+ "\n"
+ published.referenceRevision()
+ "\n"
+ request.requestedTtl());
}
private static ObjectId objectId(String seed) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8));
return ObjectId.parse(CrockfordBase32.encode(Arrays.copyOf(digest, 16), 26));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static ObjectId objectIdFromDataKey(String key) {
String[] segments = key.split("/", -1);
if (segments.length != 6) {
throw new IllegalStateException("direct session data key is corrupt");
}
return ObjectId.parse(segments[4]);
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import java.util.Set;
/** Browser-visible CORS contract that infrastructure must apply before direct admission. */
public final class DirectTransferCorsPolicy {
private DirectTransferCorsPolicy() {}
public static Set<String> allowedMethods() {
return Set.of("GET", "HEAD", "PUT");
}
public static Set<String> allowedRequestHeaders() {
return Set.of(
"content-length",
"content-type",
"if-none-match",
"x-amz-checksum-sha256",
"x-amz-server-side-encryption");
}
public static Set<String> exposedResponseHeaders() {
return Set.of("etag", "x-amz-checksum-sha256", "x-amz-version-id");
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Set;
/** Fail-closed admission policy for short-lived direct-transfer bearer grants. */
public record DirectTransferPolicy(
UploadProfile uploadProfile,
Set<String> allowedHosts,
Duration maximumGrantTtl,
Duration signatureAgeCeiling,
Duration qualifiedMaximumClockSkew,
Instant credentialHorizon,
String credentialRevision,
int maximumOutstandingGenerations,
long maximumExposureBytes,
boolean providerHardCeilingEnforced,
ClockHealth clockHealth) {
public DirectTransferPolicy {
Objects.requireNonNull(uploadProfile, "uploadProfile must be non-null");
allowedHosts =
Set.copyOf(Objects.requireNonNull(allowedHosts, "allowedHosts must be non-null"));
if (allowedHosts.isEmpty()
|| allowedHosts.stream()
.anyMatch(
host ->
host == null
|| host.isBlank()
|| host.length() > 253
|| !host.matches("[a-zA-Z0-9.-]+"))) {
throw new IllegalArgumentException("direct-transfer host allowlist is invalid");
}
requirePositive("maximumGrantTtl", maximumGrantTtl);
requirePositive("signatureAgeCeiling", signatureAgeCeiling);
requirePositive("qualifiedMaximumClockSkew", qualifiedMaximumClockSkew);
Objects.requireNonNull(credentialHorizon, "credentialHorizon must be non-null");
if (credentialRevision == null
|| credentialRevision.isBlank()
|| credentialRevision.length() > 128) {
throw new IllegalArgumentException("credential revision is invalid");
}
if (maximumOutstandingGenerations < 1 || maximumOutstandingGenerations > 16) {
throw new IllegalArgumentException("outstanding direct generation bound is invalid");
}
if (maximumExposureBytes < 1) {
throw new IllegalArgumentException("direct maximum exposure must be positive");
}
Objects.requireNonNull(clockHealth, "clockHealth must be non-null");
}
public DirectGrantGeneration planGrant(
URI configuredEndpoint, Duration requestedTtl, long exactBytes, Instant now) {
validateEndpoint(configuredEndpoint);
Objects.requireNonNull(requestedTtl, "requestedTtl must be non-null");
Objects.requireNonNull(now, "now must be non-null");
if (!clockHealth.qualifiedAt(now)) {
throw new IllegalStateException("qualified clock health is unavailable");
}
if (requestedTtl.isZero()
|| requestedTtl.isNegative()
|| requestedTtl.compareTo(maximumGrantTtl) > 0
|| requestedTtl.compareTo(signatureAgeCeiling) > 0) {
throw new IllegalArgumentException("direct grant TTL exceeds its qualified bound");
}
if (exactBytes < 1 || exactBytes > maximumExposureBytes) {
throw new IllegalArgumentException("direct transfer exceeds its exposure bound");
}
if (uploadProfile == UploadProfile.DIRECT_SINGLE_HARD_CEILING && !providerHardCeilingEnforced) {
throw new IllegalArgumentException("provider-enforced direct upload ceiling is unavailable");
}
Instant expiresAt = now.plus(requestedTtl);
Instant latestAllowed = credentialHorizon.minus(qualifiedMaximumClockSkew);
if (!expiresAt.isBefore(latestAllowed)) {
throw new IllegalArgumentException("direct grant exceeds the credential session horizon");
}
return new DirectGrantGeneration(
1, null, now, expiresAt, credentialRevision, "unbound-reference");
}
public void validateSignedGrant(URI uri, Instant expectedExpiry) {
validateEndpoint(uri);
Objects.requireNonNull(expectedExpiry, "expectedExpiry must be non-null");
}
public URI planningEndpoint() {
return URI.create("https://" + allowedHosts.stream().sorted().findFirst().orElseThrow());
}
private void validateEndpoint(URI endpoint) {
if (endpoint == null
|| !"https".equalsIgnoreCase(endpoint.getScheme())
|| endpoint.getHost() == null
|| endpoint.getUserInfo() != null
|| endpoint.getFragment() != null
|| !allowedHosts.contains(endpoint.getHost())) {
throw new IllegalArgumentException("direct-transfer endpoint is not qualified");
}
}
private static void requirePositive(String label, Duration value) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(label + " must be positive");
}
}
public enum UploadProfile {
DIRECT_SINGLE_HARD_CEILING,
DIRECT_SINGLE_SOFT_LIMIT_R1
}
@FunctionalInterface
public interface ClockHealth {
boolean qualifiedAt(Instant instant);
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import java.time.Instant;
import java.util.Objects;
/** Durable non-secret direct-transfer session state; bearer material is deliberately absent. */
public record DirectTransferSessionRecord(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
DirectGrantSessionState state,
long grantGeneration,
String constraintsDigest,
Instant signingTime,
Instant expiresAt,
String credentialRevision,
String referenceRevision,
String canonicalDataKey,
ObjectContentIdentity expectedContent,
ObjectMediaType mediaType,
ObjectVersionToken exactVersion,
Instant updatedAt) {
public DirectTransferSessionRecord {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(state, "state must be non-null");
if (grantGeneration < 0 || grantGeneration > 16) {
throw new IllegalArgumentException("direct grant generation is outside the supported range");
}
if (constraintsDigest != null && !constraintsDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("direct constraints digest is invalid");
}
if ((signingTime == null) != (expiresAt == null)
|| (signingTime != null && !expiresAt.isAfter(signingTime))) {
throw new IllegalArgumentException("direct grant times are inconsistent");
}
credentialRevision = boundedNullable("credentialRevision", credentialRevision, 128);
referenceRevision = boundedNullable("referenceRevision", referenceRevision, 128);
if (canonicalDataKey == null
|| canonicalDataKey.length() > 1024
|| !canonicalDataKey.startsWith("data/v1/")) {
throw new IllegalArgumentException("direct canonical data key is invalid");
}
Objects.requireNonNull(expectedContent, "expectedContent must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
Objects.requireNonNull(updatedAt, "updatedAt must be non-null");
if (state != DirectGrantSessionState.SESSION_RESERVED
&& (grantGeneration == 0
|| constraintsDigest == null
|| signingTime == null
|| credentialRevision == null
|| referenceRevision == null)) {
throw new IllegalArgumentException("direct grant evidence is incomplete for its state");
}
}
public static DirectTransferSessionRecord reserved(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
String canonicalDataKey,
ObjectContentIdentity expectedContent,
ObjectMediaType mediaType,
Instant now) {
return new DirectTransferSessionRecord(
operationKey,
sessionId,
DirectGrantSessionState.SESSION_RESERVED,
0,
null,
null,
null,
null,
null,
canonicalDataKey,
expectedContent,
mediaType,
null,
now);
}
public DirectTransferSessionRecord prepare(
DirectGrantGeneration generation, String constraints, String exactReferenceRevision) {
if (state != DirectGrantSessionState.SESSION_RESERVED) {
throw new IllegalStateException("direct session is not reservable");
}
DirectGrantGeneration bound = generation.bind(constraints, exactReferenceRevision);
return new DirectTransferSessionRecord(
operationKey,
sessionId,
DirectGrantSessionState.GRANT_PREPARED,
bound.value(),
bound.constraintsDigest(),
bound.signingTime(),
bound.expiresAt(),
bound.credentialRevision(),
bound.referenceRevision(),
canonicalDataKey,
expectedContent,
mediaType,
exactVersion,
bound.signingTime());
}
public DirectTransferSessionRecord issue(Instant now) {
if (state != DirectGrantSessionState.GRANT_PREPARED) {
throw new IllegalStateException("direct grant is not prepared");
}
return withState(DirectGrantSessionState.GRANT_ISSUED, exactVersion, now);
}
public DirectTransferSessionRecord uploaded(ObjectVersionToken verifiedVersion, Instant now) {
if (state != DirectGrantSessionState.GRANT_ISSUED) {
throw new IllegalStateException("direct upload is not awaiting verification");
}
return withState(
DirectGrantSessionState.DATA_UPLOADED,
Objects.requireNonNull(verifiedVersion, "verifiedVersion must be non-null"),
now);
}
private DirectTransferSessionRecord withState(
DirectGrantSessionState replacement, ObjectVersionToken version, Instant now) {
return new DirectTransferSessionRecord(
operationKey,
sessionId,
replacement,
grantGeneration,
constraintsDigest,
signingTime,
expiresAt,
credentialRevision,
referenceRevision,
canonicalDataKey,
expectedContent,
mediaType,
version,
now);
}
private static String boundedNullable(String label, String value, int maximum) {
if (value == null) {
return null;
}
if (value.isBlank() || value.length() > maximum) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
@Override
public String toString() {
return "DirectTransferSessionRecord[session="
+ sessionId.redactedLogToken()
+ ", state="
+ state
+ ", grantGeneration="
+ grantGeneration
+ "]";
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.adapter.outbound.objectstorage.direct;
import java.net.URI;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/** Central redaction helper for presigned bearer material. */
public final class PresignedGrantRedactor {
private PresignedGrantRedactor() {}
public static String redact(URI ignored) {
Objects.requireNonNull(ignored, "presigned URI must be non-null");
return "[REDACTED_PRESIGNED_URI]";
}
public static String describeHeaders(Map<String, String> headers) {
Objects.requireNonNull(headers, "signed headers must be non-null");
return headers.keySet().stream().sorted().collect(Collectors.joining(",", "[", "]"));
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
import dev.caskeleton.adapter.outbound.objectstorage.config.CompiledObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageProviderContribution;
import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageProviderSettings;
import dev.caskeleton.adapter.outbound.objectstorage.config.SelectedObjectStorageProviderFactory;
import dev.caskeleton.adapter.outbound.objectstorage.control.CanonicalJsonObjectControlRecordCodec;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
import java.util.EnumMap;
/** Side-effect-free local descriptor; filesystem access occurs only in {@link #create}. */
public final class FilesystemLocalDevProviderContribution
implements ObjectStorageProviderContribution {
@Override
public String providerType() {
return "filesystem-local-dev";
}
@Override
public ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings) {
if (settings.root() == null
|| settings.maximumObjectBytes() == null
|| settings.chunkBytes() == null) {
throw new IllegalArgumentException("filesystem-local-dev settings are incomplete");
}
String actualVersion = "jdk-" + Runtime.version().feature();
if (!actualVersion.equals(settings.version())) {
throw new IllegalArgumentException("filesystem-local-dev version does not match runtime");
}
long maximumBytes = settings.maximumObjectBytes().toBytes();
long rawChunkBytes = settings.chunkBytes().toBytes();
if (rawChunkBytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("filesystem-local-dev chunk bound is invalid");
}
return new ObjectStorageProviderDescriptor(
providerType(), actualVersion, maximumBytes, (int) rawChunkBytes, capabilitySupport());
}
@Override
public SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider) {
if (provider.localRoot() == null) {
throw new IllegalArgumentException("filesystem-local-dev root is required");
}
LocalObjectPathGuard pathGuard = new LocalObjectPathGuard(provider.localRoot());
LocalDevObjectStorageProvider dataProvider =
new LocalDevObjectStorageProvider(
provider.localRoot(),
provider.maximumObjectBytes(),
provider.chunkBytes(),
LocalObjectStreamTransfer.FaultInjector.none());
LocalDevObjectControlStore controlStore =
new LocalDevObjectControlStore(pathGuard, new CanonicalJsonObjectControlRecordCodec());
return new SelectedObjectStorageProviderFactory(dataProvider, controlStore, () -> {});
}
private static EnumMap<ObjectStorageProvider.Capability, ObjectStorageProvider.Support>
capabilitySupport() {
EnumMap<ObjectStorageProvider.Capability, ObjectStorageProvider.Support> support =
new EnumMap<>(ObjectStorageProvider.Capability.class);
for (ObjectStorageProvider.Capability capability : ObjectStorageProvider.Capability.values()) {
support.put(capability, ObjectStorageProvider.Support.UNSUPPORTED);
}
support.put(
ObjectStorageProvider.Capability.IMMUTABLE_CREATE, ObjectStorageProvider.Support.SUPPORTED);
support.put(
ObjectStorageProvider.Capability.EXACT_INSPECT, ObjectStorageProvider.Support.SUPPORTED);
support.put(
ObjectStorageProvider.Capability.FULL_READ, ObjectStorageProvider.Support.SUPPORTED);
support.put(
ObjectStorageProvider.Capability.RANGE_READ, ObjectStorageProvider.Support.SUPPORTED);
support.put(
ObjectStorageProvider.Capability.CONDITIONAL_RETIREMENT,
ObjectStorageProvider.Support.SUPPORTED);
support.put(
ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION,
ObjectStorageProvider.Support.SUPPORTED);
return support;
}
}
@@ -0,0 +1,115 @@
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
import dev.caskeleton.adapter.outbound.objectstorage.control.CanonicalJsonObjectControlRecordCodec;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlRecord;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlVersion;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Single-process local create/CAS store; it deliberately does not claim multi-node linearizability.
*/
public final class LocalDevObjectControlStore implements ObjectControlStore {
private final LocalObjectPathGuard pathGuard;
private final CanonicalJsonObjectControlRecordCodec codec;
private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
public LocalDevObjectControlStore(
LocalObjectPathGuard pathGuard, CanonicalJsonObjectControlRecordCodec codec) {
this.pathGuard = java.util.Objects.requireNonNull(pathGuard, "pathGuard must be non-null");
this.codec = java.util.Objects.requireNonNull(codec, "codec must be non-null");
}
@Override
public Optional<StoredControlRecord> read(String key) {
Path target = pathGuard.resolveControl(key, false);
if (!Files.isRegularFile(target, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
return Optional.empty();
}
try {
byte[] encoded = Files.readAllBytes(target);
ObjectControlRecord record = codec.decode(encoded);
return Optional.of(
new StoredControlRecord(ObjectControlVersion.of(codec.sha256Hex(encoded)), record));
} catch (IOException exception) {
throw new IllegalStateException("local control read failed", exception);
}
}
@Override
public StoredControlRecord create(String key, ObjectControlRecord record) {
Path target = pathGuard.resolveControl(key, true);
byte[] encoded = codec.encode(record);
try (FileChannel channel =
FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
writeFully(channel, encoded);
channel.force(true);
LocalObjectPathGuard.applyFilePermissions(target, false);
return new StoredControlRecord(ObjectControlVersion.of(codec.sha256Hex(encoded)), record);
} catch (FileAlreadyExistsException exception) {
throw new ObjectControlConflictException("local control record already exists");
} catch (IOException exception) {
throw new IllegalStateException("local control create failed", exception);
}
}
@Override
public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) {
Object lock = locks.computeIfAbsent(key, ignored -> new Object());
synchronized (lock) {
StoredControlRecord current =
read(key)
.orElseThrow(() -> new ObjectControlConflictException("control record is absent"));
if (!current.version().equals(mutation.expectedVersion())) {
throw new ObjectControlConflictException("local control version conflict");
}
byte[] replacement = codec.encode(mutation.replacement());
Path target = pathGuard.resolveControl(key, false);
Path temporary = null;
try {
temporary = pathGuard.createTemporaryFile();
try (FileChannel channel =
FileChannel.open(
temporary, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
writeFully(channel, replacement);
channel.force(true);
}
Files.move(
temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
LocalObjectPathGuard.applyFilePermissions(target, false);
return new StoredControlRecord(
ObjectControlVersion.of(codec.sha256Hex(replacement)), mutation.replacement());
} catch (IOException exception) {
throw new IllegalStateException("local control CAS failed", exception);
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Report-first local reconciliation owns any bounded .tmp orphan.
}
}
}
}
}
private static void writeFully(FileChannel channel, byte[] bytes) throws IOException {
java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
if (channel.write(buffer) <= 0) {
throw new IOException("local control write made no progress");
}
}
}
}
@@ -0,0 +1,188 @@
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec;
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
import dev.caskeleton.application.objectstorage.identity.ObjectId;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Optional;
/** Immutable local data objects using staged writes and exclusive hard-link publication. */
public final class LocalDevObjectDataStore {
private final LocalObjectPathGuard pathGuard;
private final LocalObjectStreamTransfer streamTransfer;
private final long maximumObjectBytes;
public LocalDevObjectDataStore(
LocalObjectPathGuard pathGuard,
LocalObjectStreamTransfer streamTransfer,
long maximumObjectBytes) {
this.pathGuard = java.util.Objects.requireNonNull(pathGuard, "pathGuard must be non-null");
this.streamTransfer =
java.util.Objects.requireNonNull(streamTransfer, "streamTransfer must be non-null");
if (maximumObjectBytes < 1) {
throw new IllegalArgumentException("maximumObjectBytes must be positive");
}
this.maximumObjectBytes = maximumObjectBytes;
}
public ObjectStorageProvider.StoredData create(
ObjectStorageProviderOperation operation, ObjectContentProducer producer) {
Path target = pathGuard.resolveData(operation.canonicalDataKey(), true);
Path temporary = null;
try {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw conflict();
}
temporary = pathGuard.createTemporaryFile();
ObjectContentIdentity identity = streamTransfer.write(temporary, operation, producer);
try {
Files.createLink(target, temporary);
} catch (FileAlreadyExistsException exception) {
throw conflict();
} catch (UnsupportedOperationException exception) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.UNSUPPORTED,
"local filesystem cannot prove immutable create",
exception);
}
LocalObjectPathGuard.applyFilePermissions(target, false);
return stored(operation.canonicalDataKey(), identity);
} catch (ObjectStorageProviderException exception) {
throw exception;
} catch (IOException exception) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.IO_FAILURE,
"local immutable create failed",
exception);
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// A bounded local-dev orphan remains under .tmp for report-first startup reconciliation.
}
}
}
}
public Optional<ObjectStorageProvider.StoredData> inspect(String canonicalDataKey) {
Path source = pathGuard.resolveData(canonicalDataKey, false);
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
return Optional.empty();
}
try {
MessageDigest digest = sha256();
long size = 0;
ByteBuffer buffer = ByteBuffer.allocate(64 * 1024);
try (FileChannel channel = FileChannel.open(source, StandardOpenOption.READ)) {
for (int count = channel.read(buffer); count >= 0; count = channel.read(buffer)) {
if (count == 0) {
continue;
}
size = Math.addExact(size, count);
if (size > maximumObjectBytes) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.CORRUPT_DATA,
"local object exceeds its configured bound");
}
digest.update(buffer.array(), 0, count);
buffer.clear();
}
}
ObjectContentIdentity identity =
new ObjectContentIdentity(
size,
ObjectDigest.of(
dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256,
Base64.getEncoder().encodeToString(digest.digest())));
return Optional.of(stored(canonicalDataKey, identity));
} catch (ObjectStorageProviderException exception) {
throw exception;
} catch (IOException | ArithmeticException exception) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.IO_FAILURE,
"local exact inspect failed",
exception);
}
}
public void transfer(
ObjectStorageProvider.ReadOperation operation, ObjectContentConsumer consumer) {
ObjectStorageProvider.StoredData actual =
inspect(operation.canonicalDataKey())
.orElseThrow(
() ->
new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.NOT_FOUND,
"local object is absent"));
if (!actual.exactVersion().equals(operation.exactVersion())
|| !actual.contentIdentity().equals(operation.contentIdentity())) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.PRECONDITION_FAILED,
"local read precondition failed");
}
Path source = pathGuard.resolveData(operation.canonicalDataKey(), false);
streamTransfer.read(source, operation, consumer);
}
public boolean retire(ObjectStorageProvider.RetireOperation operation) {
ObjectVersionToken actualVersion = version(operation.canonicalDataKey());
if (!actualVersion.equals(operation.exactVersion())) {
return false;
}
Path target = pathGuard.resolveData(operation.canonicalDataKey(), false);
try {
return Files.deleteIfExists(target);
} catch (IOException exception) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.IO_FAILURE,
"local conditional retirement failed",
exception);
}
}
private static ObjectStorageProvider.StoredData stored(
String canonicalDataKey, ObjectContentIdentity identity) {
return new ObjectStorageProvider.StoredData(
canonicalDataKey, identity, version(canonicalDataKey));
}
private static ObjectVersionToken version(String canonicalDataKey) {
ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey);
String[] segments = canonicalDataKey.split("/", -1);
return ObjectHandleCodec.version(
ObjectRouteToken.parse(segments[2]), ObjectId.parse(segments[4]));
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static ObjectStorageProviderException conflict() {
return new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.CONFLICT, "local immutable object already exists");
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.Optional;
/** Bounded single-process filesystem provider for explicit local-development profiles only. */
public final class LocalDevObjectStorageProvider implements ObjectStorageProvider {
private final ObjectStorageProviderDescriptor descriptor;
private final LocalDevObjectDataStore dataStore;
public LocalDevObjectStorageProvider(
Path root,
long maximumObjectBytes,
int maximumChunkBytes,
LocalObjectStreamTransfer.FaultInjector faultInjector) {
LocalObjectPathGuard pathGuard = new LocalObjectPathGuard(root);
dataStore =
new LocalDevObjectDataStore(
pathGuard, new LocalObjectStreamTransfer(faultInjector), maximumObjectBytes);
descriptor =
new ObjectStorageProviderDescriptor(
"filesystem-local-dev",
"jdk-" + Runtime.version().feature(),
maximumObjectBytes,
maximumChunkBytes,
capabilitySupport());
}
@Override
public ObjectStorageProviderDescriptor descriptor() {
return descriptor;
}
@Override
public StoredData create(
ObjectStorageProviderOperation operation, ObjectContentProducer producer) {
requireWithinDescriptor(operation);
return dataStore.create(operation, producer);
}
@Override
public Optional<StoredData> inspect(String canonicalDataKey) {
return dataStore.inspect(canonicalDataKey);
}
@Override
public void transfer(ReadOperation operation, ObjectContentConsumer consumer) {
if (operation.maximumChunkBytes() > descriptor.maximumChunkBytes()
|| operation.maximumDeliveredBytes() > descriptor.maximumObjectBytes()) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.UNSUPPORTED, "read exceeds local provider bounds");
}
dataStore.transfer(operation, consumer);
}
@Override
public boolean retire(RetireOperation operation) {
return dataStore.retire(operation);
}
@Override
public Resolution resolveCreate(ObjectStorageProviderOperation operation) {
try {
Optional<StoredData> existing = inspect(operation.canonicalDataKey());
if (existing.isEmpty()) {
return Resolution.NOT_APPLIED;
}
return existing.get().contentIdentity().equals(operation.expectedContentIdentity())
? Resolution.APPLIED
: Resolution.PRECONDITION_FAILED;
} catch (ObjectStorageProviderException exception) {
return Resolution.INDETERMINATE;
}
}
private void requireWithinDescriptor(ObjectStorageProviderOperation operation) {
if (operation.maximumObjectBytes() > descriptor.maximumObjectBytes()
|| operation.maximumChunkBytes() > descriptor.maximumChunkBytes()) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.UNSUPPORTED,
"operation exceeds local provider bounds");
}
}
private static EnumMap<Capability, Support> capabilitySupport() {
EnumMap<Capability, Support> support = new EnumMap<>(Capability.class);
for (Capability capability : Capability.values()) {
support.put(capability, Support.UNSUPPORTED);
}
support.put(Capability.IMMUTABLE_CREATE, Support.SUPPORTED);
support.put(Capability.EXACT_INSPECT, Support.SUPPORTED);
support.put(Capability.FULL_READ, Support.SUPPORTED);
support.put(Capability.RANGE_READ, Support.SUPPORTED);
support.put(Capability.CONDITIONAL_RETIREMENT, Support.SUPPORTED);
support.put(Capability.RESPONSE_LOSS_RESOLUTION, Support.SUPPORTED);
return support;
}
}
@@ -0,0 +1,158 @@
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Objects;
/** Strict root confinement and symlink rejection for the local-development provider. */
public final class LocalObjectPathGuard {
private static final int MAXIMUM_CONTROL_KEY_LENGTH = 512;
private final Path root;
public LocalObjectPathGuard(Path configuredRoot) {
Objects.requireNonNull(configuredRoot, "configuredRoot must be non-null");
root = configuredRoot.toAbsolutePath().normalize();
try {
if (Files.isSymbolicLink(root)) {
throw new IllegalArgumentException("local object-storage root cannot be a symbolic link");
}
Files.createDirectories(root);
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalArgumentException("local object-storage root must be a directory");
}
applyDirectoryPermissions(root);
} catch (IOException exception) {
throw new IllegalArgumentException("local object-storage root is unavailable", exception);
}
}
public Path resolveData(String canonicalDataKey, boolean createParents) {
ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey);
return resolve(canonicalDataKey, createParents);
}
public Path resolveControl(String canonicalControlKey, boolean createParents) {
requireCanonicalControlKey(canonicalControlKey);
/*
* Object stores permit both an object named "reference" and another named
* "reference/lifecycle". A filesystem does not. Keep the provider-neutral logical key exact
* while giving every local control record a leaf-only physical filename.
*/
return resolve(canonicalControlKey + ".record", createParents);
}
public Path createTemporaryFile() throws IOException {
Path temporaryRoot = resolve(".tmp/object", true).getParent();
Path temporary = Files.createTempFile(temporaryRoot, "pending-", ".tmp");
applyFilePermissions(temporary, true);
return temporary;
}
public Path root() {
return root;
}
private Path resolve(String canonicalKey, boolean createParents) {
Path target = root.resolve(canonicalKey).normalize();
if (!target.startsWith(root) || target.equals(root)) {
throw new IllegalArgumentException("local object key escapes the configured root");
}
Path parent = target.getParent();
try {
if (createParents) {
createParentsWithoutLinks(parent);
}
rejectExistingLinks(parent);
if (Files.isSymbolicLink(target)) {
throw new IllegalArgumentException("local object target cannot be a symbolic link");
}
return target;
} catch (IOException exception) {
throw new IllegalArgumentException("local object path cannot be resolved safely", exception);
}
}
private void createParentsWithoutLinks(Path parent) throws IOException {
Path current = root;
for (Path segment : root.relativize(parent)) {
current = current.resolve(segment);
if (Files.isSymbolicLink(current)) {
throw new IllegalArgumentException("local object parent cannot be a symbolic link");
}
if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
try {
Files.createDirectory(current);
applyDirectoryPermissions(current);
} catch (java.nio.file.FileAlreadyExistsException race) {
if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(current)) {
throw new IllegalArgumentException("local object parent must be a directory", race);
}
}
} else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalArgumentException("local object parent must be a directory");
}
}
}
private void rejectExistingLinks(Path parent) throws IOException {
Path current = root;
for (Path segment : root.relativize(parent)) {
current = current.resolve(segment);
if (Files.isSymbolicLink(current)) {
throw new IllegalArgumentException("local object parent cannot be a symbolic link");
}
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalArgumentException("local object parent must be a directory");
}
}
}
private static void requireCanonicalControlKey(String key) {
if (key == null
|| key.length() > MAXIMUM_CONTROL_KEY_LENGTH
|| !key.startsWith("control/v1/")
|| key.contains("//")
|| key.contains("/./")
|| key.contains("/../")
|| key.startsWith("/")
|| key.endsWith("/")
|| !key.matches("[a-z0-9._/-]+")) {
throw new IllegalArgumentException("control key is not canonical");
}
for (String segment : key.split("/", -1)) {
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
throw new IllegalArgumentException("control key is not canonical");
}
}
}
static void applyFilePermissions(Path file, boolean writable) throws IOException {
if (Files.getFileStore(file).supportsFileAttributeView("posix")) {
EnumSet<PosixFilePermission> permissions = EnumSet.of(PosixFilePermission.OWNER_READ);
if (writable) {
permissions.add(PosixFilePermission.OWNER_WRITE);
}
Files.setPosixFilePermissions(file, permissions);
}
}
private static void applyDirectoryPermissions(Path directory) throws IOException {
if (Files.getFileStore(directory).supportsFileAttributeView("posix")) {
Files.setPosixFilePermissions(
directory,
EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE));
}
}
}
@@ -0,0 +1,193 @@
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
import dev.caskeleton.application.objectstorage.content.ObjectChunkReadException;
import dev.caskeleton.application.objectstorage.content.ObjectChunkSink;
import dev.caskeleton.application.objectstorage.content.ObjectChunkSource;
import dev.caskeleton.application.objectstorage.content.ObjectChunkWriteException;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumptionException;
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext;
import dev.caskeleton.application.objectstorage.content.ObjectContentProductionException;
import dev.caskeleton.application.objectstorage.content.ObjectContentReadContext;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Objects;
/** Bounded synchronous streaming bridge with callback-scope invalidation and SHA-256 evidence. */
public final class LocalObjectStreamTransfer {
private final FaultInjector faultInjector;
public LocalObjectStreamTransfer(FaultInjector faultInjector) {
this.faultInjector = Objects.requireNonNull(faultInjector, "faultInjector must be non-null");
}
public ObjectContentIdentity write(
Path temporary, ObjectStorageProviderOperation operation, ObjectContentProducer producer)
throws IOException {
Objects.requireNonNull(producer, "producer must be non-null");
requireActive(operation.budget(), operation.cancellation().isCancelled());
MessageDigest digest = sha256();
long[] written = {0};
ObjectContentProductionContext context =
ObjectContentProductionContext.open(
operation.budget(), operation.cancellation(), operation.maximumChunkBytes());
try (FileChannel channel =
FileChannel.open(
temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
ObjectChunkSink sink =
ObjectChunkSink.scoped(
context,
(bytes, offset, length) -> {
requireActive(operation.budget(), operation.cancellation().isCancelled());
long next;
try {
next = Math.addExact(written[0], length);
} catch (ArithmeticException exception) {
throw new ObjectChunkWriteException("content length overflows", exception);
}
if (next > operation.maximumObjectBytes()
|| next > operation.expectedContentIdentity().exactLength()) {
throw new ObjectChunkWriteException("producer exceeded the bounded content size");
}
ByteBuffer buffer = ByteBuffer.wrap(bytes, offset, length);
try {
while (buffer.hasRemaining()) {
int count = channel.write(buffer);
if (count <= 0) {
throw new IOException("local write made no progress");
}
}
} catch (IOException exception) {
throw new ObjectChunkWriteException("local data write failed", exception);
}
digest.update(bytes, offset, length);
written[0] = next;
});
try {
producer.produce(context, sink);
} catch (ObjectContentProductionException | RuntimeException exception) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.IO_FAILURE,
"bounded content production failed",
exception);
} finally {
context.invalidate();
}
channel.force(true);
faultInjector.afterWrite(written[0], temporary);
}
ObjectContentIdentity observed =
new ObjectContentIdentity(
written[0],
ObjectDigest.of(
dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256,
Base64.getEncoder().encodeToString(digest.digest())));
if (!observed.equals(operation.expectedContentIdentity())) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.CONTENT_MISMATCH,
"produced content does not match expected identity");
}
return observed;
}
public void read(
Path source, ObjectStorageProvider.ReadOperation operation, ObjectContentConsumer consumer) {
Objects.requireNonNull(consumer, "consumer must be non-null");
requireActive(operation.budget(), operation.cancellation().isCancelled());
ObjectContentReadContext context =
ObjectContentReadContext.open(
operation.budget(),
operation.cancellation(),
operation.maximumChunkBytes(),
operation.contentIdentity(),
operation.exactVersion(),
operation.range());
try (FileChannel channel = FileChannel.open(source, StandardOpenOption.READ)) {
channel.position(operation.range().offset());
long[] remaining = {operation.range().length()};
ObjectChunkSource chunkSource =
ObjectChunkSource.scoped(
context,
(destination, offset, length) -> {
requireActive(operation.budget(), operation.cancellation().isCancelled());
if (remaining[0] == 0) {
return ObjectChunkSource.EOF;
}
int allowed = (int) Math.min(length, remaining[0]);
try {
int count = channel.read(ByteBuffer.wrap(destination, offset, allowed));
if (count < 0) {
throw new ObjectChunkReadException("local data ended before the exact range");
}
remaining[0] -= count;
return count;
} catch (IOException exception) {
throw new ObjectChunkReadException("local data read failed", exception);
}
});
try {
consumer.consume(context, chunkSource);
} catch (ObjectContentConsumptionException | RuntimeException exception) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.IO_FAILURE,
"bounded content consumption failed",
exception);
} finally {
context.invalidate();
}
if (remaining[0] != 0) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.PRECONDITION_FAILED,
"consumer did not read the exact requested range");
}
} catch (IOException exception) {
context.invalidate();
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.IO_FAILURE, "local data read failed", exception);
}
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
private static void requireActive(
dev.caskeleton.application.outbound.CallBudget budget, boolean cancelled) {
if (cancelled) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.CANCELLED, "object transfer is cancelled");
}
if (budget.isExpiredAt(System.nanoTime())) {
throw new ObjectStorageProviderException(
ObjectStorageProviderException.Failure.DEADLINE_EXCEEDED,
"object transfer deadline is exceeded");
}
}
@FunctionalInterface
public interface FaultInjector {
void afterWrite(long writtenBytes, Path temporaryTarget) throws IOException;
static FaultInjector none() {
return (writtenBytes, temporaryTarget) -> {};
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
/** Direct grant state, independent from publication and multipart state. */
public enum DirectGrantSessionState {
SESSION_RESERVED,
GRANT_PREPARED,
GRANT_ISSUED,
UPLOAD_VERIFICATION_IN_PROGRESS,
DATA_UPLOADED,
EXPIRED,
ABORTED,
FAILED
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
final class KernelValueSupport {
private KernelValueSupport() {}
static String requireRevision(String label, String value) {
if (value == null || value.length() > 64 || !value.matches("[a-z0-9][a-z0-9_-]*")) {
throw new IllegalArgumentException(label + " is not canonical");
}
return value;
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
/** Multipart provider session state with explicit terminal branches. */
public enum MultipartUploadState {
SESSION_RESERVED,
INITIATE_IN_PROGRESS,
ACCEPTING_PARTS,
COMPLETE_IN_PROGRESS,
ABORT_IN_PROGRESS,
COMPLETED,
ABORTED,
EXPIRED,
FAILED,
CORRUPT
}

Some files were not shown because too many files have changed in this diff Show More