feat: add object storage production capability

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:03 +09:00
parent b3add0162d
commit f0a6d1c8c8
427 changed files with 39930 additions and 699 deletions
+28
View File
@@ -143,3 +143,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=19
readonly EXPECTED_GATE_COUNT=23
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,92 @@
# 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 V7 dual-read schema, 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
The canonical vault
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` is absent in this environment. Therefore the
required branch note and derived raw documents could not be written. No non-canonical substitute
was used.
@@ -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
File diff suppressed because it is too large Load Diff
@@ -949,6 +949,13 @@ and durable interfaces are explicit.
### 13.3 Object storage
The authoritative implementation-level design for this capability is
[Object Storage Production Capability Deep Design](2026-07-28-objectstorage-production-capability-design.md).
Its ordered REDGREEN execution batches and promotion gates are in the
[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md).
This subsection is only the cross-capability baseline; the dedicated design governs when details
differ.
Replace whole-object `byte[]` as the only path with:
- streaming upload/download and range reads;
File diff suppressed because it is too large Load Diff
+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,280 @@
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
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
/** Immutable identifier for one exact provider and physical namespace binding. */
public record ObjectBindingRevision(String value) {
public ObjectBindingRevision {
value = KernelValueSupport.requireRevision("binding revision", value);
}
public static ObjectBindingRevision of(String value) {
return new ObjectBindingRevision(value);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
/** Evidence certainty for one exact pending external effect. */
public enum ObjectEffectCertainty {
NOT_SENT,
SENT,
CONFIRMED,
INDETERMINATE
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectStagedObjectRecord;
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
import java.util.Objects;
/** Exact-version provider evidence verifier for one unpublished stage. */
public final class ObjectIntegrityVerificationService {
public ObjectStagedObjectRecord verify(
ObjectStagedObjectRecord stage, ObjectStorageProvider provider, java.time.Instant now) {
Objects.requireNonNull(stage, "stage must be non-null");
Objects.requireNonNull(provider, "provider must be non-null");
ObjectStorageProvider.StoredData stored =
provider
.inspect(stage.canonicalDataKey())
.orElseThrow(() -> new IllegalStateException("staged provider object is absent"));
if (!stored.exactVersion().equals(stage.exactVersion())
|| !stored.contentIdentity().equals(stage.contentIdentity())) {
throw new IllegalStateException("staged provider evidence conflicts with exact identity");
}
return stage.verified(now);
}
}
@@ -0,0 +1,150 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch;
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Finite replay and compaction boundary that prevents expired operation identity reuse. */
public record ObjectOperationEpochRecord(
ObjectOperationEpoch epoch,
ObjectOperationEpochState state,
Instant createdAt,
Duration maximumAge,
long maximumOperations,
long maximumControlBytes,
int maximumReadableOldEpochs,
Duration minimumReplayHorizon,
Instant sealedAt,
boolean rejectionRecord) {
public ObjectOperationEpochRecord {
Objects.requireNonNull(epoch, "epoch must be non-null");
Objects.requireNonNull(state, "state must be non-null");
Objects.requireNonNull(createdAt, "createdAt must be non-null");
requirePositive(maximumAge, "maximumAge");
if (maximumOperations < 1 || maximumControlBytes < 1 || maximumReadableOldEpochs < 1) {
throw new IllegalArgumentException("epoch bounds must be positive");
}
requirePositive(minimumReplayHorizon, "minimumReplayHorizon");
if ((state == ObjectOperationEpochState.SEALED || state == ObjectOperationEpochState.COMPACTED)
!= (sealedAt != null)) {
throw new IllegalArgumentException("sealed state and sealedAt must agree");
}
if (state == ObjectOperationEpochState.COMPACTED && !rejectionRecord) {
throw new IllegalArgumentException("compacted epoch requires a rejection record");
}
}
public static ObjectOperationEpochRecord warm(
ObjectOperationEpoch epoch,
Instant createdAt,
Duration maximumAge,
long maximumOperations,
long maximumControlBytes,
int maximumReadableOldEpochs,
Duration minimumReplayHorizon,
Set<ObjectOperationEpoch> previouslyIssued) {
Objects.requireNonNull(previouslyIssued, "previouslyIssued must be non-null");
if (previouslyIssued.contains(epoch)) {
throw new IllegalArgumentException("operation epoch token was already issued");
}
return new ObjectOperationEpochRecord(
epoch,
ObjectOperationEpochState.WARM,
createdAt,
maximumAge,
maximumOperations,
maximumControlBytes,
maximumReadableOldEpochs,
minimumReplayHorizon,
null,
false);
}
public ObjectOperationEpochRecord activate() {
return transition(
ObjectOperationEpochState.WARM, ObjectOperationEpochState.ACTIVE, null, false);
}
public ObjectOperationEpochRecord startDraining() {
return transition(
ObjectOperationEpochState.ACTIVE, ObjectOperationEpochState.DRAINING, null, false);
}
public ObjectOperationEpochRecord seal(Instant sealedAt, boolean hasUnresolvedOperation) {
if (hasUnresolvedOperation) {
throw new IllegalStateException("unresolved operation prevents epoch sealing");
}
Objects.requireNonNull(sealedAt, "sealedAt must be non-null");
if (sealedAt.isBefore(createdAt)) {
throw new IllegalArgumentException("sealedAt precedes createdAt");
}
return transition(
ObjectOperationEpochState.DRAINING, ObjectOperationEpochState.SEALED, sealedAt, true);
}
public ObjectOperationEpochRecord compact(Instant now) {
requireState(ObjectOperationEpochState.SEALED);
Objects.requireNonNull(now, "now must be non-null");
Instant earliestCompaction = sealedAt.plus(minimumReplayHorizon);
if (now.isBefore(earliestCompaction)) {
throw new IllegalStateException("minimum replay horizon has not elapsed");
}
return new ObjectOperationEpochRecord(
epoch,
ObjectOperationEpochState.COMPACTED,
createdAt,
maximumAge,
maximumOperations,
maximumControlBytes,
maximumReadableOldEpochs,
minimumReplayHorizon,
sealedAt,
true);
}
public Optional<ObjectOperationError> admissionRejection() {
if (state == ObjectOperationEpochState.SEALED || state == ObjectOperationEpochState.COMPACTED) {
return Optional.of(ObjectOperationError.OPERATION_EXPIRED);
}
return state == ObjectOperationEpochState.ACTIVE
? Optional.empty()
: Optional.of(ObjectOperationError.OPERATION_EPOCH_NOT_ACTIVE);
}
private ObjectOperationEpochRecord transition(
ObjectOperationEpochState expected,
ObjectOperationEpochState next,
Instant nextSealedAt,
boolean nextRejectionRecord) {
requireState(expected);
return new ObjectOperationEpochRecord(
epoch,
next,
createdAt,
maximumAge,
maximumOperations,
maximumControlBytes,
maximumReadableOldEpochs,
minimumReplayHorizon,
nextSealedAt,
nextRejectionRecord);
}
private void requireState(ObjectOperationEpochState expected) {
if (state != expected) {
throw new IllegalStateException("operation epoch transition is not allowed");
}
}
private static void requirePositive(Duration value, String label) {
Objects.requireNonNull(value, label + " must be non-null");
if (value.isNegative() || value.isZero()) {
throw new IllegalArgumentException(label + " must be positive");
}
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
/** Durable bounded lifecycle of one operation-identity epoch. */
public enum ObjectOperationEpochState {
WARM,
ACTIVE,
DRAINING,
SEALED,
COMPACTED
}
@@ -0,0 +1,127 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectPolicySnapshotCodec;
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.ObjectControlStore;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import java.time.Instant;
import java.util.Objects;
/** Provider-SDK-free reservation and pending-effect kernel over exact control-store CAS. */
public final class ObjectOperationKernel {
private final ObjectControlStore controlStore;
private final CanonicalJsonObjectControlRecordCodec digestCodec =
new CanonicalJsonObjectControlRecordCodec();
public ObjectOperationKernel(ObjectControlStore controlStore) {
this.controlStore = Objects.requireNonNull(controlStore, "controlStore must be non-null");
}
public ObjectOperationKernelResult reserve(
ObjectOperationKey operationKey,
String requestFingerprint,
String operationKind,
ObjectRouteToken route,
ObjectPolicySnapshot policy,
Instant now) {
return reserve(operationKey, requestFingerprint, operationKind, route, policy, null, now);
}
public ObjectOperationKernelResult reserve(
ObjectOperationKey operationKey,
String requestFingerprint,
String operationKind,
ObjectRouteToken route,
ObjectPolicySnapshot policy,
ObjectContentIdentity expectedContentIdentity,
Instant now) {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
String key = ObjectControlKeyCodec.operation(route, operationKey);
ObjectOperationRecord candidate =
ObjectOperationRecord.reserved(
operationKey,
requestFingerprint,
operationKind,
route,
policy,
expectedContentIdentity,
digestCodec.sha256Hex(ObjectPolicySnapshotCodec.encode(policy)),
now);
try {
return new ObjectOperationKernelResult(
ObjectOperationKernelResult.Decision.RESERVED,
(ObjectOperationRecord) controlStore.create(key, candidate).record());
} catch (ObjectControlConflictException conflict) {
ObjectOperationRecord existing =
controlStore
.read(key)
.map(ObjectControlStore.StoredControlRecord::record)
.filter(ObjectOperationRecord.class::isInstance)
.map(ObjectOperationRecord.class::cast)
.orElseThrow(
() ->
new ObjectControlConflictException(
"operation reservation conflicts with unreadable evidence"));
if (!existing.requestFingerprint().equals(requestFingerprint)) {
return new ObjectOperationKernelResult(
ObjectOperationKernelResult.Decision.CONFLICT, existing);
}
return new ObjectOperationKernelResult(
existing.terminal()
? ObjectOperationKernelResult.Decision.REPLAY_TERMINAL
: ObjectOperationKernelResult.Decision.REPLAY_NON_TERMINAL,
existing);
}
}
public ObjectOperationRecord markEffectSent(
ObjectOperationRecord current, PendingObjectEffect effect) {
return replace(current, current.withPendingEffect(effect, current.updatedAt()));
}
public ObjectOperationRecord markResponseLost(ObjectOperationRecord current) {
return replace(
current,
current.withEffectCertainty(ObjectEffectCertainty.INDETERMINATE, current.updatedAt()));
}
public ObjectOperationRecord confirmEffect(ObjectOperationRecord current, Instant now) {
return replace(current, current.withEffectCertainty(ObjectEffectCertainty.CONFIRMED, now));
}
public ObjectOperationRecord advancePublication(
ObjectOperationRecord current,
PublicationOperationState next,
ObjectContentIdentity observedContentIdentity,
Instant now) {
return replace(current, current.advancePublication(next, observedContentIdentity, now));
}
public ObjectOperationRecord terminate(
ObjectOperationRecord current, PublicationOperationState terminal, Instant now) {
return replace(current, current.terminate(terminal, now));
}
private ObjectOperationRecord replace(
ObjectOperationRecord expected, ObjectOperationRecord replacement) {
String key = ObjectControlKeyCodec.operation(expected.route(), expected.operationKey());
ObjectControlStore.StoredControlRecord stored =
controlStore
.read(key)
.orElseThrow(() -> new ObjectControlConflictException("operation record is absent"));
if (!stored.record().equals(expected)) {
throw new ObjectControlConflictException("operation record is stale");
}
return (ObjectOperationRecord)
controlStore
.compareAndSet(key, new ObjectControlMutation(stored.version(), replacement))
.record();
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord;
import java.util.Objects;
/** Deterministic reserve/replay decision for one stable operation identity. */
public record ObjectOperationKernelResult(Decision decision, ObjectOperationRecord record) {
public ObjectOperationKernelResult {
Objects.requireNonNull(decision, "decision must be non-null");
Objects.requireNonNull(record, "record must be non-null");
}
public enum Decision {
RESERVED,
REPLAY_NON_TERMINAL,
REPLAY_TERMINAL,
CONFLICT
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord;
import java.time.Instant;
import java.util.Objects;
/**
* Applies one read-only evidence probe to an indeterminate pending effect without replaying I/O.
*/
public final class ObjectOperationResolutionService {
private final ObjectOperationKernel kernel;
public ObjectOperationResolutionService(ObjectOperationKernel kernel) {
this.kernel = Objects.requireNonNull(kernel, "kernel must be non-null");
}
public Decision resolve(ObjectOperationRecord record, EffectProbe probe, Instant observedAt) {
Objects.requireNonNull(record, "record must be non-null");
Objects.requireNonNull(probe, "probe must be non-null");
Objects.requireNonNull(observedAt, "observedAt must be non-null");
if (record.pendingEffect() == null
|| record.effectCertainty() != ObjectEffectCertainty.INDETERMINATE) {
throw new IllegalArgumentException("operation has no indeterminate pending effect");
}
Decision decision =
Objects.requireNonNull(
probe.resolve(record, record.pendingEffect()), "probe decision must be non-null");
if (decision == Decision.APPLIED) {
kernel.confirmEffect(record, observedAt);
}
return decision;
}
public enum Decision {
APPLIED,
NOT_APPLIED,
CONFLICT,
INDETERMINATE
}
/** Must inspect exact evidence only and must never issue the pending mutation. */
@FunctionalInterface
public interface EffectProbe {
Decision resolve(ObjectOperationRecord record, PendingObjectEffect effect);
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
import java.util.Set;
/** Closed transition tables shared by provider-neutral operation orchestration. */
public final class ObjectOperationStateMachine {
private ObjectOperationStateMachine() {}
public static PublicationOperationState advancePublication(
PublicationOperationState current, PublicationOperationState next) {
if (current.terminal()) {
throw invalid();
}
boolean allowed =
switch (current) {
case RESERVED -> next == PublicationOperationState.DATA_UPLOAD_IN_PROGRESS;
case DATA_UPLOAD_IN_PROGRESS -> next == PublicationOperationState.DATA_UPLOADED;
case DATA_UPLOADED -> next == PublicationOperationState.INTEGRITY_VERIFIED;
case INTEGRITY_VERIFIED ->
Set.of(
PublicationOperationState.SCAN_PENDING,
PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS)
.contains(next);
case SCAN_PENDING ->
Set.of(PublicationOperationState.SCAN_CLEAN, PublicationOperationState.QUARANTINED)
.contains(next);
case SCAN_CLEAN -> next == PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS;
case REFERENCE_PUBLISH_IN_PROGRESS -> next == PublicationOperationState.PUBLISHED;
default -> false;
};
return requireAllowed(next, allowed);
}
public static ScanOperationState advanceScan(
ScanOperationState current, ScanOperationState next) {
boolean allowed =
switch (current) {
case NOT_REQUIRED -> false;
case PENDING ->
Set.of(
ScanOperationState.CLEAN,
ScanOperationState.MALICIOUS,
ScanOperationState.INDETERMINATE)
.contains(next);
case INDETERMINATE -> next == ScanOperationState.PENDING;
case CLEAN, MALICIOUS -> false;
};
return requireAllowed(next, allowed);
}
public static PublishedReferenceState advanceReference(
PublishedReferenceState current, PublishedReferenceState next) {
boolean allowed =
switch (current) {
case PUBLISHED -> next == PublishedReferenceState.RETIREMENT_PENDING;
case RETIREMENT_PENDING -> next == PublishedReferenceState.RETIRED;
case RETIRED -> next == PublishedReferenceState.PURGE_ELIGIBLE;
case PURGE_ELIGIBLE -> next == PublishedReferenceState.PURGED;
case PURGED -> false;
};
return requireAllowed(next, allowed);
}
public static DirectGrantSessionState advanceDirectGrant(
DirectGrantSessionState current, DirectGrantSessionState next) {
boolean currentTerminal =
Set.of(
DirectGrantSessionState.DATA_UPLOADED,
DirectGrantSessionState.EXPIRED,
DirectGrantSessionState.ABORTED,
DirectGrantSessionState.FAILED)
.contains(current);
boolean branch =
Set.of(
DirectGrantSessionState.EXPIRED,
DirectGrantSessionState.ABORTED,
DirectGrantSessionState.FAILED)
.contains(next);
boolean allowed =
!currentTerminal
&& (branch
|| switch (current) {
case SESSION_RESERVED -> next == DirectGrantSessionState.GRANT_PREPARED;
case GRANT_PREPARED -> next == DirectGrantSessionState.GRANT_ISSUED;
case GRANT_ISSUED ->
next == DirectGrantSessionState.UPLOAD_VERIFICATION_IN_PROGRESS;
case UPLOAD_VERIFICATION_IN_PROGRESS ->
next == DirectGrantSessionState.DATA_UPLOADED;
default -> false;
});
return requireAllowed(next, allowed);
}
public static MultipartUploadState advanceMultipart(
MultipartUploadState current, MultipartUploadState next) {
boolean currentTerminal =
Set.of(
MultipartUploadState.COMPLETED,
MultipartUploadState.ABORTED,
MultipartUploadState.EXPIRED,
MultipartUploadState.FAILED,
MultipartUploadState.CORRUPT)
.contains(current);
boolean branch =
Set.of(
MultipartUploadState.ABORTED,
MultipartUploadState.EXPIRED,
MultipartUploadState.FAILED,
MultipartUploadState.CORRUPT)
.contains(next);
boolean allowed =
!currentTerminal
&& (branch
|| switch (current) {
case SESSION_RESERVED -> next == MultipartUploadState.INITIATE_IN_PROGRESS;
case INITIATE_IN_PROGRESS -> next == MultipartUploadState.ACCEPTING_PARTS;
case ACCEPTING_PARTS -> next == MultipartUploadState.COMPLETE_IN_PROGRESS;
case COMPLETE_IN_PROGRESS -> next == MultipartUploadState.COMPLETED;
default -> false;
});
return requireAllowed(next, allowed);
}
public static long requireNextRevision(long current, long next) {
if (current < 0 || next != current + 1) {
throw invalid();
}
return next;
}
private static <T> T requireAllowed(T next, boolean allowed) {
if (!allowed) {
throw invalid();
}
return next;
}
private static IllegalStateException invalid() {
return new IllegalStateException("object-storage state transition is not allowed");
}
}

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