diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index e68b3a6..f5cca2f 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -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 diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index 8a5fc44..1d9310e 100644 --- a/.github/scripts/verify-gate-matrix.sh +++ b/.github/scripts/verify-gate-matrix.sh @@ -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 diff --git a/.github/workflows/object-storage-qualification.yml b/.github/workflows/object-storage-qualification.yml new file mode 100644 index 0000000..5be0b4c --- /dev/null +++ b/.github/workflows/object-storage-qualification.yml @@ -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 diff --git a/docs/evidence/object-storage/batch-a-checkpoint.md b/docs/evidence/object-storage/batch-a-checkpoint.md new file mode 100644 index 0000000..39a7f5a --- /dev/null +++ b/docs/evidence/object-storage/batch-a-checkpoint.md @@ -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. diff --git a/docs/evidence/object-storage/batch-b-checkpoint.md b/docs/evidence/object-storage/batch-b-checkpoint.md new file mode 100644 index 0000000..9042f72 --- /dev/null +++ b/docs/evidence/object-storage/batch-b-checkpoint.md @@ -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. diff --git a/docs/evidence/object-storage/batch-c-progress.md b/docs/evidence/object-storage/batch-c-progress.md new file mode 100644 index 0000000..5ed46ef --- /dev/null +++ b/docs/evidence/object-storage/batch-c-progress.md @@ -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. diff --git a/docs/evidence/object-storage/batch-d-checkpoint.md b/docs/evidence/object-storage/batch-d-checkpoint.md new file mode 100644 index 0000000..a4fb95f --- /dev/null +++ b/docs/evidence/object-storage/batch-d-checkpoint.md @@ -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 20–24 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. diff --git a/docs/evidence/object-storage/batch-e-pause-checkpoint.md b/docs/evidence/object-storage/batch-e-pause-checkpoint.md new file mode 100644 index 0000000..48f87bb --- /dev/null +++ b/docs/evidence/object-storage/batch-e-pause-checkpoint.md @@ -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 25–30 (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. diff --git a/docs/evidence/object-storage/phase-0-inventory.md b/docs/evidence/object-storage/phase-0-inventory.md new file mode 100644 index 0000000..9220594 --- /dev/null +++ b/docs/evidence/object-storage/phase-0-inventory.md @@ -0,0 +1,73 @@ +# Object Storage Phase 0 Inventory + +- Captured: 2026-07-28 +- Branch: `codex/objectstorage-production-capability` +- Scope: repository-local source, tests, configuration, migrations, and documentation +- Evidence grade: repository-local only; deployed data, broker subscribers, and external REST + consumers were not inspected + +## Commands + +```bash +rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \ + src docs +rg -n 'image_key|posters/.*/image' src/sample-portfolio +rg -n 'poster\.image-attached|StoredObjectResponse|PosterResponse|imageKey' \ + src/sample-portfolio docs +``` + +The commands completed successfully in the isolated worktree. Results are classified below. +Documentation hits in the Object Storage design/plan describe the migration and are not runtime +consumers. The `s3://bucket/key-1` fixture in +`IdempotencyStoreAdapterTest` belongs to the generic idempotency response-reference test and is not +an Object Storage legacy-port consumer. + +## Repository-local runtime inventory + +| Contract/data | Producer | Repository-local consumers | Classification | +| --- | --- | --- | --- | +| `ObjectStoragePort` / `StoredObject` | `application-core/application/storage` | filesystem and S3 adapters, `UploadPosterImageUseCase`, `PosterController`/`PosterWebMapper` | legacy runtime contract | +| `ca-skeleton.objectstorage.*` | `ObjectStorageSettings` / `ObjectStorageConfig` | sample runtime through its objectstorage runtime dependency | legacy runtime configuration | +| `file://` receipt | `FilesystemObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator | +| `s3://bucket/key` receipt | `S3ObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator | +| `/posters/{id}/image` | `PosterController` | repository tests and the generated/public HTTP contract | legacy inbound API | +| `StoredObjectResponse` | `PosterController` / `PosterWebMapper` | HTTP caller, with `key`, `size`, `contentType`, and `location` | legacy response DTO | +| `PosterResponse.imageKey` | `PosterWebMapper` | list/get/create/update/publish/archive HTTP responses | legacy general response field | +| `poster.image-attached` | `PosterEventPublisher` | no subscriber found in this repository | versionless broker event; external consumers unknown | +| `poster.image-attached.imageKey` | `PosterImageAttached` and publisher JSON | no subscriber found in this repository | raw locator-shaped event field | +| `poster.image_key` | Flyway V6, `PosterEntity`, persistence mapper | `Poster` aggregate and repository adapter | stored-data schema | +| `posters/{id}/image` key | `UploadPosterImageUseCase` | aggregate `imageKey`, event payload, DB row, HTTP response | deterministic overwriteable legacy key | + +## Executable characterization + +The following tests pin the current behavior without approving it as the target design: + +- `LegacyObjectStorageBehaviorTest` + - caller-selected keys overwrite; + - `get` returns `Optional` 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. diff --git a/docs/registries/object-storage-readiness.yaml b/docs/registries/object-storage-readiness.yaml new file mode 100644 index 0000000..cacc0a8 --- /dev/null +++ b/docs/registries/object-storage-readiness.yaml @@ -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 diff --git a/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md b/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md new file mode 100644 index 0000000..c2a8ae6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md @@ -0,0 +1,3478 @@ +# Object Storage Production Capability Implementation Plan + +- 작성일: 2026-07-28 +- 상태: Batch A–D 체크포인트 통과, Batch E 구현 중 사용자 요청으로 일시 정지, R2 미구현 +- 범위: 상세 설계 Phase 0–6; Phase 7/R3는 별도 승인 계획 +- LLM Wiki capture: 정본 vault + `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함 + +> **For agentic workers:** REQUIRED SUB-SKILLS: use +> `superpowers:subagent-driven-development` to execute independent tasks, +> `superpowers:test-driven-development` for every behavior change, and +> `superpowers:verification-before-completion` before changing any readiness claim. Track progress +> with the checkboxes in this document. Repository policy is `human-only`: do not stage, commit, +> amend, or push. + +**Goal:** Replace the caller-keyed, whole-object `byte[]` example with a framework-free, +bounded-streaming object publication capability, migrate the sample Poster workflow through a +durable database/object-storage handoff, and qualify only exact provider/card combinations for +which the required evidence exists. + +**Architecture:** `application-core` owns provider-neutral identities, content callbacks, requests, +receipts, outcomes, and narrow outbound ports under `dev.caskeleton.application.objectstorage`. +`adapter:outbound:objectstorage` owns immutable data/control namespaces, canonical codecs, +operation state machines, provider bindings, filesystem/S3 implementations, reconciliation, and +readiness evidence. The legacy `dev.caskeleton.application.storage` CRUD contract remains isolated +until the sample and stored data have migrated. `sample-portfolio` owns the business `UploadIntent`, +database attachment choreography, and public HTTP contract; the object-storage adapter never reads +the sample database. + +**Tech Stack:** Java 21, Spring Boot 4.0.0 configuration properties/autoconfiguration, Gradle +multi-module verification, JUnit 5, AssertJ, jqwik `1.9.1`, AWS SDK for Java v2 `2.30.0`, +`S3AsyncClient` with the Java Netty NIO HTTP client, Testcontainers `2.0.2`, version-pinned MinIO, +Toxiproxy, PostgreSQL/Flyway for the sample workflow, Micrometer/Actuator. + +**Authoritative design:** +[Object Storage Production Capability Deep Design](../specs/2026-07-28-objectstorage-production-capability-design.md). +When this plan and that design differ, stop and amend the design before implementing. Module edges +remain governed only by `src/config/architecture/modules.json`. + +**Scope boundary:** This is the executable master plan for design Phases 0–6. It deliberately does +not claim that all tasks belong in one pull request or release. Phase 7 multi-node/R3 work, a +provider-leaf split, a production malware-scanner implementation, and browser API product choices +outside the approved contracts require follow-up plans. Writing this plan changes no runtime +behavior and advances no readiness card. + +**Implementation checkpoint (2026-07-28):** The Batch A–B application/kernel/local-provider +baseline and the Batch C–D S3/MinIO managed/direct primitive slices are implemented and verified in +`codex/objectstorage-production-capability`. Exact MinIO qualification proved that create-only PUT +and multipart completion conditions are ignored, so its managed/direct mutation profiles remain +`UNSUPPORTED`. AWS lanes compile only and have no observed evidence. Only the exact local managed +single upload/download cards remain R1; every direct, multipart, quarantine, retention, and +production reconciliation card remains R0. The exhaustive R2 fault/race matrices named by +unchecked steps remain follow-up evidence, not implied completion. The legacy provider and Poster +consumer remain isolated behind explicit sample-local/test activation. Evidence: +[Batch A](../../evidence/object-storage/batch-a-checkpoint.md), +[Batch B](../../evidence/object-storage/batch-b-checkpoint.md), and +[Batch C](../../evidence/object-storage/batch-c-progress.md), +[Batch D](../../evidence/object-storage/batch-d-checkpoint.md). + +**LLM Wiki capture block:** 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. + +--- + +## Execution rules + +1. Execute batches in order. A later batch may start only after the preceding checkpoint passes. +2. Within a task, write the named failing test first, run the stated RED command, make the minimum + implementation, then run the same command GREEN. +3. A compilation failure counts as RED only when the missing symbol is the symbol the task is + intentionally introducing. Dependency, formatting, daemon, Docker, credential, or unrelated + compilation failures do not count. +4. Preserve the legacy port as an isolated compatibility seam. New business code must never import + `dev.caskeleton.application.storage`. +5. Do not expose AWS SDK, Spring, `Path`, bucket, raw key, provider endpoint, raw ETag, upload ID, + persistent provider locator, or inbound DTO types through `application-core`. A bounded + presigned URI exists only in the explicit transient direct-grant value, is always redacted, and + is never persisted in a receipt/control record. The only raw-locator exception is the exact, + deprecated, admin-only `dev.caskeleton.application.storage.migration` adoption seam in Task 24; + its value is bounded, redacted, never logged/serialized into a receipt, and may not be injected + into a normal business use case. +6. Do not call a producer while a database transaction is open. Do not claim that a database + rollback reverses an object mutation. +7. Unsupported provider behavior is a typed rejection or startup failure, never a fallback, + check-then-overwrite emulation, test skip, or weaker silent guarantee. +8. `filesystem-local-dev` and the pinned MinIO topology have an R1 ceiling. AWS S3 also remains + below R2 until the Phase 6 protected qualification lane passes for an exact provider, destination + profile, card, and evidence revision. +9. Run Spotless only on files changed by this plan. Do not format or rewrite unrelated dirty files. +10. At every batch boundary, update the implementation-status section in the design and this plan. + Do not mark a task complete from code inspection alone. +11. Every Batch A–F checkpoint is a meaningful-work capture boundary. Re-read the canonical LLM + Wiki instructions, update the branch note and any honest derivatives, or record the exact + canonical-vault access block in both this plan and the design before starting the next batch. + A final Task 30 capture does not replace these per-batch records. + +## Frozen implementation decisions + +These decisions translate §37 of the design into executable constraints. A row marked +**approval gate** is intentionally not delegated to an implementation task. + +| Concern | Decision for this plan | +| --- | --- | +| Application package | New contract lives under `dev.caskeleton.application.objectstorage` with `identity`, `content`, `model`, `request`, and `port` subpackages. | +| Legacy coexistence | `dev.caskeleton.application.storage.ObjectStoragePort` and `StoredObject` become deprecated legacy-only types. They remain readable until sample data migration, dual-read observation, API snapshot approval, and zero production usages are evidenced. | +| Control format | `canonical-json-v1`: UTF-8, fixed field order, no insignificant whitespace, decimal integers, canonical enum names, strict duplicate/unknown-field rejection, bounded record-family sizes, and an outer SHA-256 corruption digest. R2 relies additionally on private namespace/IAM and provider encryption; the digest is not described as tamper authentication. | +| Record sizing | Operation/reference/session records are at most 64 KiB; terminal receipts at most 16 KiB; multipart part receipts are separate immutable records of at most 4 KiB each. A 10,000-part ledger is never materialized into one control object. | +| Conditional CAS | S3 uses `PutObject` `If-None-Match: *` for reserve and exact private ETag `If-Match` for revision CAS. The pinned SDK API must be characterized before use. MinIO must prove the same semantics in a non-skipping integration test. Filesystem local-dev uses process lock plus exclusive create/atomic replace and therefore stays R1. | +| Public reference | `osr1...` where route is 12 lowercase Crockford Base32 characters, object is 26 lowercase Crockford Base32 characters (128 random bits), and check is the first 10 lowercase hex characters of SHA-256 over the first three components. Total parsing is bounded; the value contains no provider locator and is not authorization. | +| Stage/session handles | Separate prefixes (`osh1`, `osu1`, `osm1`) and the same bounded route/random/check structure. A stage or session handle is rejected by published-reference parsers and public read ports. | +| Operation deadline | Reuse `dev.caskeleton.application.outbound.CallBudget` as the absolute monotonic parent budget. Add a framework-free `CancellationView`; never serialize either value into durable control records. Persist only wall-clock attempt/lease timestamps and bounded policy durations. | +| Async S3 HTTP | Use `S3AsyncClient` with an explicitly configured `NettyNioAsyncHttpClient`. Do not use CRT, `S3TransferManager`, or SDK-owned automatic multipart for an R2 card. | +| AWS SDK | Keep the repository SSOT `awsSdkVersion = 2.30.0` for this plan. Characterize checksum defaults, conditional builders, and the `mpuObjectSize(Integer)` boundary. A version upgrade needs its own dependency/evidence decision. | +| Local-dev root | No code default. Unit tests use `@TempDir`; the explicit sample-local profile may bind `./.data/object-storage-v1`. Production profiles reject `filesystem-local-dev`. | +| MinIO identity | Initial test identity is `s3-compatible-minio-community-release-2024-01-16t16-07-38z`, corresponding to the existing `minio/minio:RELEASE.2024-01-16T16-07-38Z`. Phase 3 acceptance requires conversion to an image digest pin and records that digest as provider-version evidence. | +| Provider IDs | Only `filesystem-local-dev`, `aws-s3-general-purpose`, and the exact MinIO identity above are implemented in this plan. `filesystem-local-persistent` remains a follow-up provider. | +| Binding prefix | Canonical prefix is `app.object-storage`; `enabled` defaults to `false`, and there is no default provider or destination. Any simultaneous `ca-skeleton.objectstorage.*` and canonical configuration fails startup without logging values. | +| Composition SPI | Task 9 creates a side-effect-free provider-contribution registry. Settings are fully compiled before the assembler asks only selected contributions to construct clients. Normal semantic routers, scan-maintenance routers, and privileged purge routers are distinct concrete types/configurations; no catch-all router exposes a privileged port in a normal application context. Every later provider slice updates its explicit contribution and selected/unselected/disabled composition test—component scanning is not provider activation. | +| Bootstrap edge | Do not add an `app-bootstrap -> adapter-outbound-objectstorage` edge in this plan: there is no production use-case owner there. The sample already has the registered runtime-only edge. A future production owner requires a separate registry/Gradle approval. | +| Scanner owner | Application contracts and staged scan fencing are implemented; tests use a fake scanner verdict source. Selecting and implementing a production scanner is an **approval gate** and is required before the quarantine-publication card can claim R2. | +| Sample durable work | Use a dedicated `poster_image_upload_intent` table/repository and reconciliation use case. Do not reuse broker-delivery outbox rows as an object-storage operation journal. Object bytes never enter that table. | +| API compatibility | Preserve the existing `/posters/{id}/image` response during the legacy window. A new opaque-reference response and any upload-session endpoints require an **approval gate** plus intentional OpenAPI snapshot regeneration; no task silently removes `key`/`location`. | +| Direct-card ceiling | This plan implements provider/session primitives but no approved public direct-upload/download/session endpoint. Therefore direct single, direct multipart, and direct-download cards remain at most R1/partial and Task 29 may not promote them to R2. A follow-up inbound authorization/rate-limit/API snapshot plan is required. | +| Readiness registry | Add `docs/registries/object-storage-readiness.yaml`, schema version 1, with exact `card_id`, provider type/version, destination profile, claimed level, evidence revision/expiry, required non-skipping Gradle tasks, and limitations. Runtime descriptors are derived from compiled binding plus live qualification; the registry is the CI claim manifest, not a substitute for probes. | +| Cleanup ownership | Maintenance lease is `(destination route, job id, owner token, monotonically increasing fence, expires-at)`. Every destructive cleanup also needs an exact object/version precondition and an application handoff/abort authorization. Age or LIST absence alone never authorizes deletion. Report-only is the default. | +| Phase 7 | Phase 6 must still prove bounded backup/restore reconciliation for every exact R2 reconciliation card in a disposable namespace. Regional/cluster disaster-recovery game days, multi-node failover/fencing, sustained scale, and the provider-leaf split/no-split ADR are excluded and require a new approved plan. | + +## Batch graph and promotion gates + +```text +Batch A: Phase 0–1 contract + -> Batch B: Phase 2 provider-neutral kernel + local R1 + -> Batch C: Phase 3 managed S3/MinIO common subset + -> Batch D: Phase 4 direct transfer + multipart + -> Approval Gate A: scanner owner + sample API contract + -> Batch E: Phase 5 staged publication + sample migration + -> Approval Gate B: AWS sandbox/IaC/workload identity + -> Batch F: Phase 6 exact R2 security/maintenance/readiness + -> separate Phase 7 plan +``` + +| Checkpoint | Minimum acceptance | Rollback posture | +| --- | --- | --- | +| A | Application contracts are pure; legacy behavior is characterized; no provider types leak. | Keep new binding disabled and old consumer unchanged. | +| B | Local-dev passes bounded provider contract and restart characterization; only R1 is published. | Disable canonical capability; legacy local example remains isolated. | +| C | Async managed path, CAS, response-loss resolution, and adapter-owned multipart pass pinned MinIO; the AWS lane is compile-only/authority-pending with no observed evidence. | Rebind only new destinations to the prior qualified provider; old route revisions stay readable. | +| D | Direct grant/session ledgers pass expiry, redaction, completion, and late-request races; no public endpoint is implied. | Stop issuing grants, drain/expire/reconcile existing sessions, keep managed transfer. | +| E | Database intent precedes remote mutation; every crash gap is tested; no unscanned data becomes public; old locators remain dual-readable only during migration. | Stop new admission, drain intents, preserve published-reference reads, do not delete legacy data. | +| F | Each claimed card has exact non-skipping security/fault/real-provider evidence and runbooks. | Disable card admission or maintenance delete, retain published reads and the manual reconciliation queue. | + +Every checkpoint named below has three inseparable outputs: (1) the stated GREEN commands and +zero-selected-skip evidence, (2) updated plan/design implementation status and truthful readiness +rows, and (3) a canonical LLM Wiki branch-note update or the exact canonical-vault access block. +This applies to A at Task 5, B at Task 10, C at Task 16, D at Task 19, E at Task 24, and F at +Task 30; a batch is not closed if any output is missing. + +--- + +## Batch A — Phase 0–1: Truth and framework-free contract + +### Task 1: Characterize the legacy boundary without changing behavior + +**Files:** + +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java` +- Create: + `docs/evidence/object-storage/phase-0-inventory.md` + +- [x] **Step 1: Record current executable behavior** + +Prove the current contract exactly as-is: + +- repeated `put` to the same caller key overwrites; +- `get` materializes the whole object; +- filesystem returns `file://` and S3 returns `s3://`; +- absent `ca-skeleton.objectstorage.backend` creates a filesystem bean and directory during + application-context construction, before the first `put`; +- S3 `autoCreateBucket=true` can provision at startup; +- Poster calls storage while `TransactionPort.inWrite` is active; +- the controller calls `MultipartFile.getBytes`; +- the response exposes raw key/location; +- Poster deletion does not retire the object. + +- [x] **Step 2: Verify the characterization baseline** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LegacyObjectStorageBehaviorTest' \ + --tests '*LegacyObjectStorageConfigTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*LegacyPosterImageUploadCharacterizationTest' \ + --console=plain +``` + +Expected: PASS against the current implementation. This is a baseline, not R1/R2 evidence. + +- [x] **Step 3: Inventory runtime and data dependencies** + +In the evidence document record command output, not an unsupported repository-wide conclusion: + +```bash +rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \ + src docs +rg -n 'image_key|posters/.*/image' src/sample-portfolio +``` + +Inventory the known producers/consumers and owner evidence for +`poster.image-attached`, `/posters/{id}/image`, `StoredObjectResponse`, and +`PosterResponse.imageKey`. Repository search proves only repository usages; unknown external +broker/REST consumers are recorded as unknown and block Gate A removal/versioning approval. + +Classify every hit as legacy runtime, test, documentation, stored-data schema, or unrelated text. +Record whether real deployed consumers/data were inspected; if they were not, state that external +inventory is still required. + +- [x] **Step 4: Run unchanged focused suites** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain +``` + +Expected: PASS. No source behavior changes belong to this task. + +### Task 2: Add bounded object-storage identities and opaque references + +**Files:** + +- Modify: + `src/application-core/build.gradle` +- Modify: + `src/application-core/gradle.lockfile` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/`: + `ObjectDestinationId.java`, `ObjectOperationEpoch.java`, `ObjectOperationId.java`, + `ObjectOperationKey.java`, `ObjectId.java`, `ObjectReference.java`, `ObjectStageHandle.java`, + `ObjectVersionToken.java`, `DirectTransferSessionId.java`, `MultipartPartNumber.java`, + `PartReceiptToken.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java` + +- [x] **Step 1: Add the approved test-only property engine** + +Add `testImplementation 'net.jqwik:jqwik:1.9.1'`, matching the existing sample test version, and +update only the application-core lockfile: + +```bash +cd src +./gradlew :application-core:resolveAndLockAll --write-locks +./gradlew :application-core:verifyDependencyLocks --console=plain +``` + +Expected: PASS with test-only jqwik entries and no production dependency. + +- [x] **Step 2: Write the failing identity contract** + +Test null/blank/control-character/oversize rejection, canonical round trips, operation-key +composition, part range `1..10_000`, prefix separation, route-token grammar, reference check-digit +tampering, and provider-locator non-disclosure. Include jqwik properties for arbitrary malformed +input and a fixed golden vector for each prefix. Route existence/retirement is adapter binding +state and is deliberately not tested or imported in `application-core`. + +- [x] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStorageIdentityContractTest' --console=plain +``` + +Expected: compilation failure because the new identity types do not exist. + +- [x] **Step 4: Implement the minimum values** + +Use immutable final classes or records with constructor validation. `ObjectOperationKey` contains +exactly destination, epoch, and operation ID. Public reference/handle types expose canonical text +and a redacted log token only; they do not expose parsed provider coordinates. Keep check-digit +validation in framework-free Java (`MessageDigest`). The adapter-owned codec in Task 6 is the only +minting path from a retained route token plus generated `ObjectId`; application values do not +consult a route registry. + +- [x] **Step 5: Verify GREEN** + +Run the command from Step 3 and +`./gradlew :application-core:verifyDependencyLocks --console=plain`. Expected: PASS. + +### Task 3: Add bounded streaming, digest, range, and cancellation contracts + +**Files:** + +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/`: + `ObjectContentProducer.java`, `ObjectChunkSink.java`, `ObjectContentConsumer.java`, + `ObjectChunkSource.java`, `ObjectContentProductionContext.java`, + `ObjectContentReadContext.java`, `CancellationView.java`, + `ObjectContentProductionException.java`, `ObjectChunkWriteException.java`, + `ObjectContentConsumptionException.java`, `ObjectChunkReadException.java` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/`: + `ObjectDigestAlgorithm.java`, `ObjectDigest.java`, `ObjectContentIdentity.java`, + `ObjectMediaType.java`, `ObjectReadRange.java`, `ObjectDigestVerification.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectContentContractTest.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java` + +- [x] **Step 1: Write failing callback and value tests** + +Prove: + +- source EOF is `-1`, zero-length calls and array ranges are validated, and bounded repeated + zero-progress reads fail; +- sink/source cannot be retained and used after callback return; +- contexts carry `CallBudget`, `CancellationView`, maximum chunk bytes, and validated read + descriptor/range only; +- SHA-256 is the baseline logical digest and is distinct from provider transport checksum/ETag; +- exact length plus digest is required for R2 `ObjectContentIdentity`; +- range offset/length arithmetic rejects zero, negative, overflow, and over-budget delivery; +- media types are canonical, bounded, and contain no control characters. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectContentContractTest' \ + --tests '*ObjectStorageValueContractTest' --console=plain +``` + +Expected: compilation failure because the callback/value types do not exist. + +- [x] **Step 3: Implement the minimum contract** + +Reuse `CallBudget`; do not create serializable wall-clock deadlines. Callback types may declare only +application exceptions and Java primitive/array types. Do not expose `InputStream`, `OutputStream`, +`ByteBuffer`, Reactor, Flow, servlet, Spring, or AWS types. Document that callbacks are synchronous +and blocking and that adapters must not invoke application callbacks on SDK event-loop threads. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +### Task 4: Add the semantic request, receipt, outcome, and port family + +**Files:** + +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/`: + `ManagedObjectPublicationPort.java`, `ObjectInspectionPort.java`, + `ObjectTransferPort.java`, `ObjectRetirementPort.java`, + `ObjectPurgeMaintenancePort.java`, `ObjectOperationResolutionPort.java`, + `ObjectPublicationHandoffPort.java`, `DirectObjectUploadPort.java`, + `DirectObjectDownloadGrantPort.java`, `DirectMultipartUploadPort.java`, + `StagedObjectPublicationPort.java`, `ObjectScanMaintenancePort.java` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/`: + `ObjectPublishRequest.java`, `ObjectReadRequest.java`, `ObjectRetireRequest.java`, + `ObjectPurgeRequest.java`, `ObjectStageRequest.java`, `ObjectVerifyRequest.java`, + `ObjectScanReadRequest.java`, `ObjectScanVerdictRequest.java`, `ObjectFinalizeRequest.java`, + `ObjectAbortRequest.java`, `ObjectHandoffClaimRequest.java`, + `ObjectHandoffRenewRequest.java`, `ObjectHandoffReleaseRequest.java`, + `ObjectAbortAuthorization.java`, `DirectUploadGrantRequest.java`, + `DirectUploadCompletionRequest.java`, `DirectDownloadGrantRequest.java`, + `MultipartStartRequest.java`, `PartUploadGrantRequest.java`, + `MultipartPartAcknowledgement.java`, `MultipartCompleteRequest.java`, + `MultipartAbortRequest.java` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/`: + `ObjectDescriptor.java`, `ObjectPublishReceipt.java`, `ObjectReadReceipt.java`, + `ObjectMutationReceipt.java`, `ObjectOperationResolution.java`, + `ObjectStageReceipt.java`, `ObjectVerificationReceipt.java`, + `ObjectHandoffReceipt.java`, `DirectUploadGrant.java`, + `DirectUploadCompletionReceipt.java`, `DirectDownloadGrant.java`, + `MultipartSession.java`, `PartUploadGrant.java`, `MultipartReceipt.java`, + `ObjectMutationOutcome.java`, `ObjectOperationError.java`, + `ObjectPublicationState.java`, `ObjectScanState.java`, + `ObjectPublicationRequirement.java`, `ObjectRetentionRequirement.java`, + `ObjectEncryptionRequirement.java`, `ObjectCapabilityRequirement.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java` + +- [x] **Step 1: Write the failing port-shape contract** + +Use reflection and construction tests to prove: + +- every mutation request contains `ObjectOperationKey`; +- inspect/transfer require opaque published references and cannot accept stage handles; +- purge is a distinct port from business retirement; +- direct completion verifies rather than trusting a client success flag; +- multipart completion accepts only server-issued part tokens; +- staged finalization is the only staged operation that returns an `ObjectReference`; +- scan verdict binds exact stage/version, scanner policy revision, and scan operation; +- `StagedObjectPublicationPort` has no scan-read/verdict methods; + `ObjectScanMaintenancePort` alone owns unpublished exact-version transfer and verdict recording, + and neither normal publication nor purge port is assignable to it; +- receipts expose no locator, ETag, upload ID, URL, credential, or provider enum; +- requirements can strengthen but never lower destination policy. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStoragePortContractTest' --console=plain +``` + +Expected: compilation failure because the semantic port family does not exist. + +- [x] **Step 3: Implement the minimum framework-free API** + +Follow design §9 exactly. Keep one public top-level type per file. Use immutable collections and +defensive copies where required. Grants contain a bounded URI, signed header names/values, +expiration, and opaque session identity, but their `toString` must redact the URI and headers. +`ObjectPurgeMaintenancePort` documentation must state its privileged composition boundary. +Document the separate scanner-workflow composition boundary on `ObjectScanMaintenancePort`; the +normal staged port owns only stage, integrity verification, finalize, and abort. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +### Task 5: Enforce application purity and isolate the legacy compatibility seam + +**Files:** + +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java` +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java` +- Create: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` + +- [x] **Step 1: Write failing purity tests** + +Assert recursively through fields, methods, constructors, record components, generic arguments, and +annotations that `dev.caskeleton.application.objectstorage..` has no Spring, AWS SDK, servlet, +transport DTO, `Path`, `File`, persistent provider locator, JPA, SLF4J, or adapter type. Permit +`java.net.URI` only in the explicit direct-grant values and prove their redacted/persistence +boundary. Add an ArchUnit rule that new sample business code may not import +`dev.caskeleton.application.storage`; freeze the current upload use case until Task 23 splits it +into an explicitly named legacy-only package, and permit the later exact +`application.storage.migration` adoption use case as an admin-only compatibility exception. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStorageArchitectureContractTest' --console=plain +./gradlew :app-bootstrap:test \ + --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: the new sample-import rule initially identifies the current legacy consumer, or the test +fixture explicitly records it as the single frozen violation. No unrelated architecture violation +may be accepted. + +- [x] **Step 3: Deprecate without adapting new calls back to raw keys** + +Mark both legacy types `@Deprecated(forRemoval = true)` and document: + +- legacy overwrite/materialization semantics; +- separate legacy namespace; +- production-disabled target state; +- no use from new code; +- removal gates, not an invented removal date. + +Freeze the existing `UploadPosterImageUseCase` as the only temporary sample violation until Task 23. +Task 23 must move the remaining compatibility surface into an allowlisted `..poster.legacy..` +slice; no non-legacy sample package may import the old port after that cutover. Do not create a +semantic-to-legacy adapter that throws away operation identity or guarantees. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS with exactly the named frozen legacy violation and zero +provider/framework leaks. The later migration exception must be bounded/redacted and visible only +to the named administrative use case. + +- [x] **Step 5: Run Batch A checkpoint** + +Run: + +```bash +cd src +./gradlew :application-core:check --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +Expected: PASS. Update readiness documentation to R0 contract only. Do not call this an implemented +object-storage provider. + +--- + +## Batch B — Phase 2: Provider-neutral kernel and local R1 + +### Task 6: Add canonical namespace, reference, fingerprint, and policy codecs + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/`: + `ObjectDataKeyCodec.java`, `ObjectControlKeyCodec.java`, + `ObjectReferenceCodec.java`, `ObjectHandleCodec.java`, + `ObjectRequestFingerprintCodec.java`, `ObjectPolicySnapshotCodec.java`, + `CrockfordBase32.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`: + `ObjectBindingRevision.java`, `ObjectPolicyRevision.java`, `ObjectRouteToken.java`, + `ObjectPolicySnapshot.java`, `ObjectOperationEpochRecord.java`, + `ObjectOperationEpochState.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java` + +- [x] **Step 1: Add the approved test-only property engine** + +Add `testImplementation 'net.jqwik:jqwik:1.9.1'` and update only this leaf's lockfile: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +``` + +Expected: PASS with no production jqwik dependency. + +- [x] **Step 2: Write failing codec and epoch tests** + +Use golden vectors and property tests for: + +- `data/v1` and every `control/v1` grammar in design §10; +- ASCII-only segments, fixed maximum segment/total length, deterministic shard, and rejection of + slash aliases, percent encoding, Unicode normalization ambiguity, `.`/`..`, and control + characters; +- data-key APIs accept only generated `ObjectId`, route, generation, and typed revision values, + never a filename/tenant/raw-name `String`; compile/static contract tests prove that representative + email/filename values have no accepted parameter path instead of attempting a PII heuristic; +- the exact `osr1`/`osh1`/`osu1`/`osm1` grammar frozen above; +- canonical fingerprint field order, absence-versus-empty, integer overflow, enum names, and + schema version; +- same intent yielding the same fingerprint and any semantic field change yielding a different + fingerprint; +- binding/policy snapshots that contain no secret or provider credential; +- epoch `WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED` transitions, no token reuse, and + `OPERATION_EXPIRED` after seal/compaction. + +- [x] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectNamespaceCodecTest' \ + --tests '*ObjectRequestFingerprintCodecTest' \ + --tests '*ObjectOperationEpochTest' --console=plain +``` + +Expected: compilation failure because the codec/kernel types do not exist. + +- [x] **Step 4: Implement deterministic codecs** + +Keep all physical key construction in these codecs. Route lookup uses retained binding revision, +never a current-provider default. The fingerprint includes exact content identity for R2 and an +explicit `R1_UNVERIFIED_CONTENT` marker for compatibility; it never hashes content by materializing +the object. Operation epoch records include finite replay/retention/compaction bounds. + +- [x] **Step 5: Verify GREEN** + +Run the command from Step 3 and +`./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain`. Expected: PASS. + +### Task 7: Add strict control records and provider-neutral operation state machines + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/`: + `ObjectControlRecord.java`, `ObjectOperationRecord.java`, + `ObjectManifestRecord.java`, `ObjectReferencePointerRecord.java`, + `ObjectReferenceRecord.java`, `ObjectMultipartSessionRecord.java`, + `ObjectMultipartPartRecord.java`, `ObjectControlRecordEnvelope.java`, + `ObjectControlRecordCodec.java`, `CanonicalJsonObjectControlRecordCodec.java`, + `CanonicalJsonReader.java`, `CanonicalJsonWriter.java`, + `ObjectControlStore.java`, `ObjectControlVersion.java`, + `ObjectControlMutation.java`, `ObjectControlConflictException.java`, + `ObjectControlCorruptionException.java`, `UnsupportedObjectControlSchemaException.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`: + `PublicationOperationState.java`, `ScanOperationState.java`, + `PublishedReferenceState.java`, `DirectGrantSessionState.java`, + `MultipartUploadState.java`, `PendingObjectEffect.java`, + `ObjectEffectCertainty.java`, `ObjectOperationStateMachine.java`, + `ObjectOperationKernel.java`, `ObjectOperationKernelResult.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java` + +- [x] **Step 1: Write failing strict-codec tests** + +For every record family introduced through Task 7 prove: + +- canonical byte-for-byte round trip and checked-in golden fixture; +- schema v1 only for writes; +- duplicate, unknown, missing, reordered-invalid, truncated, oversized, checksum-mismatched, and + newer-schema records fail closed rather than appearing absent; +- operation state and revision invariants; +- frozen route/provider/binding/policy/codec/checksum/encryption/retention revisions; +- separate expected/observed content digest and provider ETag/checksum evidence; +- no secret, URL, raw credential, original filename, public ACL, absolute path, or inbound data; +- independent part records, with no unbounded in-session map. + +Also prove an immutable manifest revision binds `ObjectId`, exact provider version, logical +size/digest/media type, encryption/retention evidence, and immutable data version, while a separate +small reference pointer CASes only the current manifest revision. No mutable pointer is treated as +the manifest itself. + +- [x] **Step 2: Write failing transition-table tests** + +Cover every allowed and forbidden transition from design §12, including: + +- reservation, pending effect before I/O, evidence-based certainty after I/O; +- terminal same-fingerprint replay without producer invocation; +- any-state different-fingerprint conflict; +- response loss yielding `INDETERMINATE` until resolution; +- scan, published-reference, direct-session, and multipart states remaining independent; +- stale fence/revision rejection; +- unknown/newer state never auto-deleted or downgraded. + +- [x] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*ObjectOperationStateMachineTest' \ + --tests '*ObjectOperationKernelTest' --console=plain +``` + +Expected: compilation failure because the control-plane and state-machine types do not exist. + +- [x] **Step 4: Implement the minimum kernel** + +The kernel accepts a compiled policy snapshot and an `ObjectControlStore`; it does not import a +provider SDK. Reserve writes the frozen snapshot before provider mutation. Every mutation writes a +pending-effect attempt before I/O and resolves from exact evidence after I/O. Implement +`canonical-json-v1` with the named bounded JDK-only reader/writer and an explicit closed family +discriminator; no reflective or `Map` binding and no undecided JSON dependency is +allowed. A later task that adds a durable family must modify this codec, add checked-in golden +bytes, and prove old/new reader compatibility before the selected write version changes. + +- [x] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 8: Implement the bounded `filesystem-local-dev` provider + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/`: + `ObjectStorageProvider.java`, `ObjectStorageProviderDescriptor.java`, + `ObjectStorageProviderOperation.java`, `ObjectStorageProviderException.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/`: + `LocalDevObjectStorageProvider.java`, `LocalDevObjectControlStore.java`, + `LocalDevObjectDataStore.java`, `LocalObjectPathGuard.java`, + `LocalObjectStreamTransfer.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/contract/ObjectStorageProviderContract.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProviderTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java` + +- [x] **Step 1: Write the reusable provider contract** + +The abstract suite must be executable for each provider and cover: + +- empty, one-byte, chunk-minus-one, exact-chunk, chunk-plus-one, and maximum-size upload; +- bounded producer invocation and no full-object buffer; +- immutable create/conflict and terminal replay; +- exact inspect/version/digest; +- full and one contiguous range read; +- short/failing/stalled producer and slow/failing consumer; +- cancellation before and during transfer; +- checksum match/mismatch; +- conditional retirement; +- response-loss resolution; +- callback/resource invalidation and closure. + +Unsupported optional capabilities must assert descriptor `UNSUPPORTED`, not skip. + +- [x] **Step 2: Write failing local security/recovery tests** + +Use `@TempDir` and injected filesystem/fault collaborators to test: + +- traversal, absolute path, Unicode alias, root escape, symlink root/nested/swap; +- exclusive-create race with two writers; +- restrictive created permissions where POSIX exists; +- disk full, permission denied, read-only simulation, short write, truncated read; +- restart after each control/data step; +- same operation recovery without producer replay; +- corrupt/newer records quarantined, never absent/deleted; +- file descriptor and temporary-file cleanup. + +- [x] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LocalDevObjectStorageProviderTest' \ + --tests '*LocalDevObjectStorageRecoveryTest' --console=plain +``` + +Expected: compilation failure because the local provider/kernel integration does not exist. + +- [x] **Step 4: Implement bounded local R1** + +Use adapter-generated immutable data names, `CREATE_NEW`, bounded chunks, streaming SHA-256, staged +temporary files, force/atomic move only where the host proves it, and strict relative path checks. +The local control store serializes per operation in one process. When portability or crash +durability cannot be proven, return the truthful R1 descriptor; never claim multi-node CAS or +power-loss durability. This provider is rejected in production profiles. + +- [x] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 9: Compile exact settings and compose a disabled-by-default routing capability + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/`: + `ObjectStorageCapabilitySettings.java`, `ObjectStorageProviderSettings.java`, + `ObjectStorageDestinationSettings.java`, `CompiledObjectStorageProvider.java`, + `CompiledObjectStorageDestination.java`, `ObjectStorageBindingCompiler.java`, + `ObjectStorageProviderContribution.java`, `SelectedObjectStorageProviderFactory.java`, + `ObjectStorageCapabilityAssembler.java`, `ObjectStorageCapabilityConfig.java`, + `RoutingObjectReadAdapter.java`, `RoutingObjectMutationAdapter.java`, + `RoutingObjectDirectGrantAdapter.java`, `ObjectStorageMaintenanceCapabilityConfig.java`, + `LegacyObjectStorageActivationGuard.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompilerTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` + +- [x] **Step 1: Write failing binding tests** + +Bind `app.object-storage` and reject: + +- enabled without providers/destinations or required destination; +- missing/unknown provider ref/type or duplicate normalized IDs; +- any implicit provider, destination, bucket, namespace, root, or capability; +- invalid namespace/size/chunk/part/replay/timeout/retry/amplification bounds; +- destination requirement stronger than provider descriptor; +- route or namespace collision/reuse; +- unknown, retired, or unavailable retained route revision during read/reconcile; +- scan-required destination without the scan seam; +- local-dev selected in a production profile; +- canonical settings and any old `ca-skeleton.objectstorage.*` alias present together; +- legacy and canonical data/control namespaces overlapping. + +Prove a valid local profile compiles one exact route and immutable policy snapshot. + +- [x] **Step 2: Write failing composition tests** + +Use `ApplicationContextRunner` to prove: + +- absent or `enabled=false` creates zero ports, directory, credential lookup, client, thread, + scheduler, health indicator, and warning; +- enabled explicit local binding creates exactly one routing implementation for each applicable + semantic port; +- an unknown destination fails before producer invocation; +- routing retains old route/binding revisions for reads/reconciliation; +- `matchIfMissing` is gone; +- legacy-only mode remains isolated and opt-in during migration; +- an old `ca-skeleton.objectstorage.*` alias together with any canonical setting fails without + logging property values; +- a namespace-separated canonical `legacy` subgroup plus the new capability may run together only + in the explicit migration/sample-local profile. + +- [x] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageBindingCompilerTest' \ + --tests '*ObjectStorageCapabilityConfigTest' --console=plain +``` + +Expected: compilation/test failure because canonical settings/composition do not exist and current +legacy configuration activates filesystem by default. + +- [x] **Step 4: Implement exact binding and activation** + +Use immutable constructor-bound settings and typed `Duration`/`DataSize`/enums. Compile settings +before constructing any provider. Contributions are side-effect-free descriptors; the assembler +invokes only the exact selected contribution after successful compilation, so an unselected +provider cannot resolve credentials, construct a client, create a thread, or touch a directory. +Normal read/mutation/direct facades are separate types; scan maintenance and privileged purge are +not implemented by or registered through a normal facade. The routers use immutable maps keyed by +destination and retained route revision and never fall back. Keep legacy configuration behind an explicit +`app.object-storage.legacy.enabled=true` compatibility condition, default false. Old +`ca-skeleton.objectstorage.*` aliases may activate legacy-only mode during the first migration +step, but their presence together with any canonical setting fails. A dual-run profile uses only +the canonical `legacy` subgroup with an explicit backend/root-or-prefix isolated from all v1 +data/control namespaces. + +Until Task 22 migrates the consumer, the sample-local YAML explicitly enables the isolated legacy +filesystem seam and may also select `filesystem-local-dev` at `./.data/object-storage-v1` for the +new capability. No production YAML receives a local fallback. + +- [x] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +- [x] **Step 6: Prove the stable contribution seam** + +Add an `ApplicationContextRunner` matrix with a counting fake contribution: disabled, unselected, +invalid binding, selected success, selected construction failure, and close. Prove construction +occurs exactly once only after compilation, close occurs exactly once, and there is no privileged +maintenance/purge bean in the normal context. Every later provider task must extend this matrix +when it registers a contribution. + +Re-run the Step 3 command. Expected: PASS; the contribution matrix is part of Task 9 GREEN, not an +unverified post-GREEN addition. + +### Task 10: Publish the truthful local R1 card and close the Phase 2 gate + +**Files:** + +- Create: + `docs/registries/object-storage-readiness.yaml` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessLevel.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java` +- Modify: + `src/adapter/outbound/objectstorage/README.md` +- Modify: + `src/adapter/outbound/objectstorage/CLAUDE.md` +- Modify: + `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md` + +- [x] **Step 1: Write the failing registry/schema test** + +Validate the frozen schema and exact nine card IDs. Reject: + +- unknown card/level/provider; +- a global “objectstorage R2” row; +- R1/R2 without exact provider version and destination profile; +- R2 without evidence revision/expiry and non-skipping required tasks; +- a required task that is absent from Gradle; +- local-dev above R1; +- a limitation-free row when a provider descriptor reports limitations. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageReadinessRegistryTest' --console=plain +``` + +Expected: failure because the registry and runtime card types do not exist. + +- [x] **Step 3: Add only evidenced claims** + +Initial entries may claim R0 for contract-only cards and R1 for local managed single upload/download +only after Tasks 6–9 pass. Direct, multipart, quarantine, retention, and production reconciliation +remain R0/unimplemented. Document that local process recovery is not multi-node CAS or R2. + +- [x] **Step 4: Run Batch B checkpoint** + +Run: + +```bash +cd src +./gradlew \ + :application-core:check \ + :adapter:outbound:objectstorage:check --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +Expected: PASS. + +- [x] **Step 5: Verify rollback** + +Start a context with the canonical capability disabled and legacy disabled. Assert there are no +storage beans or side effects. Start the explicit legacy profile and prove old data remains +readable. No migration or deletion occurs at startup. + +--- + +## Batch C — Phase 3: Managed S3/MinIO common subset + +### Task 11: Characterize the pinned SDK and add the explicit async HTTP client + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3AsyncClientFactory.java`, `S3ClientPolicy.java`, `S3ClientLifecycle.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3SdkApiCharacterizationTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java` + +- [x] **Step 1: Write the failing pinned-API characterization** + +Compile and assert the exact `2.30.0` API used by later tasks: + +- `PutObjectRequest.Builder.ifNoneMatch` and `ifMatch`; +- `CompleteMultipartUploadRequest.Builder.ifNoneMatch`, `ifMatch`, and + `mpuObjectSize(Integer)`; +- explicit request/response checksum configuration; +- `expectedBucketOwner` on every relevant request; +- presigner availability without constructing it yet. + +Add boundary tests at `Integer.MAX_VALUE`, `Integer.MAX_VALUE + 1L`, part count 10,000/10,001, and +minimum S3 non-final part size. Record the full-object multipart checksum profile as unsupported +above the SDK integer boundary unless an independently approved path exists. + +- [x] **Step 2: Write failing client-policy tests** + +Reject missing/non-positive/contradictory: + +- parent API call and per-attempt timeout; +- connect, TLS negotiation, acquire, read, and write timeout; +- max concurrency and pending acquire bounds; +- SDK retry attempts/backoff that exceed the parent budget; +- shutdown grace; +- plaintext AWS endpoint, endpoint userinfo/query/fragment, and partial static credentials. + +Assert the factory uses `S3AsyncClient` plus explicit `NettyNioAsyncHttpClient`, not CRT or the sync +client, and owns close order. + +- [x] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3SdkApiCharacterizationTest' \ + --tests '*S3AsyncClientFactoryTest' --console=plain +``` + +Expected: compilation failure because the client factory and compile-scoped Netty async client do +not exist. + +- [x] **Step 4: Add only the required dependency and implementation** + +Add `software.amazon.awssdk:netty-nio-client` under the existing AWS BOM. Keep +`software.amazon.awssdk:s3` and the BOM at `2.30.0`; do not add CRT or Transfer Manager. Configure +finite client and HTTP timeouts/pools/retry from the compiled policy. Default-chain credentials are +resolved only after the provider is selected. + +- [x] **Step 5: Regenerate and verify locks** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3SdkApiCharacterizationTest' \ + --tests '*S3AsyncClientFactoryTest' --console=plain +``` + +Expected: PASS, with only reviewed async-client transitive changes in the module lockfile. + +### Task 12: Compile exact AWS and MinIO provider bindings and qualification descriptors + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3ProviderSettings.java`, `S3ProviderBinding.java`, `S3ProviderType.java`, + `S3ProviderVersion.java`, `S3ProviderErrorMapper.java`, + `S3CapabilityProbe.java`, `S3ProviderQualifier.java`, + `S3QualificationEvidence.java`, `S3ObjectStorageProviderContribution.java`, + `S3ProviderCapabilityConfig.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/`: + `ObjectStorageCapabilityDescriptor.java`, `CapabilityEvidence.java`, + `CapabilityEvidenceStatus.java`, `CapabilityEvidenceSource.java`, + `ObjectStorageOperationProfile.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderSettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBindingTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifierTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapperTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` + +- [ ] **Step 1: Write failing exact-binding tests** + +Accept only: + +- `aws-s3-general-purpose`; +- `s3-compatible-minio-community-release-2024-01-16t16-07-38z`. + +Reject `s3`, `s3-compatible`, unknown version, directory bucket/S3 Express, MRAP/access point, +auto-create, public ACL, plaintext production endpoint, missing AWS expected owner, missing MinIO +deployment identity, namespace collision, static production credentials, unbounded budgets, and a +destination whose exact named profile has no unexpired `SUPPORTED` evidence. + +Do not derive one combined profile by AND-ing unrelated booleans. +Use the Task 9 contribution seam to prove disabled, unselected, and invalid bindings create no +client, credential lookup, DNS, executor, or probe; only a selected, fully compiled exact provider +constructs one lifecycle-owned client and closes it exactly once. + +- [ ] **Step 2: Write failing qualifier/error tests** + +Prove normalized mapping for permission, owner/region mismatch, `404`, `409`, `412`, throttling, +timeout, checksum mismatch, retention/hold, and unknown response loss. A final SDK exception alone +must not turn a mutation into an authoritative failure. Safe probe mode may access only its reserved +prefix and may not create a bucket or change versioning/lifecycle/CORS/ownership/BPA/encryption. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3ProviderBindingTest' \ + --tests '*S3ProviderQualifierTest' \ + --tests '*S3ProviderErrorMapperTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because exact S3 provider models and qualification do not exist. + +- [ ] **Step 4: Implement the minimum descriptors** + +Descriptor axes and named profiles follow design §21. Qualification sources are +`STATIC_ATTESTATION`, `STARTUP_PROBE`, or `CI_QUALIFICATION`, each with digest, provider/deployment +identity, observation/expiry, and limitations. `UNVERIFIABLE` and expired evidence never compile as +supported. Keep AWS/MinIO behavior separate behind the same provider-neutral kernel. Register the +side-effect-free S3 contribution explicitly; do not use component scanning as activation. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 13: Implement bounded managed single upload, inspect, download, and range + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3ManagedObjectProvider.java`, `S3AsyncRequestBodyBridge.java`, + `S3AsyncResponseBodyBridge.java`, `S3ObjectEvidenceMapper.java`, + `S3ChecksumPolicy.java`, `S3ConditionalRequestMapper.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridgeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridgeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProviderTest.java` + +- [ ] **Step 1: Write failing bridge tests** + +With deterministic executors and a fake async subscriber/publisher, prove: + +- aggregate buffers never exceed configured chunks/bytes; +- producer/consumer runs off the SDK event-loop; +- backpressure prevents unbounded producer lead; +- single-pass producer is invoked once; +- cancellation, callback failure, subscriber cancellation, short/zero-progress/truncated body, and + deadline expiry close resources and release admission; +- logical SHA-256 is computed while streaming and checked independently of provider checksum; +- no whole-object `byte[]`, `toBytes`, `getObjectAsBytes`, or `RequestBody.fromBytes` path exists. + +- [ ] **Step 2: Write failing provider tests** + +Mock only the SDK boundary and prove exact request mapping: + +- immutable single PUT uses `If-None-Match: *`, exact content length/checksum/encryption/owner; +- HEAD validates size, version, checksum, encryption, and publication record; +- GET/range validates returned version, range, content length/range, and digest mode; +- empty object is valid; +- `409`/`412`, permission, absence, throttling, and response loss map to distinct outcomes; +- public receipts contain no S3 locator/evidence. +- the selected S3 contribution exposes managed single/inspect/download/range delegates through the + normal routers, while unselected/disabled contexts still expose none and create no resources. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3AsyncRequestBodyBridgeTest' \ + --tests '*S3AsyncResponseBodyBridgeTest' \ + --tests '*S3ManagedObjectProviderTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because the async bridges/provider do not exist. + +- [ ] **Step 4: Implement the minimum managed path** + +Bridge the synchronous application callbacks through a bounded adapter-owned worker and queue. +Propagate cancellation to the SDK future/body, invalidate callback resources, and release every +semaphore/buffer. Do not let SDK retry replay a non-repeatable producer. If transport retry requires +body replay, resolve evidence or require a new operation; an adapter spool needs a separate plan. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 14: Implement S3 conditional control storage and response-loss resolution + +**Files:** + +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java` + +- [ ] **Step 1: Write failing CAS tests** + +Prove exact-key direct lookup and: + +- reserve with `If-None-Match: *`; +- update with the record's exact private ETag in `If-Match`; +- stale writer and same-operation/different-fingerprint conflict; +- dropped create/update response reconciled by GET and record digest/revision comparison; +- `404` is authoritative only after the operation-specific evidence rules permit it; +- corrupt/newer record never becomes absent or overwritten; +- LIST is not used on the request path. + +- [ ] **Step 2: Write the failing response-loss matrix** + +Inject loss after operation reserve, data PUT, data HEAD, reference create, terminal record CAS, and +retirement. Expected outcomes must be terminal replay, deterministic continuation, typed conflict, +or `INDETERMINATE`; never blind duplicate mutation or producer replay. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3ConditionalObjectControlStoreTest' \ + --tests '*S3ObjectOperationResolverTest' \ + --tests '*ObjectMutationResponseLossTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation/failing reconciliation because S3 CAS/resolution does not exist. + +- [ ] **Step 4: Implement conditional storage and resolution** + +Persist pending effect, attempt ID, exact precondition, and request-evidence digest before each +mutation. Use frozen binding/policy revision for resolution. Provider ETag remains private and is +not treated as logical content digest or public version. Unsupported conditional semantics fail +provider qualification; do not emulate with HEAD-then-unconditional-PUT. Wire the conditional store +and resolver into only the selected S3 contribution and retain the disabled/unselected zero-effect +composition assertions. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 15: Implement adapter-owned managed multipart and a sharded part ledger + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/`: + `ManagedMultipartCoordinator.java`, `MultipartUploadPlan.java`, + `MultipartPartLedger.java`, `MultipartCompletionEvidence.java`, + `MultipartOperationResolver.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProvider.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinatorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProviderTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartResponseLossTest.java` + +- [ ] **Step 1: Write failing plan/ledger tests** + +Prove: + +- single PUT versus multipart threshold is deterministic and frozen; +- S3 part minimum, final-part exception, max 10,000 parts, per-part/in-flight/concurrency budgets; +- each completed part is an immutable bounded control record; +- duplicate same part evidence replays; different evidence conflicts; +- ordered completion derives only from the server ledger; +- full logical SHA-256 remains separate from part/provider checksums; +- SDK `mpuObjectSize(Integer)` overflow rejects the incompatible checksum profile before I/O. + +- [ ] **Step 2: Write failing provider/recovery tests** + +Cover create, upload part, list parts, complete, abort, `404`/`409`/`412`, stale upload ID, dropped +part response, dropped complete response, concurrent abort/complete, process restart, and orphan +candidate production. Completion after a `409` that requires a new upload must not retry the old +upload ID. Before `CreateMultipartUpload`, require an `INITIATE_IN_PROGRESS` control CAS with a +pending effect, deterministic operation-exclusive data key, and attempt evidence. Inject the fault +where S3 creates the upload ID but the response is lost: bounded, paginated discovery by that exact +key/attempt horizon may adopt one unambiguous upload; zero/multiple/unprovable candidates remain +`INDETERMINATE` orphan evidence and must not trigger a blind second initiate. + +Prove create-only completion uses `If-None-Match: *` on +`CompleteMultipartUpload`, with distinct `404`/`409`/`412` outcomes. SSE-KMS/DSSE and Object Lock +headers belong on `CreateMultipartUpload`; `UploadPart`/complete receive only operation-appropriate +checksum/owner/precondition fields, not copied PUT-only KMS headers. An Object-Lock request includes +the provider-required `Content-MD5` or an exact qualified checksum. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ManagedMultipartCoordinatorTest' \ + --tests '*S3ManagedMultipartProviderTest' \ + --tests '*MultipartResponseLossTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because managed multipart types do not exist. + +- [ ] **Step 4: Implement low-level multipart only** + +Call `CreateMultipartUpload`, `UploadPart`, `ListParts`, `CompleteMultipartUpload`, and +`AbortMultipartUpload` directly. Never delegate R2 state to SDK automatic multipart. Keep upload ID +and part ETags private. Do not send create until `INITIATE_IN_PROGRESS` is durable, and do not +re-initiate while its outcome is uncertain. Register the managed multipart delegate and its closed +record-family codec explicitly in the selected S3 contribution; add golden/old-reader fixtures for +every new durable field. Abort eligibility is recorded but physical cleanup remains report-only +until Task 27; destructive provider qualification is Task 29 and requires explicit authorization. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 16: Qualify the pinned MinIO managed subset and prepare the protected AWS lane + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/resources/object-storage/minio-provider-evidence.json` +- Create: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` +- Modify: + `.github/scripts/verify-gate-matrix.sh` +- Modify: + `docs/registries/object-storage-readiness.yaml` + +- [ ] **Step 1: Add non-skipping Gradle lanes** + +Register: + +```text +objectStorageMinioContractTest +objectStorageMinioFaultTest +objectStorageAwsQualificationTest +``` + +The first two require Docker and fail with an actionable prerequisite message when unavailable. +The AWS task requires explicit sandbox enablement, account/region/bucket/owner inputs, and fails +when selected inputs are absent. Keep the existing developer-fast `disabledWithoutDocker` legacy +test, but never cite it as readiness evidence. + +Regenerate/review the leaf lock after adding the resolvable source-set configurations: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +``` + +Add a secret-free PR/container MinIO contract job and a scheduled/manual MinIO fault job to the +gate matrix. The protected AWS job is declared but cannot execute or emit evidence until Approval +Gate B supplies authority. Artifacts contain normalized results/image digests only—never generated +credentials, endpoints, account IDs, or signed requests. + +- [ ] **Step 2: Write the failing MinIO contract/fault tests** + +Use the exact MinIO release frozen above, pinned by image digest before GREEN. Use +`ghcr.io/shopify/toxiproxy:2.12.0` pinned by digest for TCP latency/reset/bandwidth faults. Generate +test credentials at runtime rather than keeping known literals in source. + +Run the shared provider suite plus: + +- actual conditional create/CAS; +- checksum/HEAD/range; +- managed multipart/abort/list/complete; +- response drop after data/control mutation; +- connection cut, slow body, process restart; +- concurrent same/different fingerprint; +- bounded heap/direct memory/thread/FD assertions. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew \ + :adapter:outbound:objectstorage:objectStorageMinioContractTest \ + :adapter:outbound:objectstorage:objectStorageMinioFaultTest \ + --console=plain +``` + +Expected: failure until images are digest-pinned, the shared suite is wired, and all required +semantics pass. Docker absence is a failure, not success/skip. + +- [ ] **Step 4: Implement only missing provider semantics** + +Do not weaken the contract for MinIO. If the exact release cannot prove a conditional, checksum, +multipart, or recovery behavior, record that operation profile as `UNSUPPORTED`/`UNVERIFIABLE` and +keep the corresponding binding/card disabled. + +- [ ] **Step 5: Re-run the required MinIO lanes GREEN** + +Run the command from Step 3. Expected: PASS for every advertised exact profile, with zero selected +test skips. Any unsupported profile is absent from the selected contract matrix and is asserted as +`UNSUPPORTED` by a separate test; it is not hidden by a skip. + +- [ ] **Step 6: Compile, but do not execute, the protected AWS common-subset lane** + +Compile its source set without contacting AWS: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain +bash ../.github/scripts/verify-gate-matrix.sh +``` + +Expected: PASS with no AWS evidence row. If a registry placeholder is necessary, keep it at R0 +with `limitation: authority_pending` and no observed-evidence fields; do not invent a new evidence +status. Task 29, after Approval Gate B, executes this test with security/fault qualification. Plan +approval alone is not authority to mutate an external bucket. + +- [ ] **Step 7: Run Batch C checkpoint** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:check --console=plain +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +Expected: PASS. Publish at most exact MinIO R1 managed cards; publish no AWS claim or observed +evidence yet. Preserve old route/binding readers before enabling any new destination writer. + +--- + +## Batch D — Phase 4: Direct transfer and multipart + +This batch implements provider/application primitives and qualification surfaces only. A public +signing/direct endpoint remains out of scope and requires a separate follow-up plan. + +### Task 17: Implement presigned single-upload and exact-version download grants + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/`: + `DirectTransferSessionRecord.java`, `DirectTransferPolicy.java`, + `DirectGrantGeneration.java`, `DirectTransferCoordinator.java`, + `PresignedGrantRedactor.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3PresignerFactory.java`, `S3DirectTransferProvider.java`, + `S3DirectCompletionVerifier.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinatorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProviderTest.java` + +- [ ] **Step 1: Write failing grant-policy tests** + +Reject: + +- non-HTTPS public presign endpoint outside explicit local-test profile; +- userinfo/query/fragment in configured endpoint or host outside allowlist; +- expiry zero/negative/over maximum, beyond the attested `s3:signatureAge` bound, or at/after + `credential/session horizon - qualified maximum clock skew`; +- missing exact operation/content identity, checksum, media type, encryption/retention header, or + create-only precondition required by the named profile; +- a named `direct-single-hard-ceiling` profile when the destination cannot prove a + provider-enforced hard size ceiling; +- an unhealthy/unqualified local clock or NTP status before any new grant; +- direct download before application authorization or for unpublished/retired/wrong-version data. + +- [ ] **Step 2: Write failing lifecycle and redaction tests** + +Prove the exact grant linearization: + +```text +session revision CAS -> GRANT_PREPARED( + constraintsDigest, signingTime, expiresAt, credentialRevision, referenceRevision) +sign exact request +same-revision CAS -> GRANT_ISSUED +return bearer URI +``` + +The `GRANT_PREPARED` CAS must precede signing; `GRANT_ISSUED` is preconditioned on that exact +generation/reference revision and must precede response. Also prove: + +- a lost grant response may reissue only under the frozen generation policy; +- multiple outstanding generations are bounded and tracked through expiry plus in-flight horizon; +- completion ignores a client “success” boolean and performs exact HEAD/checksum/size/version/ + encryption verification; +- retirement CAS linearizes before issuing a download grant; +- retirement winning before the issued CAS discards the signed URL and returns no grant; issued CAS + winning first means the already-issued URL truthfully remains valid until expiry and is not + relabeled “not issued” even if its response is lost; +- URI, query signature, signed header values, credential scope, bucket/key, and session internals + never appear in `toString`, logs, traces, exceptions, metrics, or control records. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*DirectTransferCoordinatorTest' \ + --tests '*PresignedGrantRedactionTest' \ + --tests '*S3DirectTransferProviderTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because direct coordination/provider types do not exist. + +- [ ] **Step 4: Implement the minimum direct single path** + +Use `S3Presigner` owned by the provider lifecycle. Sign only an exact method, key, checksum/content +headers, encryption/retention headers, and bounded expiry required by the compiled profile. Treat +the URL as a bearer secret. Persist the new direct-session family through the closed control codec +with golden/old-reader fixtures, and wire the direct delegate only through the selected S3 +contribution. Completion creates a stage receipt only after evidence verification; publication +remains a separate state transition. + +Keep two explicit profiles rather than one contradictory rule: +`direct-single-hard-ceiling` is rejected unless the provider enforces the ceiling; +`direct-single-soft-limit-r1` may be enabled only with a documented maximum exposure, immediate +post-upload verification/quarantine, and an R1 ceiling. Do not claim that a post-upload HEAD +prevents temporary oversized storage. POST policy remains unsupported until a separately audited +signer is added. New grant admission fails closed when qualified clock health is unavailable. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 18: Implement direct multipart grants, acknowledgement, completion, and abort + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/`: + `DirectMultipartCoordinator.java`, `DirectMultipartGrantLedger.java`, + `DirectPartAcknowledgementVerifier.java`, `DirectMultipartCompletionVerifier.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartRaceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProviderTest.java` + +- [ ] **Step 1: Write failing session/part tests** + +Prove: + +- start first CASes `INITIATE_IN_PROGRESS` plus exact pending-effect evidence, then calls create; + provider-accepted/create-response-lost recovery uses bounded, paginated, exact operation-key + discovery and never blindly initiates again; ambiguity remains `INDETERMINATE`/orphan; +- a confirmed start persists the provider upload ID privately before returning a session; +- part grants are bounded by part number/count/size/concurrency/expiry and server generation; +- acknowledgement accepts only an allowlisted, bounded provider completion claim and converts it to + an opaque `PartReceiptToken`; +- a reissued part grant cannot let a stale late request silently replace an acknowledged part; +- completion closes grant/ack admission, waits for every issued generation expiry plus qualified + clock skew and maximum in-flight horizon (or proves controlled-ingress drain), then paginates + `ListParts`; +- completion compares each current ledger revision/token against exact provider part number, + private ETag, checksum algorithm/type/scope, and length before a `COMPLETE_IN_PROGRESS` CAS; +- conditional complete is followed by exact final version/size and `FULL_OBJECT` SHA-256 + verification; multipart ETag or composite checksum is never treated as the logical full digest; +- incomplete, duplicate-conflicting, stale, or over-budget ledgers fail before provider complete; +- provider upload ID and part ETag/checksum never cross the application contract. + +- [ ] **Step 2: Write failing race/response-loss tests** + +Cover: + +- initiate accepted followed by lost response and ambiguous orphan discovery; +- grant response loss/reissue; +- part response loss and acknowledgement replay; +- acknowledge versus complete; +- late part request versus complete/abort; +- complete response loss and exact HEAD/ListParts resolution; +- `409` complete semantics; +- abort response loss and orphan discovery; +- process restart with active sessions; +- expiry plus maximum in-flight horizon before cleanup eligibility. +- retirement/abort racing the admission-close and `COMPLETE_IN_PROGRESS` CAS. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*DirectMultipartCoordinatorTest' \ + --tests '*DirectMultipartRaceTest' \ + --tests '*S3DirectMultipartProviderTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because direct multipart coordination does not exist. + +- [ ] **Step 4: Implement the minimum direct multipart path** + +Reuse the sharded control ledger, but keep managed and direct states distinct. Presign one exact +part per grant. Never accept client-supplied ETag/upload ID directly at completion. Abort requires +session state/fence and produces a pending effect; a timeout remains indeterminate until resolved. +For an R2-capable primitive, obtain the full logical SHA-256 by a bounded exact-version verification +read when provider evidence is only composite; do not publish before that read passes. Register the +direct multipart delegate and every new durable state/field explicitly in the selected S3 +contribution and closed codec with golden compatibility fixtures. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 19: Qualify direct security/fault behavior without exposing an endpoint + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java` +- Modify: + `docs/registries/object-storage-readiness.yaml` + +- [ ] **Step 1: Write failing real-provider tests** + +For the exact provider/version test: + +- method, host, path, signed headers, checksum, expiry, create-only, and content constraints; +- clock-skew boundary, unhealthy-clock admission failure, and expiry below both credential horizon + and the exact attested signature-age ceiling; +- browser-visible CORS request headers and exposed completion headers; +- URL expiration and the explicit limitation that revocation is not immediate; +- direct completion verification; +- direct multipart acknowledgement/ListParts/complete/abort; +- direct multipart grant/ack close, late-request horizon, conditional complete, and exact-version + full-object digest verification (never ETag/composite substitution); +- response loss, Toxiproxy cut, concurrent replay, process restart, and orphan eligibility; +- log/trace/metric capture with zero URL/query/provider locator leakage. + +- [ ] **Step 2: Verify RED in the required MinIO lanes** + +Run: + +```bash +cd src +./gradlew \ + :adapter:outbound:objectstorage:objectStorageMinioContractTest \ + :adapter:outbound:objectstorage:objectStorageMinioFaultTest \ + --tests '*DirectTransfer*' --console=plain +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*DirectTransferCorsContractTest' --console=plain +``` + +Expected: failures until every advertised direct profile and the normal-source-set browser CORS +contract pass. Docker absence remains a failure. + +- [ ] **Step 3: Implement only missing qualified direct semantics** + +Fix provider mapping, session reconciliation, CORS evidence, and redaction needed by the advertised +profiles. Do not weaken hard-size/create-only/checksum/expiry requirements. Mark a behavior +`UNSUPPORTED` when the exact topology cannot prove it. + +- [ ] **Step 4: Re-run the required MinIO lanes GREEN** + +Run both commands from Step 2. Expected: PASS for every advertised direct profile with zero +selected skips and for the normal-source-set CORS contract; unsupported profiles have explicit +negative descriptor tests. + +- [ ] **Step 5: Compile, but do not execute, the AWS direct qualification lane** + +Approval Gate B has not yet granted external mutation authority. Compile only: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain +``` + +Expected: PASS with no observed AWS direct evidence. If represented in the registry, use R0 plus +`limitation: authority_pending`, not a new evidence status. Task 29 executes it after Gate B. No +AWS/direct R2 claim is possible in this plan because no public direct API is implemented. + +- [ ] **Step 6: Publish only truthful Batch D state** + +The registry may record R1 for exact functional profiles. Any hard-size, create-only, checksum, +CORS, or late-request behavior that is not proven remains `UNSUPPORTED`/`UNVERIFIABLE`. No row or +documentation implies that a public endpoint exists, and all direct cards remain at most R1/partial. + +- [ ] **Step 7: Verify rollback** + +Disable new grant admission, retain the session resolver, let issued grants expire through their +in-flight horizon, and reconcile/abort without deleting session records early. Managed upload and +published download remain available. + +- [ ] **Step 8: Run Batch D checkpoint** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:check --console=plain +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +bash ../.github/scripts/verify-gate-matrix.sh +``` + +Expected: PASS after the required MinIO direct lanes have passed with zero selected skips. Re-run +`ObjectStorageReadinessRegistryTest` as part of `check`; record both AWS managed/direct lanes as +authority-pending, not as passed evidence. Perform the mandatory Batch D status/Wiki capture before +Approval Gate A. + +--- + +## Approval Gate A — Scanner ownership and sample public contract + +**Approved 2026-07-29:** The user authorized continued implementation using the recommended +defaults. The deep design now records the exact decisions: test-fake scanner only, +`integrity-verified-reference`, additive POST/GET with stable `202`, Base64 SHA-256 multipart +field, required keyed idempotency with atomic TX1 reservation/intent, existing `poster:write`, +legacy compatibility profile, new V2 event without v1 removal, intentionally scoped OpenAPI +snapshot, and additive forward-only V7 dual read. + +Do not execute Tasks 20–24 until the approved design records all of the following: + +- staged scan fencing may be implemented with a test fake, but no production scanner/provider is + claimed; +- the sample's first migrated profile is either `integrity-verified-reference` or an explicitly + named scan-gated profile with a real scanner owner; +- the additive endpoint proposal + `POST /posters/{id}/imagePublications`, status, authorization, request size/media policy, and + locator-free response fields; +- the asynchronous response contract: POST always returns the same bounded `202` reservation + (opaque publication operation plus status link) replayed by `IdempotencyExecutor`; a separately + authorized GET status resource reports progress and only exposes the published reference at + READY. The POST never sometimes returns a final payload under the same idempotency record; +- whether exact file SHA-256 is supplied as a bounded multipart field or a newly registered HTTP + header, including its canonical encoding; it is required before TX1, validated again while + staging, and multipart boundary/order is never part of the semantic fingerprint; +- required `Idempotency-Key`, fingerprint scope, and the rule that the first committed + `UploadIntent` allocates/reuses the stable `ObjectOperationId`; +- the exact `IdempotencyExecutor` shape: inside one TX1 it atomically claims/completes the stable + `202` reservation and creates-or-reads the durable intent keyed by versioned HMAC scope digest; + the same HTTP invocation then consumes the request-bound producer outside TX through stage/verify + and TX2 PENDING before returning that reservation; finalize/READY is asynchronous operation-keyed + continuation, and generic replay never allocates a second operation; +- the current sample remains permission-based (`poster:write`) because Poster has no owner + attribute; do not invent per-resource ownership checks without a separate domain/schema design; +- the explicit profile/release gate for the legacy `/posters/{id}/image` endpoint; +- a consumer inventory and owner approval for the existing `poster.image-attached` broker event; + choose a new versioned event type/envelope (preferred) or an explicit bounded dual-publish + window, consumer migration evidence, rollback, and zero-consumer proof before v1 removal. Never + rename `imageKey` to `reference` under the same unversioned event contract; +- intentional OpenAPI snapshot approval; +- the additive V7 schema and forward-only rollback window. +- the compatibility model during V7: existing legacy attachments remain readable/publishable, + legacy writes are restricted to the compatibility controller/profile, and the canonical profile + writes only READY opaque references. + +If these decisions change the deep design, amend and re-review the design first. Planning this gate +does not constitute API approval. + +## Batch E — Phase 5: Staged publication and sample migration + +### Task 20: Implement staged integrity/scan/publication and application handoff fencing + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`: + `StagedObjectPublicationKernel.java`, `ObjectIntegrityVerificationService.java`, + `ObjectScanVerdictPolicy.java`, `ObjectReferencePublicationService.java`, + `ObjectPublicationHandoffService.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java` + +- [ ] **Step 1: Write failing staged-state tests** + +Prove: + +- stage returns only an `ObjectStageHandle`; +- public inspect/read/download grant reject staged/quarantined objects; +- integrity mismatch never becomes verified or published; +- a scan-required destination cannot finalize before `CLEAN`; +- `MALICIOUS` and `INDETERMINATE` fail closed; +- verdict binds exact stage, object version, scan operation, scanner policy/version, and record + revision; +- duplicate same verdict replays and stale/different verdict conflicts; +- finalize is the first operation to create an opaque published reference. +- finalize first appends an immutable manifest revision binding `ObjectId` and exact immutable + provider version/evidence, then conditionally creates/CASes the small reference-current pointer; + response loss resolves both exact records and never rebuilds from current binding defaults. + +- [ ] **Step 2: Write failing handoff/abort race tests** + +Cover: + +- claim, renew, release with monotonically increasing fence; +- stale worker cannot mark PENDING/READY or release a newer claim; +- active claim blocks abort; +- claim expiry alone does not authorize destructive abort; +- application intent first CASes `ABORT_AUTHORIZED` and then issues an exact + `ObjectAbortAuthorization`; +- late finalize versus abort authorization; +- missing application intent never triggers auto-delete; +- object without matching intent is quarantined/reported. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*StagedObjectPublicationKernelTest' \ + --tests '*ObjectScanVerdictFenceTest' \ + --tests '*ObjectPublicationHandoffRaceTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*ObjectStorageCapabilityConfigTest' \ + --tests '*ObjectStorageScanMaintenanceConfigTest' --console=plain +``` + +Expected: compilation failure because staged/handoff implementations do not exist. + +- [ ] **Step 4: Implement the minimum staged kernel** + +Use the existing semantic ports from Task 4. Integrity verification may stream the unpublished +exact version through the narrow maintenance/scan read path. Scanner policy remains an input +verdict seam, not an objectstorage-owned malware engine. The handoff service never imports +persistence/sample types and accepts only application-provided claim/authorization contracts. +Register the handoff durable family in the closed codec with golden/old-reader fixtures and expose +publication only through the selected normal mutation facade. Scan exact-version read remains a +separate `ObjectScanMaintenancePort` facade/config with explicit scanner-workflow activation; no +scan or privileged purge port is registered in the normal context. Extend the +disabled/unselected/selected `ApplicationContextRunner` matrix and prove the normal facade cannot be +cast or injected as either privileged type. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. This is R1 protocol evidence only; without a production +scanner, `object-storage-quarantine-publication` remains below R2. + +### Task 21: Add the forward-only Poster image intent and dual-read schema + +**Files:** + +- Modify: + `src/sample-portfolio/build.gradle` +- Modify: + `src/sample-portfolio/gradle.lockfile` +- Create: + `src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`: + `PosterImageUploadIntent.java`, `PosterImageUploadIntentState.java`, + `PosterImageUploadIntentStorePort.java`, `PosterImageUploadIntentClaim.java`, + `PosterImageUploadIntentConflictException.java`, `PosterImageRetirementIntent.java`, + `PosterImageRetirementIntentState.java`, `PosterImageRetirementIntentStorePort.java`, + `PosterImageIdempotencyScopeDigest.java`, `PosterImageIdempotencyScopeDigesterPort.java`, + `PosterImageIdempotencyKeyEpochPort.java`, + `PosterImageSanitizedIdempotencyContextFactory.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/`: + `PosterImageAttachment.java`, `LegacyPosterImageAttachment.java`, + `PublishedPosterImageAttachment.java`, `PosterImageReference.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/`: + `PosterImageUploadIntentEntity.java`, `PosterImageRetirementIntentEntity.java`, + `PosterImageIdempotencyKeyEpochEntity.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/`: + `PosterImageUploadIntentJpaRepository.java`, + `PosterImageUploadIntentRepositoryAdapter.java`, + `PosterImageRetirementIntentJpaRepository.java`, + `PosterImageRetirementIntentRepositoryAdapter.java`, + `PosterImageIdempotencyKeyEpochJpaRepository.java`, + `PosterImageIdempotencyKeyEpochRepositoryAdapter.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageIdempotencyKeyRingSettings.java`, `PosterImageIdempotencyConfig.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java` +- Create: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java` +- Create: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` +- Modify: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` + +- [ ] **Step 1: Register the non-skipping migration lane and locks** + +Register `posterImageMigrationTest` with Docker/PostgreSQL prerequisites that fail when absent, add +it to the container CI workflow/gate matrix, then regenerate/review the lock before using the lane +as RED: + +```bash +cd src +./gradlew :sample-portfolio:resolveAndLockAll --write-locks +./gradlew :sample-portfolio:verifyDependencyLocks --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +``` + +Expected: the task/configuration exists and locks are valid; no migration behavior is claimed yet. + +- [ ] **Step 2: Write failing migration/repository tests** + +Run real PostgreSQL/Flyway and prove: + +- fresh V1–V7 and V6→V7 upgrade; +- V6 is unchanged; +- existing `poster.image_key` rows remain readable through the internal dual-read mapper; +- new READY attachments store only bounded opaque `image_reference`; +- legacy rows map to a discriminated legacy attachment and remain readable/publishable in the + approved compatibility window; canonical writes attach only READY published references; +- pending state lives in the intent, not as an attached domain image; +- active operation/idempotency uniqueness; +- a bounded, non-reversible idempotency-scope digest (not the raw header/principal) maps retries to + the committed operation, and the stored request fingerprint detects scope reuse with new intent; +- state/revision/fence CAS permits one winner; +- `SUPERSEDED` is a durable terminal intent state and requires a same-transaction exact + losing-reference retirement row when publication already occurred; +- intent survives Poster deletion long enough to reconcile/retire; +- no object payload or presigned URL column exists; +- replacement/delete work uses a separate durable `poster_image_retirement_intent` row and cannot + be inferred from a deleted Poster or overloaded upload intent. + +The V7 intent table must include stable operation identity/epoch/destination, request fingerprint, +bounded idempotency-scope digest, expected poster version, exact content identity/media type, state, +stage handle, handoff claim/fence/expiry, published reference/version, replaced reference, +retry/error timestamps, and optimistic revision. Index the unique scope, operation, worker claims, +and expiry. Avoid a cascading FK that deletes required cleanup evidence. + +Store idempotency lookup as `(hmac_key_version, digest)`, never raw scope or plain SHA-256. The +HMAC input is a frozen `poster-image-idempotency-scope-v1` domain separator followed by +length-prefixed UTF-8 fields for tenant presence/value, principal, use-case, and Idempotency-Key; +ambiguous concatenations and tenant A/B must produce different golden vectors. Load an operator-supplied, +permission-checked versioned key-ring file from +the typed `app.poster-image.idempotency.key-ring-path` setting; do not add it to root `src/.env` or +an app-bootstrap-only env registry. The canonical production profile requires a nonempty active +key. Retain old keys for at least the maximum intent/idempotency/reconciliation horizon and rotate +overlap-first. A DB-coordinated key-epoch row is locked in the same reservation transaction: all +pods must possess the DB-active key; a stale pod fails new admission, and activation changes only +after every pod has the new retained key. Reservation queries all retained-version digests before +inserting under the active version. Tests race old/new pods across activation and prove one intent, +same-scope lookup, and no key material disclosure. Do not reuse the logging/privacy salt. + +Before invoking the generic executor, derive a storage-safe `IdempotencyContext`: the optional +tenant and principal dimensions become separately domain-separated HMAC aliases, the +`idempotencyKey` dimension becomes the full versioned scope digest, and `useCaseName` is the fixed +publication-reservation identifier. The raw client key, tenant, and principal remain request-memory +inputs only. Tenant presence/value remains part of every alias/digest, so tenant isolation is not +collapsed. Golden and ambiguous-tuple tests cover both the intent key and sanitized generic scope. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :sample-portfolio:test \ + --tests '*PosterImageReferenceTest' \ + --tests '*PosterTest' \ + --tests '*HmacPosterImageIdempotencyScopeDigesterTest' \ + --tests '*PosterImageIdempotencyConfigTest' \ + --tests '*PosterImageUploadIntentRepositoryIntegrationTest' \ + --tests '*PosterRepositoryAdapterIntegrationTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +``` + +Expected: compilation/migration failures because V7, the intent model, key-ring binding, and +non-skipping qualification lane do not exist. Docker/PostgreSQL absence is an actionable failure, +not a skip or passing default test. + +- [ ] **Step 4: Implement additive persistence only** + +Do not call object storage from Flyway, an entity callback, repository mapper, or transaction +listener. Keep `image_key` for dual read; add `image_reference`, upload intent, and retirement +intent additively. During the approved window, the domain represents legacy versus published +attachment explicitly and permits existing legacy attachments; the canonical writer accepts only +a READY opaque reference. Keep the existing `imageKey()` accessor, legacy command, and +`PosterImageAttached` event source-compatible through this task so all current main sources compile. + +- [ ] **Step 5: Verify GREEN** + +Run the exact Step 3 commands again, then verify locks: + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*IdempotencyStoreAdapterTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*PosterImageReferenceTest' \ + --tests '*PosterTest' \ + --tests '*HmacPosterImageIdempotencyScopeDigesterTest' \ + --tests '*PosterImageIdempotencyConfigTest' \ + --tests '*PosterImageUploadIntentRepositoryIntegrationTest' \ + --tests '*PosterRepositoryAdapterIntegrationTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +./gradlew :sample-portfolio:resolveAndLockAll --write-locks +./gradlew :sample-portfolio:verifyDependencyLocks --console=plain +``` + +Expected: PASS, and the `posterImageMigrationTest` result XML reports zero skipped tests. + +- [ ] **Step 6: Verify rollback window** + +The non-skipping `PosterImageV7MigrationQualificationTest` must run a V6-compatible SQL/JPA +projection against the expanded schema, including old INSERT/UPDATE/read behavior. Document that +rollback is binary-only while old code ignores additive columns; there is no down migration, +column drop, intent deletion, or object mutation. + +### Task 22: Move Poster publication through short transactions and crash-safe handoff + +**Files:** + +- Create: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java` +- Create: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java` +- Test: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepositoryTest.java` +- Modify: + `src/adapter/outbound/persistence-jpa/README.md` +- Modify: + `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationResult.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintFactory.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintCodec.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageUploadReconciliationJob.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageUploadReconciliationSettings.java`, `PosterImageObjectStorageConfig.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/`: + `PosterImageAttachmentPrepared.java`, `PosterImageAttachmentReadyV2.java`, + `PosterImagePublicationEventPublisher.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` +- Create: + `docs/evidence/object-storage/poster-image-event-consumers.md` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicyTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventContractTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationCrashMatrixTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationConcurrencyTest.java` +- Test: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageReservationAtomicityQualificationTest.java` + +- [ ] **Step 1: Write failing transaction-boundary tests** + +Use a tracking `TransactionPort` and fake object ports. Fail the test if producer invocation, +stage/inspect/finalize/abort/resolve, or any external I/O occurs while a transaction is active. +Prove the order: + +```text +TX1 reserve UploadIntent RESERVED and commit +outside TX stage/verify (and scan when configured) +object handoff claim +TX2 CAS intent RESERVED -> PENDING with matching fence and commit + append versioned AttachmentPrepared notification in the same TX +outside TX finalize publication +TX3 CAS poster expected version + intent PENDING -> READY, + insert exact replacement retirement intent and versioned AttachmentReady notification, commit + OR, when Poster CAS loses, transition this intent -> SUPERSEDED and insert an exact retirement + intent for this operation's already-published losing reference in the same transaction +release handoff claim +claim/reconcile the independent retirement intent outside TX +``` + +The committed `poster_image_upload_intent` state is the canonical work queue. A bounded polling +worker claims `PENDING` rows and renews the object handoff; the transactional outbox events are +versioned integration notifications, not the only wake-up or an object-operation journal. This +choice must be mirrored in the deep design before implementation. + +- [ ] **Step 2: Write the failing crash-gap matrix** + +Inject a process/application stop: + +- after intent commit before stage; +- after data mutation before stage receipt; +- after stage before claim; +- after claim before DB PENDING; +- after DB PENDING before finalize; +- after finalize response loss; +- after publish before DB READY; +- after DB READY before claim release; +- before and after the single TX1 commit that atomically covers generic idempotency claim, + UploadIntent create-or-read, and generic reservation completion. + +Retry/reconciler must reuse the stable operation, avoid producer replay after staged evidence exists, +attach only exact READY reference, and never delete merely because a row is absent. The +non-skipping PostgreSQL atomicity test must prove a kill/failure before commit leaves neither row, +while commit leaves both COMPLETED generic reservation and matching intent—never a durable generic +`IN_FLIGHT` row without an intent. It also races two same-scope transactions and proves the loser +replays/commits normally, and proves expired reclaim. + +A worker must not attempt to recreate request bytes for a `RESERVED` intent. If the process dies +after TX1 but before staging, a same-key/same-fingerprint HTTP retry supplies a fresh producer and +continues the same operation; without retry, bounded intent expiry may authorize abort/report, not +invent data or publish. Once exact staged evidence exists, retry skips producer invocation. + +- [ ] **Step 3: Write failing concurrency tests** + +Cover same idempotency key/same fingerprint, same key/different fingerprint, two replacement +operations against one expected Poster version, reserve-expiry versus late PENDING, abort +authorization versus finalize, and Poster deletion during replacement. Prove deterministic CAS +winners. If finalize already published before the Poster CAS loses, commit `SUPERSEDED` plus a +dedicated exact losing-reference/version retirement row in the same transaction; a thrown/rolled +back CAS path is forbidden. Prove that row survives deletion, worker takeover, response loss, and +process restart. Prove the versioned +ready event exposes an opaque reference rather than a raw key. The old +`poster.image-attached`/`imageKey` contract is unchanged; the Gate A choice controls a new event +type/envelope or bounded dual publish, with checked-in consumer inventory and rollback evidence. + +Freeze `poster-image-publication-fingerprint-v1` as length-prefixed canonical bytes over schema, +Poster ID, expected Poster version, destination/profile, normalized media type, exact declared +length, and caller-supplied full-file SHA-256. Exclude multipart boundary, part/header ordering, +filename, and transport framing. Golden vectors prove two encodings of the same multipart semantics +match, while any semantic field change conflicts. The factory runs before TX1 without consuming the +file stream; staging recomputes byte count/SHA-256 and rejects a mismatch. + +- [ ] **Step 4: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*IdempotencyStoreAdapterTest' \ + --tests '*PostgreSqlIdempotencyClaimRepositoryTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*PosterImagePublicationPolicyTest' \ + --tests '*PosterImagePublicationFingerprintTest' \ + --tests '*PosterImagePublicationEventContractTest' \ + --tests '*PosterImageObjectStorageConfigTest' \ + --tests '*PosterImagePublicationWorkflowTest' \ + --tests '*PosterImagePublicationCrashMatrixTest' \ + --tests '*PosterImagePublicationConcurrencyTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest \ + --tests '*PosterImageReservationAtomicityQualificationTest' --console=plain +``` + +Expected: new recoverable reservation/publication symbols are absent. The existing legacy +`UploadPosterImageCommand`, `UploadPosterImageUseCase`, controller, response, and wire test remain +unchanged and must still compile/pass in this task. + +- [ ] **Step 5: Implement the minimum workflow** + +The new `PublishPosterImageCommand` may carry the framework-free `ObjectContentProducer`, exact +content identity, media type, `CallBudget`, cancellation, and `IdempotencyContext`; it must not carry +`MultipartFile` or another inbound type. Keep the legacy command/use case intact until Task 23. +`PublishPosterImageUseCase` is `Idempotency.KEYED`, but the generic executor wraps only TX1 +create-or-read reservation and immediately stores the bounded stable operation/intent result. +Build `RequestFingerprint` only from the canonical semantic fingerprint above, never raw multipart +bytes/boundary. +The exact shape is +`tx.inWrite(() -> idempotencyExecutor.execute(sanitizedContext, createOrReadIntent, +reservationCodec))`; +the existing JPA idempotency store participates in that caller transaction, and the reservation is +small enough to stay inline. Thus generic claim, durable intent, and generic COMPLETED response +commit or roll back together. The POST-facing use case always returns that same `202` reservation; +it never mixes a later READY payload into the generic replay record. In that same HTTP invocation, +after TX1 commits, consume the request-bound producer to stage/verify outside TX, acquire handoff, +and commit TX2 PENDING; only then return the stable reservation. Finalize/TX3 READY is keyed by the +operation and owned by the bounded intent worker. The authorized status query reads the intent and +returns a locator-free progress/result view. No remote I/O or producer invocation occurs in TX1, +and no worker reads a request producer after the response. Allocate the operation ID only in the +committed intent and reuse it after generic replay/expiry. Application policy owns Poster +media/size, permission, and idempotency semantics; transport and destination policies do not, and +this sample does not invent resource ownership. + +Before relying on the outer transaction, add a vendor-neutral `IdempotencyClaimRepository` SPI and +implement this exact statement only in the allowed `.postgresql` package: +`INSERT ... ON CONFLICT ON CONSTRAINT uq_idempotency_scope DO UPDATE SET +id=EXCLUDED.id, request_hash=EXCLUDED.request_hash, status='IN_FLIGHT', +response_payload=NULL, response_ref=NULL, created_at=EXCLUDED.created_at, +expires_at=EXCLUDED.expires_at WHERE idempotency_record.expires_at <= :now RETURNING id`. +One returned ID means a new/expired claim won; no row means a live winner exists. A uniqueness +exception must never poison the caller transaction. + +Because executor `find` can load an expired entity before native reclaim, detach only that exact +expired `IdempotencyRecordEntity` before the claim and reload the returned ID/fingerprint before +complete; never call `EntityManager.clear()` or detach unrelated business entities. The +sample/PostgreSQL configs explicitly select this implementation; no vendor SQL enters the generic +idempotency package. Preserve the existing application port contract and prove winner/loser, +expired-find→reclaim→complete with new fingerprint/expiry/COMPLETED state, and unrelated managed +entity preservation in non-skipping real PostgreSQL. Do not emulate with a process lock. Update the +owner README/CLAUDE vendor SPI table. The same qualification queries both `idempotency_record` and +V7 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored +aliases/digests retain tenant separation. + +Reconciliation settings are constructor-bound, disabled by default, and bound batch size, +claim/renew duration, fixed delay, retry/backoff, concurrency, and shutdown grace. The disabled +context creates no scheduler/thread and performs no DB/object access. The explicit canonical sample +profile enables the new capability; the legacy-only profile remains unchanged until Task 23. + +- [ ] **Step 6: Verify GREEN** + +Run the command from Step 4 plus: + +```bash +cd src +./gradlew :sample-portfolio:test \ + --tests '*PosterControllerWireTest' \ + --tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain +``` + +Expected: PASS with both the new semantic application slice and unchanged legacy slice compiling; +no endpoint switches in this task. + +### Task 23: Add bounded multipart ingress and an approved locator-free response + +**Files:** + +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/`: + `PosterImagePublicationResponse.java`, `PosterImagePublicationStatusResponse.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/`: + `PosterImagePublicationController.java`, `LegacyPosterImageController.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageApiSettings.java`, `PosterImageApiConfig.java` +- Move: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java` + to + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/legacy/LegacyUploadPosterImageCommand.java` +- Move: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java` + to + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/legacy/LegacyUploadPosterImageUseCase.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageControllerWireTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationStatusAuthorizationTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/architecture/PosterImageIngressArchitectureTest.java` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java` +- Modify: + `src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json` +- Create: + `src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-publication-snapshot.json` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java` +- Modify, only if Approval Gate A selects a new header: + `docs/registries/headers.yaml` + +- [ ] **Step 1: Write failing bounded-ingress tests** + +Prove: + +- no `MultipartFile.getBytes()` or full file materialization; +- inbound adapter opens/closes the multipart stream inside the producer callback; +- request-bound producer is fully consumed before POST returns; any attempted use after response is + rejected, and a background worker never receives a `MultipartFile`/request stream; +- chunks do not exceed the application sink limit; +- declared/exact size and SHA-256 representation are validated; +- client disconnect/read failure/cancellation is not upload success; +- controller/producer signatures leak no `MultipartFile` into sample application/domain; +- transport multipart/body hard limit and header syntax stay inbound; Poster allowed media/logical + size/permission/idempotency stay in `PosterImagePublicationPolicy`/use case; destination + max/checksum/encryption stay in the compiled object-storage binding. Architecture tests reject + those business rules in controller/mapper/producer/configuration. + +- [ ] **Step 2: Write the failing wire/API contract** + +For the exact approved endpoint, prove: + +- required authorization and `Idempotency-Key`; +- accepted digest input and canonical mismatch error; +- POST returns `202` only after exact stage evidence and TX2 PENDING are durable, with the stable + opaque publication operation and status link replayed for the same key/fingerprint; +- validation/read/stage/TX2 failure returns the normalized 4xx/5xx and does not consume the + request stream after response; a same-key/same-body retry reuses the reservation/operation and + supplies the producer again until staged evidence exists; +- authorized GET status returns progress and, only at READY, opaque reference, size, media type, and + logical digest; operation tokens are unguessable but not authorization, missing operation is 404, + and missing `poster:write` is rejected before lookup; +- the new publication/status DTOs contain no raw key, location, bucket, path, `file://`, `s3://`, + presigned URL, provider version, or internal handle; +- legacy endpoint/response exists only under the explicit compatibility profile; +- legacy and canonical image controllers are distinct conditional beans and never active + simultaneously; disabled mode exposes neither; +- signing/direct endpoints are absent. + +The pre-existing general `PosterResponse.imageKey` and legacy `StoredObjectResponse` remain +unchanged during the approved REST compatibility window. Therefore this task claims only the new +publication surface is locator-free; removing/versioning the old field requires consumer inventory, +a separate API version/rollback approval, and new snapshot. Do not silently edit both DTO shapes. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :sample-portfolio:test \ + --tests '*MultipartObjectContentProducerTest' \ + --tests '*PosterImagePublicationControllerWireTest' \ + --tests '*LegacyPosterImageControllerWireTest' \ + --tests '*PosterImagePublicationStatusAuthorizationTest' \ + --tests '*PosterImageApiConfigTest' \ + --tests '*PosterImageIngressArchitectureTest' \ + --tests '*LegacyPosterImageUploadCharacterizationTest' \ + --tests '*PosterControllerWireTest' \ + --tests '*OpenApiDriftContractTest' --console=plain +``` + +Expected: new producer/controllers/settings/status contracts do not exist. The existing controller +still materializes bytes and the approved dual-profile snapshots are absent. + +- [ ] **Step 4: Implement the approved additive API** + +Keep each controller thin. Use the framework-free producer bridge and explicit conditional config; +never place both image mappings in one controller or use component scanning as a profile switch. +Remove stereotype auto-registration from the moved legacy use case/controller and construct them +only through the legacy condition. The legacy command/use case/controller live only in the named +allowlisted `..legacy..` slice, and update the frozen ArchUnit exception to that exact package. No +other sample application package imports `application.storage`. Do not change legacy DTO/general +Poster response shape in place. When Approval Gate A authorizes the new surface, +regenerate the snapshot intentionally: + +```bash +cd src +./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange --console=plain +``` + +Review the diff for only approved changes. The approval flag is not blanket authorization for +unrelated OpenAPI drift. + +- [ ] **Step 5: Verify GREEN** + +Run the exact Step 3 command plus: + +```bash +cd src +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: PASS in disabled, legacy-only, and publication-only profile matrices. The only old-port +import is the exact legacy slice; canonical controller/status/application paths have zero legacy +imports, and direct endpoints remain absent. + +### Task 24: Add report-first legacy adoption, replacement retirement, and removal gates + +**Files:** + +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/storage/migration/`: + `LegacyObjectAdoptionPort.java`, `LegacyObjectAdoptionRequest.java`, + `LegacyObjectAdoptionReceipt.java`, `LegacyObjectLocator.java`, + `LegacyObjectAdoptionApproval.java`, `LegacyObjectAdoptionApprovalVerifierPort.java` +- Modify: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`: + `LegacyAdoptionApprovalDocument.java`, `LegacyAdoptionApprovalCodec.java`, + `Ed25519LegacyAdoptionApprovalVerifier.java`, `LegacyAdoptionApprovalReplayRecord.java`, + `LegacyAdoptionApprovalReplayStore.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/`: + `AdoptLegacyPosterImageCommand.java`, `RetirePosterImageCommand.java`, + `ReconcilePosterImageRetirementCommand.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`: + `LegacyPosterImageAdoptionResult.java`, `PosterImageRetirementResult.java`, + `LegacyPosterImageAdoptionAuthorizationPolicy.java`, + `LegacyPosterImageAdoptionExecutionIdentity.java`, + `LegacyPosterImageAdoptionExecutionIdentityPort.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageRetirementJob.java`, `PosterImageRetirementSettings.java`, + `PosterImageRetirementConfig.java`, + `LegacyPosterImageAdoptionSettings.java`, `LegacyPosterImageAdoptionConfig.java`, + `LegacyPosterImageAdoptionMaintenanceRunner.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageLegacyMigrationIntegrationTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/LegacyPosterImageAdoptionConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageAdoptionAuthorizationTest.java` +- Test: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java` +- Create: + `docs/evidence/object-storage/poster-legacy-migration.md` + +- [ ] **Step 1: Write failing report/adoption tests** + +Prove report-first ordering: + +```text +inventory raw key +-> exact HEAD/read digest/media/size/version +-> create immutable manifest/reference with a stable adoption operation +-> DB row compare-and-swap to opaque reference +-> retain dual read +-> separately authorize old-object retirement +``` + +Missing/corrupt/retained/unknown-version legacy objects are reported/quarantined, never overwritten, +renamed, copied, or deleted automatically. A DB CAS loser leaves evidence for reconciliation and +does not publish itself as current. Re-running returns the same reference/receipt. + +- [ ] **Step 2: Write failing replacement/delete retirement tests** + +Poster replacement and deletion must create retirement work with exact reference/version and +retention/handoff fence. Business delete is logical retirement, not privileged purge. A failure to +retire cannot resurrect the Poster or silently discard cleanup work. Prove same-transaction enqueue +for TX3 winner's replaced reference, TX3 loser's already-published `SUPERSEDED` reference, and +business delete; independent claim/lease/fence/retry after the Poster row is gone; two-worker CAS; +process death during retirement; lost retirement response resolved by exact reference/version +inspect; and durable terminal/held evidence. The worker never receives +`ObjectPurgeMaintenancePort`. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStorageArchitectureContractTest' --console=plain +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LegacyObjectAdoptionServiceTest' \ + --tests '*ObjectStorageLegacyMigrationConfigTest' \ + --tests '*LegacyAdoptionApprovalVerifierTest' \ + --tests '*ObjectControlRecordCodecTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*PosterImageLegacyMigrationIntegrationTest' \ + --tests '*DeletePosterImageRetirementTest' \ + --tests '*PosterImageRetirementCrashMatrixTest' \ + --tests '*PosterImageRetirementConfigTest' \ + --tests '*LegacyPosterImageAdoptionConfigTest' \ + --tests '*LegacyPosterImageAdoptionAuthorizationTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest \ + --tests '*PosterImageRetirementQualificationTest' --console=plain +``` + +Expected: failures because adoption and retirement workflows do not exist. + +- [ ] **Step 4: Implement report-only, then reviewed apply** + +The first runnable mode emits a bounded report and performs no mutation. Apply mode requires an +explicit reviewed manifest of candidates and per-row CAS. Flyway never performs backfill. Keep old +route/binding readers and legacy objects until observation proves zero legacy reads and all +missing/corrupt cases are resolved. The application contract for this operation remains in the +deprecated legacy migration namespace and may be injected only into the named administrative +adoption use case; no normal business endpoint may use it. `LegacyObjectLocator` is capped at 1,024 +UTF-8 bytes, rejects controls, redacts `toString`/errors, never appears in adoption receipts, and is +the exact architecture-tested exception to the new semantic no-locator rule. + +The adoption use case lives in the allowlisted `..poster.migration..` package, requires the exact +administrative permission, and is assembled only in an explicit maintenance profile. Settings bind +report path, reviewed-manifest path/digest, batch size, deadline, and mode; default is disabled and +`REPORT_ONLY`. `APPLY` additionally requires a detached, canonical +`LegacyObjectAdoptionApproval` document. Its JDK-only length-prefixed codec binds the approval +schema version, adoption operation ID, exact reviewed-manifest SHA-256, legacy namespace digest, +target destination/namespace, literal mode `APPLY`, `notBefore`, expiry, nonce, and two distinct +approver key IDs. Both approvers independently sign those exact canonical bytes with Ed25519. +Trusted public keys come only from permission-checked configured files; inline or manifest-supplied +keys are rejected. The verifier checks canonical re-encoding, both signatures, distinct trusted +approvers, every binding, time window, and bounded manifest bytes before any mutation. + +After verification, a durable replay record CAS-binds the nonce to the same operation, manifest, +namespace pair, and approval digest. A terminal replay of that exact operation is idempotent; +reuse for any other operation or digest fails closed. `LegacyPosterImageAdoptionAuthorizationPolicy` +then requires the isolated maintenance execution identity, exact administrative capability, and +verified approval receipt before invoking the adoption port. The maintenance runner supplies that +identity explicitly; it neither depends on a web `SecurityContext` nor treats +`@RequiresPermission` as scheduler authorization. Call order is: + +```text +load bounded reviewed manifest and signed approval +-> verify canonical bytes, two signatures, bindings, time window, and execution identity +-> claim/replay-CAS nonce for the exact adoption operation +-> re-read and re-hash the immutable manifest +-> per-row inspect/digest and CAS adoption +-> mark exact replay record terminal with evidence +``` + +Tests fail closed for absent or malformed approval, forged signature, one signer, the same signer +twice, untrusted key, wrong operation/manifest/legacy namespace/target namespace/mode, expired or +not-yet-valid approval, nonce replay against a different binding, changed manifest bytes, missing +or mismatched maintenance identity, and normal-context invocation. `REPORT_ONLY` does not require +an APPLY approval and cannot enter the mutating path. The normal web/application context exposes +neither the raw-locator port nor an adoption entrypoint. + +Neither the adapter adoption service nor the sample adoption use case/job uses a component +stereotype. Explicit maintenance configurations assemble them only after mode/profile, reviewed +digest, signed-approval trust store, permission boundary, maintenance identity, and isolated +legacy/new namespaces validate; normal and canonical publication contexts assert zero +migration-port/verifier/replay-store/runner beans and zero side effects. The closed control codec +registers approval replay records with golden/new-reader/old-reader compatibility fixtures. + +Retirement settings are constructor-bound and disabled by default, with bounded claim/renew, +batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V7 +retirement table and calls only logical exact-reference retirement outside DB transactions. A +disabled context creates no scheduler/thread; stale fence/takeover is rejected and response loss +remains operation-keyed exact-reference/version reconciliation, not blind retry. TX3 replacement and +`DeletePosterUseCase` insert the row in the same DB transaction; its FK/retention preserves work +after Poster deletion. Physical purge remains separately privileged in Tasks 26–29. + +All three named use cases implement the repository `CommandUseCase` convention with explicit +commands/results and `@UseCaseCapability`; jobs invoke those boundaries rather than calling a +repository/port directly. Adoption carries the admin-only legacy exception, while logical +retirement/reconciliation use only opaque exact references. + +- [ ] **Step 5: Run Batch E checkpoint** + +Run: + +```bash +cd src +./gradlew \ + :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + :adapter:outbound:objectstorage:check \ + :sample-portfolio:check --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot --console=plain +``` + +Expected: PASS. Record sample workflow R1 evidence only. Do not remove legacy types, columns, +objects, readers, or endpoint merely because the new happy path passes. + +--- + +## Approval Gate B — Production AWS topology and authority + +Do not execute Batch F protected/provider mutations until an authorized deployment owner provides: + +- a pre-provisioned regional AWS S3 general-purpose bucket plus isolated disposable qualification, + backup-export, and restored-destination namespaces; +- exact account/bucket owner, region, endpoint/network path, ownership controls, Block Public + Access, versioning, lifecycle, incomplete-multipart policy, encryption/KMS, retention profile, + bounded `s3:signatureAge` policy, qualified clock-skew source, and CORS decisions; +- a workload role/default-chain credential path with least-privilege data/control/qualification/ + maintenance separation and credential-refresh evidence; +- an IaC-produced, expiry-bounded, Ed25519-signed capability attestation and trusted public key; +- permission to run non-destructive safe probes and separately authorized cleanup in the + qualification namespace; +- permission to create a bounded backup of exact test data/control versions and restore it into the + disposable restored namespace for R2 reconciliation evidence; this is not authority for a + regional production DR game day; +- KMS/S3 throttle, permission revocation, process kill, network fault, and cleanup test windows; +- CI secret handling and evidence retention; +- the exact cards/destination profiles proposed for R2. +- Object-Lock-enabled topology and separate privileged retention/hold/purge role only if the + retention card is proposed; otherwise that card remains below R2. + +The runtime must not create or alter buckets, IAM, KMS, lifecycle, versioning, Object Lock, BPA, +ownership controls, or CORS. If the authority/topology is unavailable, complete the code/tests that +do not require it and leave the exact card below R2; do not substitute MinIO or mocks. + +## Batch F — Phase 6: Production security, maintenance, and exact R2 evidence + +### Task 25: Verify signed deployment attestation and fail-closed AWS startup qualification + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/`: + `ObjectStorageDeploymentAttestation.java`, `ObjectStorageAttestationCodec.java`, + `Ed25519ObjectStorageAttestationVerifier.java`, `ObjectStorageSafeProbe.java`, + `AwsS3StartupQualifier.java`, `ObjectStorageQualificationCache.java`, + `ObjectStorageQualificationFailure.java`, `ObjectStorageClockHealth.java`, + `AwsS3VersioningPropagationQualifier.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/security/`: + `ObjectStorageCredentialPolicy.java`, `ObjectStorageEndpointPolicy.java`, + `ObjectStorageEncryptionPolicy.java`, `ObjectStorageSecurityValidator.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageAttestationVerifierTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3StartupQualifierTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/security/ObjectStorageSecurityConfigurationTest.java` + +- [ ] **Step 1: Write failing attestation tests** + +Use deterministic Ed25519 fixtures and reject: + +- invalid signature, non-canonical document, unknown/newer schema; +- expired/not-yet-valid evidence or expiry beyond configured maximum; +- wrong account/owner/bucket/region/provider/deployment identity; +- binding/policy/encryption/versioning/lifecycle/ownership/BPA/profile digest mismatch; +- missing/mismatched `versioningActivatedAt`, approved propagation-soak duration, + maximum `s3:signatureAge`, time-source identity, or qualified maximum clock skew; +- an attestation that advertises a card combination not explicitly qualified; +- key/document path traversal, symlink, world-writable file, oversize, or value leakage; +- last-known-good use without a signed grace policy. + +- [ ] **Step 2: Write failing startup/security tests** + +Prove: + +- required destination fails startup on missing/mismatched evidence; +- minimal probe uses expected owner and only the reserved qualification namespace; +- probe does bounded create/HEAD/GET/conditional-CAS/delete only when authorized; +- no provisioning/config mutation API is called; +- production requires HTTPS, approved public presign host, default-chain temporary credentials, + expected owner, private ownership/BPA, and approved encryption profile; +- new grant admission requires healthy time synchronization and an expiry strictly inside both + credential horizon minus qualified skew and the attested signature-age ceiling; +- after the attested versioning activation time plus approved soak, a bounded sentinel + create/read/new-version/exact-version-delete/delete-marker check passes before mutation admission; + a recent/unknown activation or failed sentinel keeps the destination unqualified; +- static access/secret literals, anonymous credentials, public ACL, governance bypass, plaintext + endpoint, and auto-create fail; +- disabled/unselected provider performs no attestation read, credential resolution, client + creation, DNS, or probe. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageAttestationVerifierTest' \ + --tests '*AwsS3StartupQualifierTest' \ + --tests '*ObjectStorageSecurityConfigurationTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because attestation/production qualification does not exist. + +- [ ] **Step 4: Implement bounded verification and qualification** + +Use JDK Ed25519 and strict canonical JSON. Cache exact evidence with expiry and refresh before its +horizon. Refresh failure never recompiles an existing operation against current settings. After +expiry, block new required mutations/grants; published exact-version reads continue only under an +explicit signed read-continuity policy plus live safe probe. Emit redacted audit/metrics later in +Task 28. Versioning propagation is evidence, not a sleep inside startup: compare the signed +activation instant to the approved soak and run the sentinel only after the horizon. Wire +qualification only through the selected S3 contribution; disabled/unselected contexts remain +side-effect free and the normal context still has no privileged purge bean. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 26: Implement version-aware retirement, retention evidence, and privileged exact purge + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`: + `VersionAwareObjectRetirementService.java`, `PrivilegedObjectPurgeService.java`, + `ObjectRetentionDecision.java`, `ObjectPurgeAuthorization.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3RetentionEvidenceReader.java`, `S3VersionAwarePurgeProvider.java`, + `S3PrivilegedPurgeProviderContribution.java`, `S3PrivilegedPurgeClientFactory.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeSettings.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfig.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedProviderAssembler.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PrivilegedPurgeCompositionTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/VersionAwareObjectRetirementTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/PrivilegedObjectPurgeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3RetentionEvidenceReaderTest.java` + +- [ ] **Step 1: Write failing lifecycle/privilege tests** + +Prove: + +- retirement makes a published reference unavailable through business read/grant but does not + imply physical deletion; +- exact object version, reference revision, handoff fence, retention policy revision, and purge + operation are required; +- a delete marker is not evidence that a noncurrent version was physically purged; +- active retention/legal hold yields `HELD`, not success or bypass; +- `HELD` requires exact successful `GetObjectRetention`/`GetObjectLegalHold` evidence; a generic + `403`, timeout, unavailable API, or unmapped provider error is `UNKNOWN`/fail-closed, never held; +- governance bypass is absent from the normal role/path; +- general business composition cannot obtain `ObjectPurgeMaintenancePort`; +- response loss resolves exact version state before retry; +- object/version mismatch, unknown schema, or missing authorization never deletes. +- if `object-storage-retention` is proposed at R2, versioning, exact noncurrent-version purge, + delete-marker behavior, lifecycle interaction, retention/legal-hold reads, and the split + privileged role are mandatory; otherwise the card remains below R2. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*VersionAwareObjectRetirementTest' \ + --tests '*PrivilegedObjectPurgeTest' \ + --tests '*S3RetentionEvidenceReaderTest' \ + --tests '*ObjectStoragePrivilegedPurgeConfigTest' \ + --tests '*S3PrivilegedPurgeCompositionTest' --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: compilation failure because version-aware retirement/purge does not exist. + +- [ ] **Step 3: Implement split business and maintenance paths** + +Retirement is a reference-state CAS. Purge uses a separately composed least-privilege provider and +exact version API. Read exact retention/legal-hold APIs only for profiles that claim them; map +ambiguous permission/error results to unknown. The normal application context has zero +`ObjectPurgeMaintenancePort` beans. Only an explicit protected maintenance context/configuration +with separately compiled binding, workload credentials, S3 client, contribution/assembler, and +close lifecycle may expose the privileged router. Construction happens only after selected +attestation/settings validation and never reuses or casts the normal contribution. Tests cover +disabled/unselected/invalid/selected/close and prove the normal context performs zero privileged +credential lookup/client creation. ArchUnit forbids injection outside approved maintenance/ +bootstrap packages. Keep purge disabled/report-only at composition until Task 27 and protected +security tests pass. + +- [ ] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. Do not promote the retention card from mocked tests. + +### Task 27: Add fenced reconciliation, report-first cleanup, and epoch compaction + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`: + `ObjectStorageMaintenanceLease.java`, `ObjectStorageMaintenanceLeaseStore.java`, + `ObjectStorageCleanupPlanner.java`, `ObjectStorageCleanupCandidate.java`, + `ObjectStorageMaintenanceRunner.java`, `ObjectStorageCleanupMode.java`, + `ObjectOperationReconciler.java`, `ObjectMultipartReaper.java`, + `ObjectRetiredVersionReaper.java`, `ObjectOperationEpochCompactor.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/`: + `ObjectStorageMaintenanceSettings.java`, `ObjectStorageMaintenanceConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageMaintenanceLeaseTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageCleanupPlannerTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageMaintenanceRaceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectOperationEpochCompactorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceConfigTest.java` + +- [ ] **Step 1: Write failing lease/candidate tests** + +Prove: + +- one current `(owner token, fence, expiry)` controls a destination/job; +- expired owner cannot update/delete after takeover; +- LIST discovers candidates only; exact control/data/reference/session GET establishes truth; +- age, LIST absence, worker lease expiry, or missing application row alone never authorizes delete; +- candidate needs supported schema, terminal/eligible state, exact version, retention result, handoff + authorization, replay horizon, and no active direct/scan/multipart generation; +- unknown/newer/corrupt records are report-only quarantine. + +- [ ] **Step 2: Write failing cleanup/late-operation races** + +Cover cleanup versus late finalize, grant in-flight horizon, part acknowledgement, DB PENDING CAS, +retention activation, legal hold, claim renewal, operation response loss, and process kill at every +report/quarantine/delete/control-CAS step. A stale worker must not remove an artifact committed by a +newer fence. + +- [ ] **Step 3: Write failing epoch-compaction tests** + +Prove: + +- only sealed epochs beyond all replay/indeterminate/retention horizons compact; +- an immutable rejection record is durable before per-operation tombstones are removed; +- any operation in a sealed/compacted epoch returns `OPERATION_EXPIRED`; +- active/draining epoch, live session/reference, missing old binding/policy revision, or unknown + schema blocks compaction; +- epoch tokens are never reused. + +- [ ] **Step 4: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageMaintenanceLeaseTest' \ + --tests '*ObjectStorageCleanupPlannerTest' \ + --tests '*ObjectStorageMaintenanceRaceTest' \ + --tests '*ObjectOperationEpochCompactorTest' \ + --tests '*ObjectStorageMaintenanceConfigTest' \ + --tests '*ObjectControlRecordCodecTest' --console=plain +``` + +Expected: compilation failure because maintenance coordination does not exist. + +- [ ] **Step 5: Implement report-only first** + +Default maintenance is disabled; first production activation is `REPORT_ONLY`. `QUARANTINE` and +`DELETE` require explicit reviewed settings and exact qualified provider/card evidence; `DELETE` +also remains unavailable until Task 29 protected qualification and an explicit runtime approval +token. Constructor-bound settings cover enabled/mode, lease/renew, batch size, scan/list pages, +operation deadline, retry, concurrency, and shutdown grace. Invalid/unbounded combinations fail +before any provider/list/credential work, and disabled composition creates no runner/scheduler/ +privileged bean. Persist audit decisions before destructive I/O and outcome evidence after. +Register maintenance lease/compaction record families explicitly in the closed codec with golden +and old-reader fixtures. + +- [ ] **Step 6: Verify GREEN** + +Run the command from Step 4. Expected: PASS. + +### Task 28: Add low-cardinality observability, readiness, resource bounds, and graceful lifecycle + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/observability/`: + `ObjectStorageMetrics.java`, `ObjectStorageAuditSink.java`, + `LoggingObjectStorageAuditSink.java`, `ObjectStorageTelemetryRedactor.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/`: + `ObjectStorageReadinessProbe.java`, `ObjectStorageReadinessSnapshot.java`, + `ObjectStorageReadinessFailure.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLifecycleCoordinator.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageActuatorConfig.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/observability/ObjectStorageObservabilityTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessProbeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageActuatorConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLifecycleCoordinatorTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageResourceTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageResourceBoundTest.java` +- Modify: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` +- Modify: + `.github/scripts/verify-gate-matrix.sh` +- Modify: + `docs/registries/metrics.yaml` + +- [ ] **Step 1: Register the non-skipping resource lane and locks** + +Register `objectStorageResourceTest`, add its secret-free bounded-resource job/release dependency to +the workflow/gate matrix, and lock the new configurations before behavioral RED: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +``` + +Missing resource prerequisites fail the selected task; the task never silently skips. + +- [ ] **Step 2: Write failing observability/redaction tests** + +Cover logical operations versus physical attempts, bytes/chunks, latency, outcome/certainty, +admission rejection, pool acquire, retry amplification, indeterminate age, orphan/scan/multipart +age, evidence expiry, and cleanup decisions. Tags may include only bounded card/provider/profile/ +operation/outcome values. Reject raw destination, tenant, reference, key, filename, bucket, +endpoint, request ID, URL/query, credential, ETag, upload ID, or exception message as metric tags. + +Captured logs/traces/audits must redact the same sensitive values while retaining hashed +correlation tokens and normalized outcomes. + +- [ ] **Step 3: Write failing readiness/lifecycle tests** + +Prove: + +- only enabled required destinations affect readiness; +- liveness does not depend on object storage; +- expired/mismatched qualification blocks new mutation/grant and reports exact redacted cause; +- optional destination outage follows its reviewed policy without changing required claims; +- disabled capability registers no probe/health/metrics/client; +- shutdown stops admission, drains managed callbacks within grace, persists cancellation or + indeterminate state, leaves durable direct sessions for recovery, releases maintenance lease, + closes presigner/client/executors exactly once, and leaks no thread/FD/buffer. + +- [ ] **Step 4: Write the failing resource task** + +Test increasing object size without linear heap growth; configured aggregate chunk/pool/multipart +bounds; slow producer/consumer; pool saturation; retry storm; concurrent range reads; in-flight +shutdown; direct memory, thread, and FD stability. The selected resource task may not silently skip. + +- [ ] **Step 5: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageObservabilityTest' \ + --tests '*ObjectStorageReadinessProbeTest' \ + --tests '*ObjectStorageActuatorConfigTest' \ + --tests '*ObjectStorageLifecycleCoordinatorTest' --console=plain +./gradlew :adapter:outbound:objectstorage:objectStorageResourceTest --console=plain +``` + +Expected: compilation/task failure because observability/readiness/resource/lifecycle support does +not exist. + +- [ ] **Step 6: Implement with reviewed dependencies** + +Add `io.micrometer:micrometer-core` under existing dependency management, update the lockfile, and +apply registry names/cardinality bounds. Add the Actuator API as `compileOnly` plus +`testImplementation` and register the adapter's health bridge only when Actuator is present and the +capability is enabled; the runtime consumer already supplies Actuator. The objectstorage leaf must +not depend on `sample-portfolio`. Do not add an `app-bootstrap` project edge. + +- [ ] **Step 7: Verify GREEN and locks** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +git diff -- adapter/outbound/objectstorage/gradle.lockfile +./gradlew \ + :adapter:outbound:objectstorage:test \ + :adapter:outbound:objectstorage:objectStorageResourceTest \ + :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +``` + +Expected: only the reviewed Micrometer/Actuator/resource-source-set lock delta, then PASS. + +### Task 29: Run protected AWS security/fault qualification and promote exact cards only + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageSecurityTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3SecurityQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ResponseLossQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3VersionRetentionQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3CredentialLifecycleQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ProcessCrashQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3BackupRestoreReconciliationQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/resources/object-storage/aws-provider-evidence.json` +- Modify: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` +- Modify: + `.github/scripts/verify-gate-matrix.sh` +- Modify: + `docs/registries/object-storage-readiness.yaml` + +- [ ] **Step 1: Register protected, non-skipping tasks** + +`objectStorageSecurityTest` and `objectStorageAwsQualificationTest` must require the approved +profile and exact attestation. Missing Docker/AWS/IaC/credential inputs fail when the lane is +selected. Never put account IDs, credentials, KMS material, or signed URLs in reports. + +Regenerate/review locks for the protected configurations and verify the gate matrix before any +external call: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +``` + +The protected workflow is manual/release-gated behind an approved environment and executes the +managed/common test from Task 16, direct test from Task 19, and all Phase 6 tests. Scheduled MinIO +fault remains separate. Retained artifacts are normalized card/result/evidence digests with expiry, +never secrets or raw topology. + +- [ ] **Step 2: Execute the full matrix** + +The exact AWS provider/profile/card matrix must include, as relevant: + +- real TLS/network/expected-owner/workload-role and credential refresh/rotation; +- BPA/ownership/private ACL negative tests; +- SSE-S3/SSE-KMS/DSSE profile and KMS deny/throttle/disabled-key behavior; +- conditional data/control mutation, checksum, versioning propagation soak/sentinel, delete + markers, and noncurrent exact purge; +- lifecycle/incomplete multipart; when and only when the retention card is proposed, mandatory + Object Lock, exact retention/legal-hold reads, normal-role governance-bypass denial, privileged + exact-version purge, and a generic-403 negative test proving it is not mapped to `HELD`; +- grant expiry versus credential horizon/qualified skew and an IaC-enforced bounded + `s3:signatureAge` positive/negative test; unhealthy time blocks new grant admission; +- DNS/connect/TLS/acquire/read/write/API deadline and SDK retry amplification; +- throttle, permission revocation, response drop, process halt/restart, rolling control schema; +- managed/direct single/multipart, download/range, quarantine only if a real scanner exists, + retention, and reconciliation card-specific requirements; +- bounded export/restore of exact data, manifests, reference pointers, operation/control records, + and version evidence into the authorized disposable restored namespace, followed by + reconciliation and digest/reference integrity audit; +- observability/redaction and no silent skip. + +- [ ] **Step 3: Run protected commands** + +Run in the authorized lane: + +```bash +cd src +./gradlew \ + :adapter:outbound:objectstorage:objectStorageSecurityTest \ + :adapter:outbound:objectstorage:objectStorageAwsQualificationTest \ + :adapter:outbound:objectstorage:objectStorageResourceTest \ + --console=plain +``` + +Expected: PASS only for the exact tested combinations. A partial failure leaves that card/profile at +its previous level; it does not lower requirements or borrow evidence from another card. +Regional/cluster DR is not inferred from the bounded restored-namespace test. + +- [ ] **Step 4: Update signed evidence and readiness registry** + +For each promoted row record: + +```text +card_id +provider exact type/version/deployment identity +destination profile +R2 +evidence revision and expiry +required non-skipping task names/results +limitations +attestation digest +``` + +Do not write a module-global R2 statement. In particular, scanner absence keeps +`object-storage-quarantine-publication` below R2. All direct upload/download cards remain below R2 +even if provider qualification passes because this plan implements no public direct endpoint, +authorization/rate limiting, or direct API snapshot. A reconciliation row may reach R2 only when +the bounded backup/restore qualification passes; retention may reach R2 only when every mandatory +Object-Lock/role/negative test above passes. + +### Task 30: Complete documentation, full gates, independent review, and Wiki capture + +**Files:** + +- Create: + `docs/runbooks/object-storage-startup-qualification-failed.md` +- Create: + `docs/runbooks/object-storage-managed-transfer-failed.md` +- Create: + `docs/runbooks/object-storage-indeterminate-operation.md` +- Create: + `docs/runbooks/object-storage-checksum-mismatch.md` +- Create: + `docs/runbooks/object-storage-scan-backlog.md` +- Create: + `docs/runbooks/object-storage-multipart-abandonment.md` +- Create: + `docs/runbooks/object-storage-orphan-backlog.md` +- Create: + `docs/runbooks/object-storage-retention-hold.md` +- Create: + `docs/runbooks/object-storage-credential-kms-failure.md` +- Create: + `docs/runbooks/object-storage-direct-grant-cors-incident.md` +- Create: + `docs/runbooks/object-storage-control-corruption-schema.md` +- Create: + `docs/runbooks/object-storage-cleanup-delete-kill-switch.md` +- Create: + `docs/runbooks/object-storage-local-filesystem-capacity.md` +- Create: + `docs/runbooks/object-storage-poster-handoff-stuck.md` +- Create: + `docs/runbooks/object-storage-epoch-compaction.md` +- Create: + `docs/runbooks/object-storage-backup-restore-reconciliation.md` +- Create: + `docs/runbooks/object-storage-provider-outage-upgrade.md` +- Create: + `docs/runbooks/object-storage-readiness-downgrade.md` +- Modify: + `src/adapter/outbound/objectstorage/README.md` +- Modify: + `src/adapter/outbound/objectstorage/CLAUDE.md` +- Modify: + `src/sample-portfolio/README.md` +- Modify: + `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md` +- Modify: + `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` +- Modify: + `docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md` +- Modify, only for actual fixed runtime placeholders: + `src/app-bootstrap/src/main/resources/application.yml`, + `src/.env`, `docs/registries/env-keys.yaml`, + `docs/registries/secrets-classification.yaml` +- Modify, when surfaced externally: + `docs/registries/error-codes.yaml`, `docs/registries/headers.yaml` + +- [ ] **Step 1: Write and exercise runbooks** + +Each runbook must include detection, scope, safe first actions, evidence to preserve, report-only +and admission-disable controls, exact reconciliation commands, escalation, and unsafe actions. +Exercise startup mismatch, credential expiry, KMS deny, response loss, checksum mismatch, scan +backlog, multipart/orphan cleanup, and retention hold in the relevant non-skipping lane. + +Maintain this design §40 traceability and exercise every row required by a claimed R2 card: + +| Incident family | Runbook owner | +| --- | --- | +| startup attestation/evidence mismatch, readiness downgrade | `startup-qualification-failed`, `readiness-downgrade` | +| TLS/certificate/DNS/VPC endpoint/pool saturation | `startup-qualification-failed`, `managed-transfer-failed`, `provider-outage-upgrade` | +| managed timeout/response loss/checksum | `managed-transfer-failed`, `indeterminate-operation`, `checksum-mismatch` | +| presigned URL leak/reissue/signature age/CORS drift | `direct-grant-cors-incident` | +| control corruption/newer schema/rolling reader | `control-corruption-schema` | +| scan backlog/malicious/indeterminate | `scan-backlog` | +| multipart/orphan/late grant | `multipart-abandonment`, `orphan-backlog` | +| cleanup DELETE enablement/process kill/stale fence | `cleanup-delete-kill-switch` | +| retention/legal hold/privileged purge | `retention-hold` | +| versioning suspended/delete-marker or noncurrent-version growth | `retention-hold`, `readiness-downgrade` | +| credential expiry/rotation and KMS deny/throttle | `credential-kms-failure` | +| local filesystem disk/inode/permission | `local-filesystem-capacity` | +| UploadIntent/handoff/retirement stuck | `poster-handoff-stuck` | +| epoch seal/compaction/replay expiry | `epoch-compaction` | +| bounded backup/restore reconciliation | `backup-restore-reconciliation` | +| provider outage/upgrade/rollback | `provider-outage-upgrade` | +| graceful shutdown/in-flight drain/resource leak | `managed-transfer-failed`, `readiness-downgrade` | +| operation/reference lookup hot partition or prefix imbalance | `provider-outage-upgrade`, `readiness-downgrade` | + +- [ ] **Step 2: Reconcile settings/env/secrets truthfully** + +Because `app-bootstrap` has no objectstorage project edge in this plan, do not add orphan +object-storage placeholders to its YAML or `src/.env`. Protected qualification inputs belong to CI +secret/config, not application env registries. If a future approved production owner adds the edge, +that separate plan must update `modules.json`, `app-bootstrap/build.gradle`, application YAML, +`.env`, env/secrets registries, settings tests, and `verifyEnvKeys` together. + +Document canonical settings and explicit sample-local YAML without inventing static production +credentials. Update error/header registries only for fields actually exposed by the approved sample +API. + +- [ ] **Step 3: Run focused and integration gates** + +Run: + +```bash +cd src +./gradlew :application-core:check --console=plain +./gradlew :adapter:outbound:persistence-jpa:check --console=plain +./gradlew :adapter:outbound:objectstorage:check --console=plain +./gradlew :sample-portfolio:check --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew :app-bootstrap:sampleOffTest --console=plain +./gradlew \ + :adapter:outbound:objectstorage:objectStorageMinioContractTest \ + :adapter:outbound:objectstorage:objectStorageMinioFaultTest \ + :adapter:outbound:objectstorage:objectStorageSecurityTest \ + :adapter:outbound:objectstorage:objectStorageAwsQualificationTest \ + :adapter:outbound:objectstorage:objectStorageResourceTest \ + --console=plain +``` + +Expected: all selected required tasks PASS; no selected readiness task skips. +Assert the `posterImageMigrationTest` XML has zero skipped tests. + +- [ ] **Step 4: Run repository-wide gates** + +Run: + +```bash +cd src +./gradlew test --console=plain +./gradlew check --console=plain +./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --console=plain +./gradlew \ + :application-core:verifyDependencyLocks \ + :adapter:outbound:objectstorage:verifyDependencyLocks \ + :sample-portfolio:verifyDependencyLocks \ + :app-bootstrap:verifyDependencyLocks \ + verifyCleanArchitectureDependencies \ + verifyPublicPathSnapshot \ + verifyEnvKeys --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +git diff --check +``` + +Expected: all commands PASS. If an unrelated dirty-file check fails, preserve it, report exact +ownership/evidence, and do not claim the repository-wide gate passed. + +- [ ] **Step 5: Perform independent review** + +Review at least these tracks independently: + +1. application/transport/domain boundary and module edges; +2. operation fingerprint, state machine, CAS, response-loss and crash-gap recovery; +3. provider exactness, checksum/multipart/presign/version/retention semantics; +4. configuration, credentials, TLS, attestation, redaction, resource/lifecycle safety; +5. sample DB/API migration, rollback, dual read, retirement; +6. readiness claims versus actual non-skipping evidence. + +Resolve every blocker/high (or Critical/Important) finding and rerun affected focused plus full +gates. + +- [ ] **Step 6: Capture the required LLM Wiki record** + +Before the implementation completion response, read the canonical vault instructions and update: + +```text +/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/.md +``` + +Record files, decisions, test commands/results, failures/blocks, evidence grade, and derivative +raw notes with bidirectional links. If the canonical vault is absent or inaccessible, do not use a +different clone; record the exact block in the plan/design and final response. + +- [ ] **Step 7: Report exact completion** + +The handoff must list changed files, implementation slices, exact commands/results, failures or +unrun protected lanes, Wiki capture, card/provider/profile/evidence tuples, rollback posture, and +remaining gates. Never use “Objectstorage R2” as an unqualified completion statement. + +--- + +## Phase 7 follow-up plan, not executable here + +Create a separately approved plan only after Phase 6 evidence exists. It must cover: + +- multi-node operation/maintenance fencing and failover; +- rolling writer/reader schema compatibility and old binding/policy restoration; +- regional/cluster disaster-recovery game day and failover restore integrity audit, building on but + not replaced by Phase 6's bounded single-destination restored-namespace R2 test; +- sustained load, quota/capacity exhaustion, credential/KMS rotation under load; +- provider upgrade/rollback and operational game day; +- a split/no-split ADR based on actual dependency/release/security/runtime divergence. + +Until that plan passes, no exact card may claim R3. Disabling optional cards and returning new +admission to the last qualified provider/schema revision is the rollback posture; live operation, +reference, session, and audit records remain readable/reconcilable. diff --git a/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md b/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md index 6a7364f..2669121 100644 --- a/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md +++ b/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md @@ -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 RED–GREEN 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; diff --git a/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md b/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md new file mode 100644 index 0000000..9670e96 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md @@ -0,0 +1,4203 @@ +# Object Storage Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: Batch A–D 구현 체크포인트 통과, Approval Gate A 대기, R2 미구현 +- 독립 재리뷰: 완료 — 아키텍처 및 문서 실행성 blocker 0, high 0 +- LLM Wiki capture: 정본 vault + `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-objectstorage` +- 현재 구현 수준: provider-neutral kernel + `filesystem-local-dev` managed single R1, + S3 managed/direct primitives, exact MinIO mutation profiles `UNSUPPORTED`, + legacy whole-object filesystem/S3-MinIO 격리 +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- 구현 계획: + [Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md) +- 참고 설계: + [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md), + [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md), + [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) + +## 0. 구현 상태 + +### Approval Gate A 결정 — 2026-07-29 + +사용자의 구현 계속 승인에 따라 Batch E의 public/sample 계약을 다음과 같이 고정한다. + +- production scanner는 주장하지 않는다. 스캔 경계는 test fake만 제공하고 첫 sample + destination profile은 `integrity-verified-reference`로 한다. +- canonical surface는 additive + `POST /posters/{id}/imagePublications`와 별도 authorized GET status resource다. 저장소의 + AIP-122 경로 규칙에 따라 collection segment에는 kebab-case를 사용하지 않는다. + POST는 항상 동일한 bounded `202` reservation을 반환하며 READY 결과를 같은 idempotency + response에 섞지 않는다. +- 정확한 full-file SHA-256은 canonical Base64 형식의 bounded multipart field로 받고 TX1 전에 + semantic fingerprint에 포함한 뒤 staging 중 다시 검증한다. +- `Idempotency-Key`는 필수다. 첫 committed UploadIntent가 stable operation ID를 할당하며 + generic idempotency reservation과 intent는 같은 TX1에서 원자적으로 commit한다. +- sample authorization은 기존 `poster:write` permission을 유지한다. 존재하지 않는 Poster + owner 속성을 발명하지 않는다. +- legacy `/posters/{id}/image`는 명시적 compatibility profile에서만 유지한다. canonical과 + legacy controller는 동시에 활성화하지 않는다. +- 기존 `poster.image-attached`/`imageKey` 이벤트는 변경하거나 제거하지 않는다. canonical + 흐름은 새 versioned V2 event를 사용하며 외부 zero-consumer를 주장하지 않는다. +- OpenAPI 승인은 새 publication/status surface에만 한정한다. 기존 DTO의 locator-shaped + 필드는 compatibility window 동안 그대로 둔다. +- V7은 additive, forward-only다. `image_key`와 legacy reader를 유지하고 canonical writer는 + READY opaque reference만 기록한다. rollback은 구 binary가 additive columns를 무시하는 + binary-only window이며 down migration이나 자동 object mutation은 없다. + +2026-07-28 Batch D 체크포인트 기준 다음 범위가 구현되었다. + +- `application-core`의 framework/provider-neutral object-storage identity, opaque reference, + bounded content callback, digest/range, request/receipt/outcome, capability requirement; +- managed, staged, direct single/multipart, scan-maintenance, purge-maintenance를 분리한 narrow port; +- 모든 mutation의 `ObjectOperationKey`, strict reference grammar/checksum, transient grant redaction; +- application contract의 framework/provider/transport/persistent-locator purity 검사; +- legacy port/receipt의 removal-boundary deprecation과 sample legacy import freeze; +- deterministic `data/v1`/`control/v1` namespace, reference/handle/fingerprint/policy codec, + operation epoch와 immutable route/policy snapshot; +- strict canonical JSON control envelope와 operation/manifest/reference/multipart record, + reserve/replay/conflict/pending-effect state kernel; +- bounded immutable local create, exact inspect/version, full/range transfer, conditional retire, + create resolution, symlink/path confinement, restrictive permissions와 single-process CAS; +- constructor-bound `app.object-storage` settings, compile-before-construction provider + contribution/assembler, destination/retained-route routers, disabled/unselected/invalid/close + side-effect matrix; +- explicit canonical legacy subgroup와 old alias isolation, sample local/test explicit activation; +- exact nine-card readiness registry. `filesystem-local-dev` managed single upload/download만 R1, + 나머지는 R0; +- pinned AWS SDK `2.30.0` conditional/checksum/expected-owner API characterization과 explicit + `NettyNioAsyncHttpClient` policy/factory/lifecycle; +- exact AWS/MinIO binding, bounded async transport, managed S3 single/multipart/control primitives, + response-loss resolution, selected-only provider composition; +- durable direct single/multipart session and grant records, S3 presigner lifecycle, exact part + acknowledgement, completion/abort fences, terminal exact-version replay; +- digest-pinned MinIO contract/fault lanes and protected AWS compile-only lane. Exact MinIO는 + create-only PUT/complete 조건을 무시하므로 managed/direct mutation profile을 + `UNSUPPORTED`로 유지하며 readiness를 올리지 않는다. + +증거는 [Batch A checkpoint](../../evidence/object-storage/batch-a-checkpoint.md), +[Batch B checkpoint](../../evidence/object-storage/batch-b-checkpoint.md), +[Batch C checkpoint](../../evidence/object-storage/batch-c-progress.md), +[Batch D checkpoint](../../evidence/object-storage/batch-d-checkpoint.md)에 기록했다. + +Batch B LLM Wiki capture는 정본 vault +`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`와 그 부모 +`/home/donghyeon/workspace/ai-tool/`가 이 환경에 존재하지 않아 차단되었다. 필수 +`raw/branch-notes/codex-objectstorage-production-capability.md`와 파생 raw 문서를 생성하거나 +갱신할 수 없었고, 이름이 비슷한 비정본 clone은 사용하지 않았다. + +legacy runtime 범위는 별도로 유지된다. + +- `application-core`의 범용 `ObjectStoragePort`; +- caller가 지정한 raw key에 `byte[]`를 put/get/delete/exists하는 계약; +- local filesystem adapter; +- synchronous AWS SDK v2 `S3Client`를 사용하는 S3/MinIO adapter; +- 명시적 legacy-only filesystem 또는 S3 Spring composition; +- filesystem unit test, mocked S3 unit test, Testcontainers MinIO integration test; +- module registry가 허용한 `application-core`, `shared-contract` 의존성. + +아직 구현되지 않은 runtime/provider 범위: + +- staged upload, quarantine, scan, publication; +- public presigned upload/download endpoint와 inbound authorization; +- R2 수준의 exhaustive multipart/direct response-loss·race·restart qualification; +- lifecycle, versioning, retention, legal hold 검증; +- multi-node linearizable control CAS와 production unknown-outcome reconciliation; +- production credential, encryption, TLS, retention/Object Lock qualification; +- orphan/abandoned multipart/retired object reaper; +- database와 object storage 사이의 crash-safe workflow; +- 운영 metric, trace, audit, runbook; +- AWS sandbox fault/security evidence. + +따라서 현재 MinIO round-trip test가 통과하더라도 S3 production readiness를 의미하지 않는다. +Batch B의 R1은 exact local functional topology 증거다. Multi-node CAS, power-loss durability, +S3/MinIO qualification, R2 또는 운영 준비 완료를 뜻하지 않는다. + +## 1. 설계 판정 + +현재 구현은 개발 편의를 위한 blob CRUD 예제다. + +```text +MultipartFile.getBytes() + -> UploadPosterImageCommand(byte[]) + -> DB write transaction 안에서 ObjectStoragePort.put(raw key, byte[]) + -> provider final object overwrite + -> aggregate에 raw key 저장 + -> file:// 또는 s3:// 내부 locator를 HTTP 응답으로 반환 +``` + +목표는 bucket/key CRUD wrapper가 아니다. 목표는 다음 capability다. + +> 제한된 크기의 content를 bounded streaming으로 저장·검증하고, private immutable +> reference를 통해 공개 상태와 version을 추적하며, direct transfer와 multipart의 +> 불확정 결과까지 재조정할 수 있는 production object publication capability + +선택한 핵심 구조는 다음과 같다. + +1. Application은 bucket, path, endpoint가 아닌 `ObjectDestinationId`를 선택한다. +2. 모든 mutation은 안정적인 `ObjectOperationId`와 canonical request fingerprint를 가진다. +3. caller는 raw provider key를 만들지 않는다. Adapter가 managed namespace 아래 immutable key를 + 생성한다. +4. Application과 domain은 provider locator 대신 opaque `ObjectReference`를 저장한다. +5. upload/download는 동기식 bounded chunk callback을 baseline으로 하며 adapter가 resource + lifecycle을 소유한다. +6. 업로드 완료, integrity 검증, malware scan, business attachment, public visibility를 서로 다른 + 상태로 모델링한다. +7. publication은 public ACL 변경이 아니라 private object에 대한 durable reference/manifest의 + 상태 전이로 구현한다. +8. provider 응답 유실은 임의 retry가 아니라 `INDETERMINATE`로 분류하고 먼저 reconcile한다. +9. ETag를 whole-object checksum 또는 portable version으로 간주하지 않는다. +10. database와 object storage 사이의 원자적 transaction을 주장하지 않는다. +11. provider 지원 수준은 설정 이름이 아니라 startup qualification과 CI evidence로 판정한다. +12. 사용하지 않는 provider는 client, thread, scheduler, directory 또는 network side effect를 + 만들지 않는다. + +## 2. 상위 설계 및 기존 capability 설계와의 관계 + +상위 설계의 §13.3은 다음 cross-capability baseline을 이미 정했다. + +- streaming, head, range; +- checksum과 conditional mutation; +- bounded presigned transfer와 multipart cleanup; +- encryption, TLS, endpoint, region, credentials; +- lifecycle, versioning, retention; +- quarantine와 scan 전 publication 금지; +- payload/metadata limit; +- explicit staged lifecycle; +- database rollback이 object write를 되돌린다고 가정하지 않기. + +이번 문서는 그 방향을 구현 계획으로 변환할 수 있도록 다음 결정을 추가한다. + +- 정확한 port 분리와 framework-free callback signature; +- logical destination, operation, object, reference, version의 identity; +- immutable data key와 mutable control record의 경계; +- state machine, request fingerprint, result taxonomy; +- managed upload, direct upload, multipart, download의 protocol; +- provider-neutral 보장과 AWS/MinIO/filesystem별 차이; +- provider capability descriptor와 startup qualification; +- presigned URL의 bearer-secret 취급 및 검증 후 publish; +- database attachment workflow의 commit 순서와 crash gap; +- version-aware delete, retention hold, orphan cleanup; +- exact readiness card와 R0/R1/R2/R3 증거; +- 기존 `ObjectStoragePort`와 sample-portfolio migration; +- idempotency response offload 계약과 sibling adapter boundary. + +기존 심화 설계에서 재사용하는 방식은 다음과 같다. + +| 기존 설계 | 재사용하는 결정 | Object Storage에서 달라지는 점 | +| --- | --- | --- | +| Redis | exact activation, provider/card readiness, fail-closed binding | data plane이 대용량 stream이며 object mutation의 unknown outcome을 별도로 다룬다. | +| Fileserver | stable operation ID, staged publication, immutable naming, reconciliation | rename 대신 object+manifest/reference 전이를 사용하고 multipart/presign이 추가된다. | +| HTTP Client | body lifecycle, timeout budget, retry amplification, secret-safe observability | provider SDK retry와 multipart part 단위 resource budget을 함께 제한한다. | + +이 문서와 상위 문서가 충돌하면 Object Storage 구현 세부에는 이 문서를 적용한다. 모듈 +dependency edge에는 언제나 `src/config/architecture/modules.json`이 우선한다. + +## 3. 현재 코드의 증거 기반 진단 + +### 3.1 Application contract + +| 영역 | 현재 구현 | 운영상 의미 | +| --- | --- | --- | +| Identity | caller supplied `String key` | tenant, destination, ownership, generation 경계가 없다. | +| Upload | `put(String, byte[], String)` | content 전체를 heap에 적재하고 overwrite한다. | +| Download | `Optional get(String)` | large object와 range/backpressure를 지원하지 않는다. | +| Existence | `boolean exists(String)` | not-found와 forbidden/provider failure를 충분히 구분하지 못한다. | +| Delete | unconditional `void delete(String)` | version, precondition, retention hold, unknown outcome이 없다. | +| Receipt | key, size, content type, provider `URI` | `file://`와 `s3://bucket/key`가 application/HTTP로 유출된다. | +| Error | primitive/SDK exception 혼합 | retry와 reconciliation 결정을 application이 할 수 없다. | + +### 3.2 Filesystem adapter + +현재 `FilesystemObjectStorageAdapter`는 다음 문제를 가진다. + +- constructor에서 base directory를 즉시 생성한다; +- `Files.write(final, bytes)`로 기존 파일을 truncate/overwrite한다; +- temp, exclusive create, file force, directory force가 없다; +- `readAllBytes`로 전체 content를 heap에 적재한다; +- content type과 checksum을 영속화하지 않는다; +- `normalize().startsWith(baseDir)` lexical check만 사용한다; +- nested symlink와 TOCTOU 탈출을 막지 못한다; +- `"."`, `"a/.."`처럼 root alias로 normalize되는 key가 가능하다; +- root alias delete가 base directory 자체를 대상으로 삼을 수 있다; +- file permission, quota, inode, retention, orphan cleanup이 없다; +- absolute `file://` URI를 receipt에 넣는다. + +따라서 이 provider는 현재 local developer fixture이지 persistent production store가 아니다. + +### 3.3 S3/MinIO adapter + +현재 `S3ObjectStorageAdapter`는 다음 문제를 가진다. + +- synchronous `S3Client`와 `RequestBody.fromBytes`/`getObjectAsBytes`만 사용한다; +- put이 unconditional overwrite다; +- checksum, size precondition, metadata schema가 없다; +- exact version/ETag을 receipt에 보존하지 않는다; +- conditional create/delete와 reconciliation이 없다; +- presign과 multipart가 없다; +- SSE/KMS, expected bucket owner, ownership/BPA qualification이 없다; +- finite API call/attempt/acquire/read/write timeout을 명시하지 않는다; +- provider SDK exception이 application boundary를 통과할 수 있다; +- missing object mapping이 operation마다 일관되지 않다; +- `s3://bucket/key` URI 생성이 성공한 write 뒤에 별도로 실패할 수 있다; +- startup `HEAD bucket` 실패가 404이면 runtime identity로 bucket을 생성할 수 있다. + +같은 adapter가 endpoint override와 path-style 설정만으로 AWS S3와 MinIO를 “동일한 backend”로 +취급한다. 이는 protocol happy path 재사용에는 유용하지만 consistency, checksum, conditional +request, versioning, retention, encryption, error 의미론의 동등성을 증명하지 않는다. + +### 3.4 Settings와 activation + +현재 설정은 다음 production-unsafe default를 가진다. + +```yaml +backend: filesystem +base-path: ./.data/objectstorage +bucket: ca-skeleton +endpoint: http://localhost:9000 +region: us-east-1 +path-style-access: true +auto-create-bucket: true +``` + +- filesystem bean이 `matchIfMissing=true`로 활성화된다; +- S3 설정이 없으면 local plaintext MinIO를 가리킨다; +- access key 하나만 있으면 secret key pair validation 없이 static provider를 만든다; +- production bucket을 runtime startup에서 생성할 수 있다; +- `app-bootstrap`은 registry상 objectstorage leaf에 의존하지 않지만 sample application의 넓은 + component scan에서는 configuration이 발견된다; +- Docker runtime의 read-only root와 `./.data/objectstorage` default가 맞지 않는다; +- canonical env/settings/secrets registries에 object storage key가 등록되지 않았다. + +### 3.5 Sample workflow + +`sample-portfolio`의 image upload는 다음 failure gap을 가진다. + +```text +begin DB transaction + load Poster + overwrite deterministic final object key + attach raw key + save Poster + enqueue outbox +commit DB transaction +``` + +- DB rollback이 이미 완료된 object overwrite를 복원하지 못한다; +- 기존 image를 overwrite했으므로 compensation으로 delete해도 이전 version을 복구하지 못한다; +- upload 시간 동안 DB transaction과 connection을 점유한다; +- concurrent transaction loser도 final object를 바꿀 수 있다; +- `MultipartFile.getBytes()`가 inbound와 application 양쪽에서 전체 heap materialization을 만든다; +- client가 보낸 content type을 검증 없이 신뢰한다; +- delete use case는 Poster만 삭제하고 object lifecycle을 처리하지 않는다; +- aggregate와 public DTO에 raw key가 저장/노출된다; +- scan, checksum, quarantine, ready 상태가 없다. + +### 3.6 별도 persistence idempotency blob seam + +`adapter:outbound:persistence-jpa`에는 idempotency response body를 외부 object store로 offload할 +수 있다는 별도 interface가 있다. Object Storage leaf가 그 sibling adapter interface를 +구현하면 registry edge와 Clean Architecture 방향을 위반한다. + +이번 baseline에서 이 용도는 제외한다. 향후 필요하면: + +1. application 의미인 response-blob 계약을 `application-core`에 둔다; +2. persistence adapter와 objectstorage adapter가 각각 application port만 의존한다; +3. orchestration은 application service 또는 composition root가 담당한다; +4. objectstorage가 persistence leaf의 type을 import하지 않는다. + +## 4. 범위와 명시적 비범위 + +### 4.1 전체 설계 범위 + +- bounded streaming managed upload/download; +- single contiguous range read; +- metadata/head와 exact version; +- SHA-256 content digest와 provider transport checksum; +- immutable create와 conditional mutation; +- private staged object와 durable publish reference; +- server-mediated transfer; +- short-lived presigned PUT/GET; +- adapter-owned managed multipart와 optional direct multipart; +- quarantine/scan integration seam; +- version-aware retirement/delete; +- orphan, abandoned multipart, indeterminate operation reconciliation; +- filesystem-local-dev, AWS S3 general-purpose, version-pinned MinIO provider cards; +- TLS, credentials, ownership, encryption, endpoint, lifecycle startup validation; +- resource budgets, timeout, retry, graceful shutdown; +- readiness, metrics, trace, audit, CI evidence; +- sample image attachment migration protocol. + +`filesystem-local-dev`는 이 전체 설계의 R0/R1 개발 provider일 뿐 R2 대상이 아니다. R2 claim은 +exact AWS S3 또는 qualified MinIO/deployment provider와 §32의 개별 card 조합에만 부여한다. + +### 4.2 Optional capability + +다음은 destination이 요구하고 provider card가 증명할 때만 활성화한다. + +- browser POST policy; +- SSE-KMS 또는 DSSE-KMS; +- Object Lock retention/legal hold; +- provider-side copy; +- provider checksum algorithm 추가; +- filesystem-local-persistent; +- direct multipart upload; +- customer-managed public download domain; +- provider notification을 이용한 reconciliation hint; +- cross-region replication을 고려한 secondary verification. + +Optional capability 부재를 silent emulation하지 않는다. 요청 시 +`UNSUPPORTED_CAPABILITY`로 실패한다. + +### 4.3 비범위 + +- generic bucket CRUD/list console; +- runtime bucket 생성, lifecycle/IAM/KMS/IaC provisioning; +- public-read ACL 또는 website hosting; +- CDN/cache invalidation; +- generic file server 또는 mounted drop-zone; +- inbound object-created event consumer; +- scanner/AV engine 자체 구현; +- media transcoding, thumbnail 생성, EXIF business policy; +- arbitrary provider endpoint를 request마다 선택하는 기능; +- database와 object store의 distributed transaction 또는 exactly-once claim; +- backup/restore 시스템 자체; +- Glacier restore workflow; +- S3 Express One Zone/directory bucket; +- Multi-Region Access Point와 cross-region failover; +- provider replication을 application consistency로 일반화; +- unlimited multi-range response; +- S3 Select/object SQL; +- unbounded user-specified metadata/tag; +- raw object key를 public API로 제공하는 기능. + +## 5. HARD invariants + +다음 항목은 구현 선택이 아니라 위반 시 중단하는 불변식이다. + +1. `domain-core`에는 Spring, AWS SDK, `Path`, `URI`, stream, transport DTO를 넣지 않는다. +2. `application-core` port에는 AWS SDK request/response, `S3Client`, provider exception을 노출하지 + 않는다. +3. inbound `MultipartFile`, servlet stream, WebFlux publisher를 application command로 전달하지 + 않는다. +4. controller가 bucket/key를 만들거나 SDK/repository/object persistence entity를 호출하지 않는다. +5. caller가 provider-relative raw key, bucket, base path, endpoint를 선택하지 않는다. +6. Destination ID는 bounded registered value이며 untrusted inbound/tenant input이 임의 destination을 + 선택하지 않는다. +7. object reference는 locator도 authorization token도 아니다. +8. presigned URL은 bearer secret으로 취급하고 log, trace, metric, audit payload에 기록하지 않는다. +9. 모든 production mutation은 stable operation ID와 canonical fingerprint를 가진다. +10. R2 write fingerprint는 exact length + full content digest 또는 검증 가능한 immutable source + revision을 포함한다. +11. 같은 operation ID에 다른 fingerprint가 오면 conflict이며 기존 결과를 반환하지 않는다. +12. 기본 upload는 immutable create-only다. unconditional overwrite는 baseline에 없다. +13. successful SDK response와 business publication을 같은 상태로 간주하지 않는다. +14. ETag를 portable checksum 또는 multipart 전체 MD5로 간주하지 않는다. +15. partial/truncated upload·download 또는 consumer failure를 success receipt로 만들지 않는다. +16. content size, chunk size, part size/count/concurrency, metadata 수와 길이에 finite limit가 있다. +17. timeout/retry 후 mutation 결과가 확정되지 않으면 phase-specific `INDETERMINATE`이며 blind + retry하지 않는다. +18. database transaction 안에서 remote object transfer를 실행하고 rollback 원자성을 주장하지 + 않는다. +19. unpublished/quarantined object에 public/direct download grant를 발급하지 않는다. +20. scan verdict가 필요한 destination은 `SCAN_CLEAN` 전 publish하지 않는다. +21. object lifecycle/retention/hold를 application business 삭제로 우회하지 않는다. +22. versioned bucket의 delete marker 생성을 physical purge라고 보고하지 않는다. +23. cleanup은 owned namespace와 known schema만 처리하고 newer/unknown record를 삭제하지 않는다. +24. LIST 결과만으로 direct lookup, ownership, completion truth를 판정하지 않는다. +25. production startup은 bucket, policy, lifecycle, public access setting을 자동 수정하지 않는다. +26. disabled capability는 bean/client/thread/scheduler/network/directory side effect가 0이다. +27. filesystem과 S3-compatible provider가 AWS S3와 동등하다고 일반화하지 않는다. +28. module dependency는 registry의 `allowed_dependencies`만 따른다. +29. provider/card별 증거 없이 module 전체를 R2라고 부르지 않는다. +30. readiness는 exact required destination/provider/capability mismatch에서 fail closed한다. +31. metric/tag/log에 object key, bucket, tenant/user ID, original filename, content, URL을 넣지 않는다. +32. verification과 Wiki capture 또는 명시적 capture 차단 사유 없이 완료를 주장하지 않는다. + +## 6. 대안 검토 + +### A. 현재 CRUD port에 InputStream만 추가 + +기각한다. + +- caller와 adapter 사이 close ownership이 모호하다; +- repeatability와 retry 가능성을 표현하지 못한다; +- raw key/overwrite/locator leak이 남는다; +- direct upload, multipart, staged publish를 같은 의미로 섞게 된다. + +### B. 범용 `ObjectStoragePort` 하나에 모든 operation 추가 + +기각한다. + +- read-only use case도 delete/presign/multipart 권한을 가진 interface를 주입받는다; +- provider optional capability가 nullable method와 runtime branch로 퍼진다; +- 테스트와 readiness가 “module on/off” 한 단계로 뭉개진다. + +Operation family별 작은 port를 사용하고 destination capability compile 단계에서 조합한다. + +### C. Application에 AWS presigner와 multipart upload ID 노출 + +기각한다. + +- provider 교체와 테스트가 불가능해진다; +- provider upload ID와 part ETag가 domain state로 퍼진다; +- security/expiry/header 조건을 adapter 밖에서 조립하게 된다. + +Application은 opaque `DirectTransferSessionId`, `PartReceiptToken`, typed grant만 본다. + +### D. 업로드 후 final key로 copy하여 publish + +기본 전략으로 기각한다. + +- 대용량 object copy 비용과 latency가 추가된다; +- copy response loss의 unknown outcome이 하나 더 생긴다; +- KMS/metadata/version semantics가 복잡해진다. + +기본은 immutable private data object를 그대로 두고 durable reference/manifest만 publish한다. +보안 등급이나 destination이 physical namespace 분리를 요구할 때만 optional copy capability를 +사용한다. + +### E. object tag 또는 ACL 변경을 publication truth로 사용 + +기각한다. + +- tag/ACL policy와 cache/authorization coupling이 커진다; +- S3-compatible provider 의미가 일치하지 않는다; +- public access 실수의 blast radius가 크다. + +모든 bucket은 private이고, application authorization 후 server stream 또는 short-lived grant로 +전달한다. + +### F. DB transaction 안에서 upload하고 실패 시 delete + +기각한다. + +- delete도 실패하거나 indeterminate일 수 있다; +- overwrite된 기존 object는 복구되지 않는다; +- DB lock/connection 보유 시간이 content 크기에 비례한다. + +외부 transfer와 짧은 DB transaction을 단계별로 분리하고 durable recovery state를 둔다. + +### G. provider별 Gradle leaf 즉시 분리 + +초기에는 보류한다. + +현재 registry의 19개 leaf와 dependency direction을 유지하며 한 physical leaf 안에서 package, +settings, test suite, readiness card를 분리한다. 다음 조건 중 둘 이상이 생기면 별도 설계로 +leaf split을 검토한다. + +- provider별 독립 release cadence; +- SDK/security patch 주기가 현저히 다름; +- runtime image에서 선택하지 않은 SDK를 제거해야 함; +- credential/IAM 또는 deployment owner가 분리됨; +- provider별 test/CI 비용이 기본 pipeline을 과도하게 지연함; +- registry edge가 실제로 달라짐. + +### H. filesystem을 S3 emulator로 승격 + +기각한다. + +Filesystem은 local development와 provider-neutral contract 일부를 빠르게 검증하는 수단이다. +ETag, multipart, presign, versioning, retention, KMS를 흉내 내어 R2 증거로 사용하지 않는다. + +## 7. 목표 아키텍처 + +```text +adapter:inbound:web + - multipart parsing / request validation + - bounded ingress bridge + - authorization mapping + | + v +sample/application orchestration + - business consent and ownership + - attachment state + - DB transaction boundaries + | + v +application-core object storage ports + - publication / inspection / transfer / deletion / direct transfer + - framework-free IDs, request, receipt, error + | + v +adapter:outbound:objectstorage + +-- binding compiler / capability registry + +-- provider-neutral operation kernel + +-- durable control-plane codec/store + +-- reconciliation / cleanup engine + +-- filesystem-local-dev provider + +-- AWS S3 general-purpose provider + `-- version-pinned MinIO provider + | + v +private provider data plane + private control namespace +``` + +Inbound stream과 outbound storage stream을 직접 결합하지 않는다. Application callback 경계가 +다음 책임을 분리한다. + +- inbound adapter: transport framing, multipart limit, client disconnect; +- application: business authorization, destination 선택, operation identity; +- objectstorage adapter: chunk budget, hashing, SDK lifecycle, retry, stage/publish; +- provider: durable byte/object primitive. + +## 8. 모듈과 계층 소유권 + +### 8.1 `domain-core` + +- provider 독립적인 business entity와 invariant만 소유한다; +- object storage concept가 business에 필요하면 opaque string wrapper 수준의 domain reference만 + 둘 수 있다; +- checksum, bucket, key, presign, multipart, scan vendor result를 소유하지 않는다. + +### 8.2 `application-core` + +- outbound port와 framework-free value object; +- logical destination, operation ID, object reference, version token; +- content producer/consumer callback; +- common request/result/error taxonomy; +- application-level deadline와 cancellation signal; +- capability requirement. + +Business별 media type 허용, 사용자 소유권, 공개 승인, attachment 교체 정책은 sample 또는 실제 +application layer가 소유한다. 공통 adapter limit는 business policy의 대체물이 아니다. + +### 8.3 `adapter:outbound:objectstorage` + +- settings binding과 exact validation; +- provider selection과 effective capability compilation; +- internal key/reference/manifest codec; +- SDK/client/presigner lifecycle; +- streaming, hashing, conditional operation; +- operation journal과 reconciliation; +- cleanup/retention execution; +- provider error normalization; +- health/metric/trace/audit instrumentation. + +Malware clean/malicious 판정 정책을 adapter configuration이나 mapper에 넣지 않는다. + +### 8.4 `adapter:inbound:*` + +- HTTP body/multipart limit; +- upload command 생성; +- request content type/name을 untrusted input으로 다룸; +- callback을 통해 bounded chunk를 application에 전달; +- direct transfer grant를 안전한 response DTO로 매핑; +- raw reference 또는 URL logging 방지. + +### 8.5 `sample-portfolio` + +- image attachment business lifecycle; +- Poster ownership과 permission; +- 허용 media type/size/image decode 정책; +- pending/ready/retired attachment 상태; +- DB transaction/outbox orchestration; +- sample consumer test. + +Production leaf가 sample type에 의존하지 않는다. + +### 8.6 `app-bootstrap` + +- 실제 runtime에서 objectstorage leaf를 classpath에 넣을지 결정; +- required destination/card와 environment profile을 composition; +- startup readiness exposure; +- business use case를 구현하지 않는다. + +## 9. Application 계약 + +### 9.1 Port 분리 + +초기 target interface family는 다음 의미를 가진다. 이름은 구현 계획에서 Java naming 규칙에 +맞춰 확정하되 의미를 합치지 않는다. + +```java +public interface ManagedObjectPublicationPort { + ObjectPublishReceipt publish( + ObjectPublishRequest request, + ObjectContentProducer producer); +} + +public interface ObjectInspectionPort { + Optional inspect(ObjectReference reference); +} + +public interface ObjectTransferPort { + ObjectReadReceipt transfer( + ObjectReadRequest request, + ObjectContentConsumer consumer); +} + +public interface ObjectRetirementPort { + ObjectMutationReceipt retire(ObjectRetireRequest request); +} + +public interface ObjectPurgeMaintenancePort { + ObjectMutationReceipt purge(ObjectPurgeRequest request); +} + +public interface ObjectOperationResolutionPort { + ObjectOperationResolution resolve(ObjectOperationKey operationKey); +} + +public interface ObjectPublicationHandoffPort { + ObjectHandoffReceipt claimForPublication(ObjectHandoffClaimRequest request); + ObjectHandoffReceipt renewClaim(ObjectHandoffRenewRequest request); + ObjectMutationReceipt releaseClaim(ObjectHandoffReleaseRequest request); + ObjectMutationReceipt authorizeAbort(ObjectAbortAuthorization request); +} + +public interface DirectObjectUploadPort { + DirectUploadGrant createUploadGrant(DirectUploadGrantRequest request); + DirectUploadCompletionReceipt completeUpload(DirectUploadCompletionRequest request); +} + +public interface DirectObjectDownloadGrantPort { + DirectDownloadGrant createDownloadGrant(DirectDownloadGrantRequest request); +} + +public interface DirectMultipartUploadPort { + MultipartSession startMultipart(MultipartStartRequest request); + PartUploadGrant createPartGrant(PartUploadGrantRequest request); + PartReceiptToken acknowledgePart(MultipartPartAcknowledgement request); + MultipartReceipt completeMultipart(MultipartCompleteRequest request); + ObjectMutationReceipt abortMultipart(MultipartAbortRequest request); +} +``` + +Quarantine가 필요한 workflow에는 single-call `publish`만으로 부족하다. 다음 staged family를 +분리한다. + +```java +public interface StagedObjectPublicationPort { + ObjectStageReceipt stage(ObjectStageRequest request, ObjectContentProducer producer); + ObjectVerificationReceipt verifyIntegrity(ObjectVerifyRequest request); + ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request); + ObjectMutationReceipt abort(ObjectAbortRequest request); +} + +public interface ObjectScanMaintenancePort { + ObjectReadReceipt transferForScan( + ObjectScanReadRequest request, + ObjectContentConsumer consumer); + ObjectMutationReceipt recordScanVerdict(ObjectScanVerdictRequest request); +} +``` + +`ManagedObjectPublicationPort.publish`는 compiled destination이 scan-free publication을 +명시한 경우에만 사용한다. Scan-gated destination을 이 편의 port로 호출하면 +`UNSUPPORTED_CAPABILITY`가 아니라 configuration/programming error로 fail closed하며 반드시 +staged family를 사용한다. + +모든 mutation request와 resolution은 공통 `ObjectOperationKey`를 포함한다. Operation record의 +physical route를 current provider default로 추론하지 않는다. §10.2의 destination route token, +epoch registry, retained binding revision을 통해 exact control namespace를 찾는다. + +Direct operation의 의미: + +- `completeUpload`: client completion claim을 신뢰하지 않고 exact HEAD/checksum/size/encryption을 + 검증한 뒤 staged receipt를 만든다; +- `createDownloadGrant`: application authorization 뒤 published exact version에 대한 bounded GET + grant를 만든다; +- `acknowledgePart`: client가 반환한 bounded provider completion claim을 검증하고 server-side + opaque part token/ledger로 바꾼다; +- `completeMultipart`: server ledger의 part token만 받아 provider complete와 reconciliation을 + 수행한다. + +Scan operation은 normal staged publication과 다른 maintenance 권한 port를 사용한다. +`ObjectScanMaintenancePort`의 의미: + +- `transferForScan`은 unpublished exact version만 scanner workflow에 bounded read한다; +- `recordScanVerdict`는 object version, scan operation, scanner policy/version precondition이 + 일치할 때만 `CLEAN`, `MALICIOUS`, `INDETERMINATE` verdict를 기록한다; +- stale/duplicate verdict는 current object에 적용하지 않는다. + +`ObjectScanMaintenancePort`와 `ObjectPurgeMaintenancePort`는 일반 business use case에 주입하지 +않는다. Scanner workflow용 maintenance composition과 physical purge용 privileged composition도 +서로 다른 concrete router/client 권한으로 유지한다. Maintenance +composition 또는 명시적으로 승인된 privileged administration workflow만 사용한다. +`ObjectOperationResolutionPort`는 read-only recovery service가 공유한다. +`ObjectPublicationHandoffPort`는 application DB/outbox와 adapter reaper 사이의 destructive +cleanup fence다. Objectstorage adapter가 persistence repository를 직접 조회하지 않는다. + +구현 시 interface를 과도하게 세분화하지 않되 다음 권한 경계는 유지한다. + +- inspect/read; +- managed write/stage/finalize; +- scanner unpublished-read/verdict maintenance; +- direct upload, direct download grant, multipart; +- business retirement; +- privileged physical purge; +- maintenance/reconciliation. + +Maintenance port는 application business service에 주입하지 않고 운영 job/composition에만 +노출한다. + +### 9.2 Identity type + +필수 identity: + +| Type | 의미 | 생성 주체 | +| --- | --- | --- | +| `ObjectDestinationId` | logical storage/security/retention 목적지 | application/config | +| `ObjectOperationEpoch` | operation namespace rotation/rejection epoch | composition/application ID factory | +| `ObjectOperationId` | mutation 한 건의 stable idempotency identity | application | +| `ObjectOperationKey` | destination, epoch, operation을 묶은 모든 mutation의 exact key | application | +| `ObjectId` | immutable data object identity | adapter | +| `ObjectStageHandle` | unpublished exact object를 stage/scan/finalize에만 쓰는 opaque handle | adapter | +| `ObjectReference` | application이 저장하는 opaque published reference | adapter | +| `ObjectVersionToken` | exact immutable generation/version precondition | adapter | +| `DirectTransferSessionId` | direct upload workflow identity | adapter | +| `MultipartPartNumber` | bounded 1-based logical part number | application/adapter validation | +| `PartReceiptToken` | provider part result를 숨긴 opaque token | adapter | + +모든 ID는: + +- null/blank를 허용하지 않는다; +- canonical text form을 가진다; +- log용 hash/token을 별도로 제공한다; +- provider locator를 encode하지 않는다; +- parsing이 bounded이고 exception message에 secret/raw content를 넣지 않는다. + +`ObjectOperationId`는 HTTP request ID와 다르다. 동일 business mutation retry에서 유지되고, 새 +사용자 의도에는 새 ID를 사용한다. + +`ObjectStageHandle`은 published reference가 아니며 public DTO/download port에 사용할 수 없다. +`ObjectStageReceipt`가 이를 반환하고 finalize success가 처음으로 `ObjectReference`를 만든다. + +Destination ID의 provider/namespace binding은 한 번 published operation이 생기면 in-place로 +재지정하지 않는다. Provider migration은 새 destination route token/binding revision을 만들고 +기존 binding을 read/reconcile/drain 상태로 보존한다. 그렇지 않으면 +`destinationId + operationId` retry가 과거 control record를 찾지 못한다. + +### 9.3 Content callback + +Java standard `InputStream`을 port에 그대로 넘기지 않는다. Baseline은 synchronous callback이다. + +```java +@FunctionalInterface +public interface ObjectContentProducer { + void produce( + ObjectContentProductionContext context, + ObjectChunkSink sink) + throws ObjectContentProductionException; +} + +public interface ObjectChunkSink { + void write(byte[] bytes, int offset, int length) + throws ObjectChunkWriteException; +} + +@FunctionalInterface +public interface ObjectContentConsumer { + void consume( + ObjectContentReadContext context, + ObjectChunkSource source) + throws ObjectContentConsumptionException; +} + +public interface ObjectChunkSource { + int read(byte[] destination, int offset, int length) + throws ObjectChunkReadException; +} +``` + +계약: + +- context는 read-only `CancellationView`, remaining deadline/budget checkpoint를 제공한다; +- read context는 body 전에 validated descriptor, exact version, delivered range/length를 제공한다; +- callback이 반환되면 producer/consumer가 provider resource를 보관할 수 없다; +- adapter가 sink/source를 닫고 invalidation한다; +- source는 EOF에 `-1`, positive request에서 progress가 없으면 bounded zero-read 후 protocol + failure를 반환한다; +- offset/length/array bounds를 호출 전에 검증한다; +- producer의 input array는 `write` 반환까지만 유효하며 adapter가 반환 뒤 reference를 보관하지 + 않는다; +- consumer destination array는 `read`를 호출한 consumer가 소유하며 adapter가 보관하지 않는다; +- configured max chunk보다 큰 write를 쪼개거나 거부한다; +- producer가 던진 application 오류와 storage 오류를 분리한다; +- producer는 한 operation attempt에서 기본적으로 한 번만 호출한다; +- 재호출이 필요하면 request가 repeatable임을 명시하고 별도 factory를 사용한다; +- consumer failure, cancellation, disconnect는 read success가 아니다; +- adapter는 application callback을 SDK event-loop thread에서 실행하지 않는다; +- callback이 blocking임을 contract에 명시한다. + +향후 reactive port가 필요하면 별도 capability로 설계한다. `Flow.Publisher`, Reactor type 또는 +AWS `AsyncRequestBody`를 이 baseline port에 노출하지 않는다. + +### 9.4 Publish request + +`ObjectPublishRequest`의 최소 필드: + +- `ObjectOperationKey operationKey`; +- `ObjectMediaType declaredMediaType`; +- `ObjectContentIdentity contentIdentity`; +- `ObjectPublicationRequirement publicationRequirement`; +- `ObjectRetentionRequirement retentionRequirement`; +- `ObjectEncryptionRequirement encryptionRequirement`; +- `OperationDeadline deadline`; +- bounded correlation/audit context. + +넣지 않는 필드: + +- bucket; +- raw key/path; +- region/endpoint; +- AWS storage class enum; +- KMS raw key ARN; +- public ACL; +- arbitrary metadata map; +- original filename; +- inbound DTO. + +Destination binding이 provider와 namespace, maximum size, checksum, encryption, retention, +direct-transfer 허용 여부를 결정한다. + +R2 `ObjectContentIdentity`는 다음 중 하나다. + +- exact byte length + expected full SHA-256; +- adapter가 검증 가능한 immutable source reference/revision + expected length/digest. + +Maximum size만 있고 payload digest가 없는 one-shot upload는 다른 payload로 operation ID를 +오용했을 때 conflict를 검출할 수 없다. 이 경로는 R0/R1 compatibility로만 허용하고 R2 +publication card에 포함하지 않는다. + +### 9.5 Read request + +`ObjectReadRequest`: + +- exact `ObjectReference`; +- optional expected `ObjectVersionToken`; +- `ObjectReadRange`; +- digest verification mode; +- maximum delivered bytes; +- deadline/cancellation. + +Public `ObjectTransferPort`는 caller가 publication/scan requirement를 낮추는 field를 받지 않는다. +항상 compiled destination minimum인 `PUBLISHED`와 required `SCAN_CLEAN`을 강제한다. Unpublished +exact-version read는 별도 narrow scan/maintenance port와 bean만 제공한다. + +Baseline range는 하나의 contiguous `(offset, length)`다. + +- offset은 0 이상; +- length는 1 이상이며 destination maximum 이하; +- object end를 넘는 range의 exact 결과를 정의한다; +- suffix/multi-range HTTP 문법은 inbound에서 canonical form으로 변환한다; +- provider range response의 실제 offset/length/content-range를 검증한다. + +### 9.6 Descriptor와 receipt + +`ObjectDescriptor`: + +- opaque reference; +- exact version token; +- logical size; +- declared/detected media type; +- content digest algorithm/value; +- publication state; +- scan state; +- encryption profile ID; +- retention state; +- created/published timestamp; +- schema version. + +`ObjectPublishReceipt`: + +- operation ID; +- request fingerprint; +- opaque reference; +- exact version; +- size; +- digest; +- media type; +- terminal outcome; +- applied timestamp; +- effective capability descriptor revision. + +Receipt에는 bucket, provider key, filesystem path, endpoint, raw ETag, upload ID를 넣지 않는다. +Provider-specific evidence는 private operation record에만 저장한다. + +### 9.7 Capability requirement + +Request는 필요한 보장을 typed enum/set으로 표현한다. + +- immutable create; +- exact version read; +- conditional retirement; +- SHA-256 verification; +- scan-gated publication; +- direct upload; +- direct multipart; +- retention hold; +- server-side encryption profile; +- response-loss reconciliation. + +Binding compiler는 destination의 required capability와 provider의 effective capability를 +startup에서 대조한다. Runtime request가 compiled binding보다 강한 보장을 요구하면 호출 전에 +`UNSUPPORTED_CAPABILITY`로 거부한다. + +Runtime request의 scan, encryption, retention, checksum, publication requirement는 destination +minimum을 강화할 수만 있고 낮출 수 없다. Weaker request는 stronger destination policy로 +승격하거나 ambiguous하면 fail closed한다. + +## 10. Logical key, object reference, namespace + +### 10.1 Internal data key + +Provider key는 adapter 내부에서 생성한다. 예시 grammar: + +```text +data/v1//// +``` + +요구사항: + +- ASCII lower-case의 제한된 alphabet; +- segment 길이와 전체 길이 제한; +- `.`/`..`, empty segment, slash alias 금지; +- percent/Unicode normalization ambiguity 금지; +- tenant/user/original filename/email 같은 PII 금지; +- object ID에서 deterministic shard 계산; +- destination별 private prefix 고정; +- generation은 immutable create마다 새 값; +- canonical encoder와 parser에 property test; +- provider별 key normalization 차이를 adapter kernel에서 제거. + +### 10.2 Control namespace + +```text +control/v1/operations//// +control/v1/references/// +control/v1/manifests//// +control/v1/multipart/// +control/v1/reaper-cursors// +control/v1/operation-epochs// +``` + +Data와 control prefix는 IAM/policy와 lifecycle에서 분리한다. Runtime identity가 broad bucket +list/delete 권한을 갖지 않도록 operation별 최소 prefix 권한을 설계한다. + +`destination-token`은 stable, non-secret route identity다. 동일 token의 provider/namespace를 +in-place로 바꾸지 않는다. Migration은 새 token을 만들며 old token의 binding revision을 +read/reconcile/retire 기간 동안 보존한다. + +### 10.3 Public opaque reference + +예시 외형: + +```text +osr1... +``` + +Reference는: + +- provider/bucket/key를 복호화할 수 없는 opaque value; +- 오타 탐지용 check digits; +- schema/version prefix; +- destination route token; +- 충분한 entropy; +- application DB에 저장 가능한 bounded string; +- secret이 아니지만 log에서는 hash/token 처리; +- authorization을 대체하지 않음; +- public download URL이 아님. + +Reference를 받은 사용자는 application authorization을 통과해야만 stream 또는 presigned grant를 +받는다. + +Route registry는: + +- token -> destination/binding revision history를 durable하게 보존; +- destination display-name rename과 route identity를 분리; +- live reference/operation/tombstone가 남아 있으면 token 삭제·재사용 금지; +- provider migration 시 old route를 read/reconcile/retire 상태로 유지; +- unknown/removed route를 current default provider로 보내지 않음; +- route alias/tombstone retention을 reference maximum lifetime보다 길게 유지 + +한다. Startup validation은 token collision/reuse와 required old binding 부재를 hard fail한다. + +### 10.4 Original filename와 user metadata + +Original filename은 object key로 사용하지 않는다. Business상 필요하면: + +- inbound에서 control character/path separator를 제거한다; +- length를 제한한다; +- public response용 display metadata로 application DB에 저장한다; +- storage adapter control record에는 allowlisted, encoded metadata만 둔다; +- log/metric tag에 넣지 않는다. + +Arbitrary `Map` metadata는 baseline port에 없다. 필요한 metadata는 typed, +versioned field로 추가한다. + +## 11. Operation identity와 request fingerprint + +### 11.1 Canonical fingerprint + +Fingerprint는 content bytes 자체가 아니라 immutable request intent를 canonical encode한 뒤 +SHA-256으로 계산한다. + +포함: + +- schema version; +- destination ID; +- operation kind; +- declared media type canonical form; +- exact/maximum size expectation; +- exact R2 content identity 또는 explicit R1 compatibility marker; +- publication/scan requirement; +- encryption/retention profile; +- direct/multipart parameter; +- prior reference/version precondition. + +제외: + +- request ID, trace ID; +- current time; +- provider-generated key/upload ID; +- presigned URL; +- credential; +- transient retry count. + +Canonical encoding은: + +- field 순서 고정; +- enum canonical name 고정; +- number decimal encoding 고정; +- absence와 empty를 구분; +- Unicode normalization 정책 고정; +- schema version 포함; +- golden vector test 보유. + +### 11.2 Same-operation decision + +| 기존 operation | 새 요청 | 결과 | +| --- | --- | --- | +| 없음 | valid fingerprint | reserve 후 실행 | +| non-terminal, same fingerprint | retry | current state/continuation 반환 | +| terminal success, same fingerprint | retry | 저장된 receipt 반환, producer 재호출 금지 | +| terminal failure, same fingerprint | retry | 정책에 따라 same failure 또는 explicit new operation 요구 | +| 어떤 상태든 different fingerprint | retry | `OPERATION_CONFLICT` | +| unknown/newer schema | retry | fail closed, manual/upgrade reconciliation | + +Operation ID uniqueness만 보고 dedupe하지 않는다. Fingerprint 비교가 필수다. + +### 11.3 Content digest와 operation fingerprint 분리 + +두 digest를 혼동하지 않는다. + +- request fingerprint: 같은 사용자 의도인지 판정; +- content digest: 업로드된 byte가 기대한 content인지 판정. + +R2 expected content digest는 fingerprint에 포함하고 adapter가 streaming 중 계산한 값과 비교한다. +R1 compatibility 경로는 adapter가 discovered digest를 기록하지만, terminal retry에서 다른 +payload 오용을 검출하지 못한다는 보장 한계를 descriptor/receipt에 표시한다. Direct upload는 +server가 bytes를 직접 보지 않을 수 있으므로 provider checksum/head 또는 별도 verification +read가 필요하다. + +### 11.4 Replay horizon과 tombstone + +Destination은 다음보다 긴 `minimumOperationReplayHorizon`을 가진다. + +- public API idempotency retry horizon; +- outbox/redelivery/dead-letter recovery horizon; +- maximum worker outage; +- provider indeterminate reconciliation horizon. + +Operation identity는 `destination route + operation epoch + operation ID`다. + +- active epoch에서는 새 operation을 받을 수 있다; +- active epoch의 per-operation tombstone은 개별 horizon 경과만으로 삭제하지 않는다; +- compaction하려면 epoch를 먼저 `SEALED`로 바꿔 신규 operation을 거부한다; +- seal 뒤 maximum replay horizon과 indeterminate/retention recovery가 모두 끝날 때까지 individual + receipt/tombstone을 유지한다; +- 그 뒤 immutable epoch rejection record를 남기고 per-operation tombstone을 compact할 수 있다; +- sealed/compacted epoch로 온 어떤 operation ID도 exact lookup 결과와 무관하게 + `OPERATION_EXPIRED`를 반환한다; +- epoch token은 destination lifetime 동안 재사용하지 않는다. + +따라서 tombstone 삭제 뒤 “record 없음”을 새 operation으로 오인하지 않는다. Current active +epoch의 tombstone을 지우고 같은 epoch를 계속 쓰는 구현은 금지한다. Application operation-ID +factory와 outbox는 epoch를 operation과 함께 durable하게 보존한다. + +### 11.5 Bounded epoch rotation + +```text +WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED +``` + +Finite destination settings: + +- `epochMaxAge`; +- `epochMaxOperations`; +- `epochMaxControlBytes`; +- maximum concurrently readable old epochs; +- drain/replay/compaction deadline. + +Rotation: + +1. 새 epoch를 `WARM`으로 만들고 codec/policy/binding 및 모든 reader가 old/new lookup을 지원하는지 + qualification한다. +2. Durable current-write-epoch pointer를 CAS로 새 epoch에 전환한다. +3. Old epoch를 `DRAINING`으로 바꾸고 stale writer admission을 거부한다. +4. maximum in-flight request/outbox dispatch horizon 동안 old operation continuation/replay만 + 허용한다. +5. unresolved indeterminate operation이 없고 drain fence가 확인되면 `SEALED`한다. +6. replay/retention horizon 뒤 immutable rejection record를 남기고 `COMPACTED`한다. + +Pointer unavailable, stale writer, seal race는 fail closed한다. Rollback은 새 epoch에 operation이 +없을 때만 pointer를 되돌리거나 또 다른 epoch를 사용한다. Multi-pod cutover, delayed outbox, +partitioned writer, restore를 rolling/fault test한다. + +## 12. 분리된 상태 머신 + +Publication operation, published reference lifecycle, retirement/purge mutation, direct grant +session, multipart session을 한 enum에 합치지 않는다. 각각 별도 record와 stable operation +identity를 가진다. + +### 12.1 Publication operation + +```text +RESERVED + -> DATA_UPLOAD_IN_PROGRESS + -> DATA_UPLOADED + -> INTEGRITY_VERIFIED + -> SCAN_PENDING + -> SCAN_CLEAN + -> REFERENCE_PUBLISH_IN_PROGRESS + -> PUBLISHED +``` + +Terminal: + +```text +PUBLISHED | ABORTED | QUARANTINED | EXPIRED | FAILED | CORRUPT +``` + +Scan이 필요 없는 destination은 `INTEGRITY_VERIFIED -> REFERENCE_PUBLISH_IN_PROGRESS`로 +전이한다. `PUBLISHED`는 이 publication operation의 immutable terminal이다. 이후 retirement가 +같은 record를 변경하지 않는다. + +### 12.2 Scan sub-state + +```text +NOT_REQUIRED +PENDING +CLEAN +MALICIOUS +INDETERMINATE +``` + +- destination minimum이 scan을 요구하면 caller가 `NOT_REQUIRED`로 낮출 수 없다; +- verdict는 exact object version, scan operation, scanner policy revision, fence를 묶는다; +- `MALICIOUS`는 publication operation을 `QUARANTINED` terminal로 보낸다; +- `INDETERMINATE`는 clean이 아니며 scan retry/운영 판정 전 publish를 막는다. + +### 12.3 Published reference lifecycle + +```text +PUBLISHED + -> RETIREMENT_PENDING + -> RETIRED + -> PURGE_ELIGIBLE + -> PURGED +``` + +Reference lifecycle record는 publication receipt와 별도다. + +- retirement mutation마다 새 `ObjectOperationId`와 fingerprint를 사용한다; +- physical purge도 retirement와 다른 privileged operation ID를 사용한다; +- `RETIRED`는 business visibility 제거이며 physical purge와 다르다; +- retention/legal hold는 purge operation outcome을 `HELD`로 만들며 reference record를 임의로 + `PURGED`로 승격하지 않는다; +- reference가 가리키는 immutable generation은 lifecycle 전체에서 덮어쓰지 않는다. + +### 12.4 Direct grant session + +```text +SESSION_RESERVED + -> GRANT_PREPARED + -> GRANT_ISSUED + -> UPLOAD_VERIFICATION_IN_PROGRESS + -> DATA_UPLOADED +``` + +Terminal/branch: + +```text +EXPIRED | ABORTED | FAILED +``` + +Grant generation, constraints digest, signing revision, expiry, outstanding-grant exposure는 session +record가 소유한다. Download grant도 별도 grant-operation record와 published reference revision +precondition을 가진다. Grant response uncertainty는 stable grant state와 pending-effect certainty로 +표현한다. 상세 발급/reissue linearization은 §18.6을 따른다. + +### 12.5 Multipart session + +```text +SESSION_RESERVED + -> INITIATE_IN_PROGRESS + -> ACCEPTING_PARTS + -> COMPLETE_IN_PROGRESS + -> COMPLETED +``` + +Terminal/branch: + +```text +ABORTED | EXPIRED | FAILED | CORRUPT +``` + +Session record는 provider upload ID, part ledger, grant fence를 private하게 보존한다. `COMPLETED`가 +되면 publication operation의 `DATA_UPLOADED` evidence로 연결되지만 두 record를 같은 state로 +간주하지 않는다. + +### 12.6 Pending effect와 certainty + +단일 `INDETERMINATE` state로 phase를 지우지 않는다. 모든 external mutation 직전에 control +record를 CAS하여 다음을 기록한다. + +```text +stablePhase +pendingEffect { + kind + attemptId + exactTargetAndVersion + desiredRevisionOrState + precondition + requestEvidenceDigest +} +certainty = NOT_SENT | SENT | CONFIRMED | INDETERMINATE +``` + +`kind` 예: + +- `DATA_PUT`; +- `DIRECT_GRANT_ISSUE`; +- `DIRECT_UPLOAD_VERIFY`; +- `DIRECT_DOWNLOAD_GRANT_ISSUE`; +- `MULTIPART_INITIATE`; +- `MULTIPART_PART`; +- `MULTIPART_COMPLETE`; +- `MULTIPART_ABORT`; +- `REFERENCE_CAS`; +- `REFERENCE_RETIRE`; +- `OBJECT_PURGE`; +- `CONTROL_RECORD_CAS`. + +따라서 `DATA_PUT_INDETERMINATE`, `REFERENCE_CAS_INDETERMINATE`, +`MULTIPART_COMPLETE_INDETERMINATE`, `OBJECT_PURGE_INDETERMINATE`를 서로 다른 reconciler +branch로 보낸다. + +### 12.7 전이 규칙 + +- terminal operation record는 immutable하게 보존한다; +- state version/CAS와 pending-effect precondition이 일치할 때만 전이한다; +- state마다 allowed predecessor를 고정한다; +- 같은 terminal transition retry는 stored receipt를 반환한다; +- out-of-order callback은 무시하지 말고 conflict/audit한다; +- object data 존재만으로 publication state를 승격하지 않는다; +- state와 provider evidence가 충돌하면 `CORRUPT` 또는 phase-specific indeterminate로 격리한다; +- quarantined/unpublished object는 public transfer/grant 대상이 아니다. + +### 12.8 Mutation response loss + +Response loss가 발생하면: + +1. pending effect와 certainty를 `INDETERMINATE`로 유지한다; +2. 같은 mutation을 blind retry하지 않는다; +3. effect kind에 맞는 exact key/version/upload session/control revision을 조회한다; +4. size, digest, metadata, version, state evidence를 비교한다; +5. `APPLIED`, `NOT_APPLIED`, `PRECONDITION_FAILED`, 계속 `INDETERMINATE` 중 하나로 resolve한다; +6. resolve 결과에 따라 continuation 또는 compensation을 수행한다. + +## 13. Durable control plane + +### 13.1 필요성 + +Provider object data만으로 다음을 복구할 수 없다. + +- operation ID와 request fingerprint; +- scan/publication 상태; +- provider response loss 전후 의도; +- old/new attachment reference; +- cleanup ownership/age; +- multipart part ledger; +- schema/policy/encryption revision; +- terminal receipt. + +따라서 R2에는 durable control plane이 필요하다. + +### 13.2 Source of truth + +초기 선택은 같은 object storage의 private control namespace에 versioned canonical record를 두는 +것이다. + +- data plane과 failure domain이 같아 disaster recovery가 단순하다; +- conditional create/CAS를 provider capability로 검증할 수 있다; +- application DB schema와 capability internals를 분리한다. + +단, control record와 data object는 여전히 단일 atomic transaction이 아니다. protocol과 +reconciliation이 crash gap을 닫는다. + +Control plane을 별도 database에 두는 선택도 가능하지만 이 문서의 baseline이 아니다. 바꾸려면: + +- ownership과 dependency edge; +- transaction/capacity/failover; +- dual-store recovery; +- backup/restore ordering + +을 별도 설계로 승인한다. + +### 13.3 Record schema + +Operation record v1 최소 필드: + +- schema version; +- operation ID; +- canonical request fingerprint; +- operation kind; +- destination route token; +- provider/binding/policy revision; +- canonical non-secret effective policy snapshot와 digest; +- key grammar/codec/checksum/encryption/retention plan revision; +- logical credential/key reference revision; +- internal object ID/key hash; +- opaque reference; +- operation family, stable phase, family-specific state/state version; +- pending effect, attempt ID, precondition, request evidence digest, certainty; +- expected/observed size; +- expected/observed content digest; +- provider checksum algorithm/value; +- provider version/ETag private evidence; +- multipart opaque session and completed-part ledger; +- encryption/retention/scan profile revision; +- timestamps and bounded lease/fence; +- last normalized outcome/error; +- terminal public receipt; +- minimum replay-until/terminal tombstone epoch; +- cleanup ownership/eligibility; +- application handoff/cleanup authorization fence; +- audit correlation token. + +Record는 canonical format으로 encode하고: + +- checksum/MAC 또는 authenticated encryption 정책; +- maximum record size; +- unknown field policy; +- schema upgrade/downgrade behavior; +- corruption handling; +- golden fixtures + +를 테스트한다. Secret, presigned URL, raw credential은 기록하지 않는다. + +Reserve 시 effective execution policy를 freeze한다. Same operation retry/reconcile은: + +- 저장된 policy snapshot과 digest; +- 저장된 provider/binding revision; +- 저장된 key/codec/checksum/encryption/retention plan; +- 당시의 logical credential/key revision + +만 사용한다. Current configuration으로 다시 resolve하거나 silent fallback하지 않는다. 필요한 old +revision을 복원할 수 없으면 `POLICY_REVISION_UNAVAILABLE`로 fail closed하고 manual +reconciliation 대상으로 보낸다. + +Old revision은 maximum operation/replay/retention horizon보다 길게 보존한다. 새 policy는 새 +operation에만 적용한다. Rolling deployment test는 old revision read/continue와 new revision +write를 함께 검증한다. + +### 13.4 Conditional update + +R2 provider는 control record에 다음 중 하나를 증명해야 한다. + +- create-if-absent와 exact version compare-and-swap; +- immutable revision append + conditional current pointer; +- 동등한 linearizable primitive. + +지원하지 않는 provider는 multi-node deterministic recovery를 claim하지 않는다. + +LIST는 reaper candidate discovery에만 사용한다. Operation/reference direct lookup의 source of +truth는 deterministic exact key GET/HEAD다. + +### 13.5 Schema evolution + +- reader는 자신보다 낮은 supported version을 migrate in memory할 수 있다; +- writer는 deployment의 selected write version만 쓴다; +- newer version은 삭제/overwrite하지 않고 `UNSUPPORTED_CONTROL_SCHEMA`로 격리한다; +- rolling deployment 동안 old/new reader compatibility matrix를 CI에서 검증한다; +- downgrade 전에 write-version gate를 낮추는 별도 단계가 필요하다; +- cleanup job은 unknown schema를 보고만 하고 삭제하지 않는다. + +## 14. Managed upload protocol + +### 14.1 Plan + +Adapter는 content producer를 호출하기 전에: + +1. operation key의 destination route token, epoch, operation ID로 existing record 또는 sealed + epoch rejection record를 exact lookup한다; +2. existing record가 있으면 frozen binding/policy revision을 복원하고 fingerprint를 비교한다; +3. record가 없을 때만 current destination binding과 capability requirement를 resolve한다; +4. operation ID/fingerprint와 R2 content identity를 검증한다; +5. immutable object ID/key와 opaque reference를 생성한다; +6. size/digest/encryption/retention limit와 effective policy snapshot/digest를 freeze한다; +7. `RESERVED` control record를 create-if-absent한다; +8. winning record를 다시 읽어 same-operation decision을 수행한다; +9. operation-scoped resource budget을 예약한다. + +이 단계에는 data write가 없다. + +### 14.2 Upload + +1. control state를 CAS로 `DATA_UPLOAD_IN_PROGRESS`로 전이하고 `DATA_PUT` pending effect를 + `NOT_SENT`로 기록한다. +2. provider immutable-create request를 열기 직전에 pending effect certainty를 `SENT`로 + 전이한다. +3. producer callback의 chunk를 bounded buffer로 전달한다. +4. 동시에 logical byte count와 SHA-256을 계산한다. +5. provider transport checksum을 지원하면 별도로 계산/전송한다. +6. maximum bytes를 넘기기 전에 sink를 중단한다. +7. producer, adapter, SDK 오류를 서로 다른 normalized cause로 기록한다. +8. successful provider response에서 exact version/checksum/encryption evidence를 보존한다. +9. response가 확정되면 pending effect를 confirm/clear하고 `DATA_UPLOADED`로 전이한다. + 불확정이면 stable phase는 `DATA_UPLOAD_IN_PROGRESS`, pending effect kind는 `DATA_PUT`, + certainty는 `INDETERMINATE`로 유지한다. + +Producer exception 뒤에는 partial provider upload를 abort/cleanup한다. Cleanup 실패가 원래 +producer 오류를 덮지 않으며 operation record에 별도 evidence로 남는다. + +### 14.3 Integrity verification + +검증 우선순위: + +1. caller expected SHA-256과 adapter-calculated SHA-256 비교; +2. sent provider checksum과 provider response/head checksum 비교; +3. exact object size 비교; +4. exact immutable version 확인; +5. encryption/retention response attestation 확인; +6. provider 특성상 response evidence가 불충분하면 bounded verification read. + +불일치 시 object를 publish하지 않고 `QUARANTINED` 또는 `CORRUPT`로 보낸다. 단순 retry로 +정상화하지 않는다. + +### 14.4 Scan + +Scan이 필요한 destination: + +```text +DATA_UPLOADED + -> INTEGRITY_VERIFIED + -> scanState=PENDING + -> scanner application workflow + -> scanState=CLEAN | MALICIOUS | INDETERMINATE +``` + +`scanState=CLEAN`만 publication state를 `SCAN_CLEAN`으로 진행시킨다. `MALICIOUS`는 +`QUARANTINED` terminal, `INDETERMINATE`는 `SCAN_PENDING` publication state에 머문다. + +Object Storage adapter는 scanner SDK나 business verdict를 소유하지 않는다. 다음 seam만 제공한다. + +- unpublished exact version에 대한 authorized bounded read; +- scan operation correlation; +- clean/malicious/indeterminate verdict를 conditional state transition으로 기록; +- stale verdict가 새 version에 적용되지 않도록 version precondition; +- scanner unavailable 시 fail-closed publication. + +Scanner capability는 별도 outbound adapter가 application port 뒤에서 구현한다. Objectstorage +leaf가 sibling adapter를 직접 의존하지 않는다. + +### 14.5 Publish + +기본 publication: + +1. required integrity/scan state를 재검증한다; +2. immutable manifest revision을 기록한다; +3. reference current pointer를 conditional create/CAS한다; +4. `PUBLISHED` terminal receipt를 operation record에 보존한다; +5. caller에게 opaque receipt를 반환한다. + +Data object key는 이동/복사/ACL 변경하지 않는다. Reference lookup이 private immutable object의 +exact version을 가리킨다. + +Manifest/pointer write response가 유실되면 reference exact GET과 revision/digest 비교로 +reconcile한다. + +### 14.6 Abort + +Abort는: + +- terminal published object를 지우지 않는다; +- non-terminal object와 multipart session만 대상으로 한다; +- operation ID/fingerprint를 확인한다; +- active publication handoff claim이 있으면 destructive abort를 거부한다; +- business-owned verified stage는 DB upload-intent의 terminal abort fence에 묶인 + `ObjectAbortAuthorization`을 검증한다; +- conditional state transition으로 single owner를 확보한다; +- provider delete/abort 결과를 reconcile한다; +- retention/hold가 있으면 `HELD`를 반환한다; +- cleanup failure를 숨기지 않는다. + +### 14.7 Empty object + +Empty object 지원 여부를 destination별로 명시한다. + +- generic binary destination은 size 0을 허용할 수 있다; +- poster image 같은 business destination은 application policy로 거부한다; +- checksum은 empty SHA-256의 정상 값으로 계산한다; +- multipart는 empty object에 사용하지 않는다. + +## 15. Database attachment workflow + +### 15.1 금지 shape + +```text +DB transaction { + remote upload + aggregate save +} +``` + +이 shape는 사용하지 않는다. + +### 15.2 권장 workflow + +Poster image 교체 예시: + +```text +1. authorize + validate request +2. stable attachment operation 생성 +3. short DB transaction: + Poster exists/version 확인 + versioned HMAC key epoch을 lock하고 raw tenant/principal/Idempotency-Key를 + domain-separated alias/digest로 변환 + tx.inWrite(() -> IdempotencyExecutor.execute( + sanitizedContext, + create-or-read UploadIntent(operation, expected poster version, content identity, RESERVED), + bounded reservation codec)) + generic idempotency COMPLETED reservation과 UploadIntent를 함께 commit +4. object stage/upload outside DB transaction +5. integrity verify + required scan +6. object publication handoff claim을 획득 +7. short DB transaction: + UploadIntent가 RESERVED이고 operation/fence가 같은지 CAS + pending attachment(stage handle, operation, expected object version, handoff fence) 저장 + UploadIntent를 PENDING으로 전이 + outbox AttachmentPrepared(handoff fence) 기록 +8. committed UploadIntent worker가 PENDING row를 claim하고 handoff claim을 갱신한 뒤 + object publication finalize +9. short DB transaction: + same pending stage handle인지 CAS 확인 + finalize receipt의 published reference로 교체해 READY 승격 + UploadIntent를 READY로 전이 + old reference를 retirement queue/outbox에 추가 + outbox AttachmentReady 기록 +10. handoff claim release +11. async old-reference retirement +``` + +UploadIntent는 remote transfer 전에 commit되므로 “DB row가 아직 commit될 수 있는 중인데 absence를 +읽고 abort”하는 race를 제거한다. Handoff fence는 secret authorization이 아니라 +operation/version/claim generation에 묶인 opaque value다. DB commit 여부를 objectstorage +adapter가 추측하지 않는다. + +TX1은 generic idempotency claim, UploadIntent create/read, bounded reservation COMPLETE가 같은 +PostgreSQL transaction에 참여한다. Commit 전 crash는 둘 다 남기지 않고, commit 후에는 +COMPLETED generic row와 matching intent가 함께 남는다. Scope claim은 transaction을 +unique-violation으로 poison하지 않는 PostgreSQL `ON CONFLICT` claim primitive를 사용한다. +Versioned key epoch과 retained HMAC key lookup은 rolling rotation 중 old/new digest가 서로 다른 +intent를 만들지 못하게 하며, generic row와 intent에는 raw header/principal/tenant를 저장하지 +않는다. + +Multipart HTTP 요청의 `RequestFingerprint`는 raw body나 multipart boundary의 hash가 아니다. +`poster-image-publication-fingerprint-v1` canonical codec이 schema/domain separator, Poster ID, expected +aggregate version, destination/profile, normalized media type, declared content length, 그리고 +요청 전에 제출된 full-file SHA-256을 length-prefixed bytes로 직렬화한 semantic digest다. +Filename, multipart boundary, part/header 순서와 transport-only header는 제외한다. 같은 의미의 +재시도는 같은 fingerprint가 되고, content identity나 business precondition이 바뀌면 반드시 +달라지는 golden/property test를 둔다. + +HTTP multipart producer는 request lifetime에 묶이므로 4–7은 successful `202` 반환 전에 같은 +request invocation에서 끝난다. Worker는 request body가 없는 `RESERVED` intent를 임의로 stage하지 +않는다. TX1 뒤 stage 전 crash는 same-idempotency-key/same-fingerprint retry가 같은 operation에 새 +producer를 공급하고, retry가 없으면 bounded expiry/abort/report 대상으로 남긴다. Worker가 +비동기로 소유하는 구간은 durable `PENDING` 이후 finalize/READY다. + +`AttachmentPrepared`/`AttachmentReady` outbox row는 versioned integration notification/audit다. +Object publication의 canonical work queue와 recovery source of truth는 +`poster_image_upload_intent`이며, outbox/broker delivery만을 유일한 wake-up 또는 object operation +journal로 사용하지 않는다. + +### 15.3 Crash gap + +| Crash 위치 | 남는 상태 | 복구 | +| --- | --- | --- | +| TX1 commit 후 stage 전 | COMPLETED reservation + DB RESERVED intent, no object | same-key/same-fingerprint HTTP retry가 새 request producer로 same operation stage; retry가 없으면 expiry/abort report | +| upload 중 | DB RESERVED intent + pending/partial provider effect | exact provider evidence가 있으면 resolver가 reconcile; bytes 재현이 필요하면 HTTP retry producer만 continuation; worker가 request bytes를 invent/replay하지 않음 | +| verified 후 handoff 전 | DB RESERVED intent + exact verified stage evidence | worker가 exact stage/operation claim; staged evidence가 없으면 worker가 stage하지 않음 | +| handoff 후 pending DB commit 전 | active claim + DB RESERVED intent | worker가 pending CAS; claim expiry 후에만 abort CAS | +| pending DB commit 후 finalize 전 | DB PENDING + active/frozen claim | UploadIntent worker가 same operation finalize/claim renew | +| finalize 후 ready DB commit 전 | published reference + pending DB | worker가 reference inspect 후 ready CAS | +| ready DB 후 old retire 전 | new ready + old published | retirement outbox 재처리 | +| retire response loss | old state indeterminate | exact reference/version reconcile | + +Verified/staged object는 age만으로 reaper가 삭제하지 않는다. Application maintenance가 +operation ID로 business DB/UploadIntent와 관련 outbox evidence를 조회한 뒤: + +- pending/ready면 claim을 획득·갱신하거나 finalize를 재개; +- `UploadIntent=RESERVED`이고 active claim이 없으면 DB CAS로 `ABORT_AUTHORIZED` terminal과 + fence를 먼저 기록한 뒤 exact object/version `ObjectAbortAuthorization`을 발급; +- worker의 late `RESERVED -> PENDING` CAS는 `ABORT_AUTHORIZED` 뒤 실패하며 새 operation으로 + restage/manual recovery; +- object는 있는데 matching UploadIntent가 없으면 auto-delete하지 않고 corruption quarantine; +- DB/outbox 확인이 불가능하면 keep/quarantine하고 alert + +한다. DB absence read만으로 abort authorization을 만들지 않는다. 이 protocol은 orphan leak을 +data loss보다 우선하며 cleanup-vs-late-commit/claim-expiry race를 fault test한다. + +### 15.4 Truth priority + +- business visibility의 source of truth는 DB attachment `READY` 상태다; +- object availability의 source of truth는 published reference manifest다; +- 둘 중 하나만 ready이면 workflow가 non-terminal이다; +- HTTP download는 DB authorization/READY 확인 뒤 object reference를 resolve한다; +- DB row 삭제만으로 object가 물리 삭제되었다고 간주하지 않는다. + +### 15.5 Concurrency + +동일 Poster에 concurrent image upload가 오면: + +- 각 upload는 새 immutable reference를 만든다; +- DB aggregate version/pending attachment CAS로 winner를 결정한다; +- loser object는 publish하지 않거나, 이미 publish되었으면 TX3에서 intent를 + `SUPERSEDED` terminal로 전이하면서 exact losing reference/version retirement row를 같은 + transaction에 기록한다. CAS 예외로 TX 전체를 rollback해 cleanup evidence를 잃지 않는다; +- deterministic final key overwrite를 하지 않는다; +- retry는 same operation ID를 유지한다; +- user가 새 image를 선택한 새 의도에는 새 operation ID를 사용한다. + +### 15.6 Delete + +Poster 삭제: + +1. short DB transaction에서 business 삭제와 reference retirement intent/outbox를 기록한다; +2. object retirement worker가 visibility를 제거한다; +3. retention 정책 뒤 physical purge eligibility를 계산한다; +4. exact version conditional purge를 시도한다; +5. hold/retention은 정상 `HELD` 결과로 보존한다. + +Object delete 실패 때문에 이미 승인된 business delete transaction을 장시간 붙잡지 않는다. + +### 15.7 Legacy object adoption + +기존 raw locator를 opaque reference로 채택하는 기능은 일반 upload/publish port에 섞지 않는다. +Deprecated administrative migration seam과 명시적인 maintenance profile에서만 +`REPORT_ONLY` 또는 `APPLY`로 실행한다. `REPORT_ONLY`가 먼저 exact legacy namespace inventory와 +HEAD/read SHA-256 evidence를 bounded manifest로 만들며 mutation은 하지 않는다. + +`APPLY`는 그 immutable manifest와 별도의 canonical Ed25519 이중 승인 문서가 정확히 일치해야 +한다. 승인 문서는 schema version, adoption operation ID, manifest SHA-256, legacy namespace +digest, target destination/namespace, literal mode `APPLY`, 유효 시간, nonce, 서로 다른 trusted +approver 두 명을 묶는다. Permission-checked configured key file 외의 key는 신뢰하지 않는다. +Verifier는 두 서명과 모든 binding을 확인하고 nonce replay record를 exact operation/digest에 +CAS한 뒤, manifest bytes를 다시 hash한 다음에만 per-row inspect/digest/adoption CAS를 허용한다. +동일 terminal operation replay만 idempotent하며 다른 binding의 nonce 재사용은 거부한다. + +Scheduled maintenance runner는 web `SecurityContext`에 의존하지 않고 격리된 execution +identity와 administrative capability를 명시적으로 전달한다. Normal context에는 migration +port, verifier, replay store, runner bean이 하나도 없다. 적용 중 DB CAS loser나 +missing/corrupt/unknown-version object는 durable evidence와 reconciliation 대상으로 남기며, +자동 overwrite/copy/delete를 하지 않는다. Legacy object retirement는 별도 exact-reference +retirement authorization 뒤에 수행한다. + +## 16. Read, HEAD, range + +### 16.1 Inspect + +`inspect(reference)`는: + +1. reference syntax/check digits를 검증한다; +2. deterministic reference control key를 exact GET한다; +3. supported schema와 `PUBLISHED` 상태를 확인한다; +4. exact immutable data key/version을 얻는다; +5. 필요 시 provider HEAD로 size/checksum/version/encryption을 확인한다; +6. normalized descriptor만 반환한다. + +`Optional.empty`는 reference가 존재하지 않을 때만 사용한다. 다음은 typed error다. + +- malformed reference; +- forbidden destination; +- quarantined/retired; +- provider unavailable; +- corrupt manifest; +- unsupported schema. + +### 16.2 Server-mediated download + +- application authorization이 먼저다; +- adapter가 exact version/range precondition으로 provider read를 연다; +- configured buffer와 delivered-byte limit를 적용한다; +- consumer callback이 반환/실패하면 provider response body를 확실히 close/cancel한다; +- full read digest verification은 전체 object를 모두 읽었을 때만 success다; +- range read는 whole-object digest를 “검증 완료”로 표시하지 않는다; +- optional chunk/range digest가 manifest에 있을 때만 range integrity를 별도 증명한다; +- short read, excess read, wrong content-range는 provider protocol failure다; +- client disconnect는 partial delivery outcome이며 storage success와 구분한다. + +### 16.3 Direct download + +Presigned GET은: + +- DB authorization과 published/scan-clean 확인 후 발급한다; +- exact immutable key/version과 response header profile에 묶는다; +- short TTL과 maximum download size policy를 적용한다; +- URL을 log/audit payload에 저장하지 않는다; +- revocation이 필요한 resource에는 사용하지 않거나 매우 짧은 TTL을 사용한다; +- reference retire 후에도 이미 발급한 URL이 만료 전 유효할 수 있음을 계약에 명시한다; +- AWS는 request 시작 시 expiry를 평가하므로 expiry 직전 시작한 transfer가 이후 계속될 수 있고 + connection이 끊겨 재시도하면 실패할 수 있음을 client contract에 명시한다; +- AWS card는 bucket policy의 bounded `s3:signatureAge` upper bound를 qualification 후보로 둔다. + +One-time URL이라고 부르지 않는다. + +### 16.4 HTTP Range mapping + +Inbound HTTP adapter가 `Range`를 지원할 때: + +- single range만 baseline으로 허용한다; +- unsatisfiable range는 application typed outcome을 HTTP 416으로 매핑한다; +- `If-Range`, ETag 같은 HTTP transport semantics를 application port의 raw header로 넘기지 않는다; +- public ETag을 provider ETag 그대로 쓰지 않고 application version token으로 생성한다; +- exact `Content-Length`와 `Content-Range`를 descriptor/receipt로 검증한다. + +## 17. Checksum, media type, metadata + +### 17.1 Digest model + +```text +ObjectDigest { + algorithm: SHA_256 + encoding: LOWERCASE_HEX + value: exactly 64 lowercase hexadecimal characters + scope: FULL_CONTENT +} +``` + +R2 baseline business digest는 SHA-256이다. Provider transport checksum은 별도 value object다. + +```text +ProviderChecksum { + algorithm: CRC32 | CRC32C | CRC64NVME | SHA1 | SHA256 | qualified extension + checksumType: FULL_OBJECT | COMPOSITE + scope: OBJECT | PART(partNumber) + encoding: BASE64 + value +} +``` + +Algorithm만 같은 composite multipart checksum을 full-content SHA-256과 비교하지 않는다. + +- content digest: application-level immutable content identity/integrity; +- transport checksum: provider request/response corruption detection; +- ETag: provider-specific entity/version evidence; +- operation fingerprint: user intent identity. + +서로 대체하지 않는다. + +### 17.2 ETag 규칙 + +- single-part/plain object에서도 ETag을 portable MD5 계약으로 노출하지 않는다; +- multipart ETag은 whole-object MD5가 아니다; +- encryption/provider 구현에 따라 의미가 달라질 수 있다; +- ETag은 private conditional evidence로 보존할 수 있다; +- public API version은 opaque `ObjectVersionToken`이다. + +### 17.3 Direct upload checksum + +Grant에는: + +- required checksum algorithm; +- checksum header/field; +- declared exact/maximum size; +- content type; +- key/version precondition + +을 묶는다. Upload 뒤에는: + +1. client completion claim을 신뢰하지 않는다; +2. exact HEAD로 size/checksum/version을 확인한다; +3. provider가 full SHA-256을 증명하지 못하면 bounded verification read 또는 scan pipeline을 + 수행한다; +4. 검증 전에는 publish하지 않는다. + +### 17.4 Media type + +- inbound `Content-Type`은 declared value일 뿐이다; +- application이 destination별 allowlist를 적용한다; +- scan/content-sniff 단계에서 detected media type을 별도로 기록할 수 있다; +- declared/detected mismatch 정책은 business/application 소유다; +- browser-executable type은 download response의 disposition/CSP/nosniff 정책과 함께 다룬다; +- metadata value에 CR/LF/control character를 허용하지 않는다. + +### 17.5 Metadata budget + +Typed metadata마다: + +- field count; +- key/value byte length; +- character set; +- canonical encoding; +- redaction; +- persistence location; +- public exposure 여부 + +를 고정한다. Provider user-metadata 최대치에 기대어 application input을 무제한 허용하지 않는다. + +## 18. Direct upload grant + +### 18.1 Threat model + +Presigned request는 URL을 가진 주체가 제한된 provider operation을 실행할 수 있는 bearer +credential이다. + +따라서: + +- authenticated/authorized business intent 뒤에만 생성한다; +- TLS 외 endpoint를 production에서 허용하지 않는다; +- query string을 access log/APM/error message에서 redact한다; +- browser history/referrer 노출을 줄이는 client contract를 제공한다; +- TTL은 destination마다 짧은 upper bound를 둔다; +- underlying temporary credential 만료보다 길게 발급하지 않는다; +- provider/client clock skew budget을 빼고 grant expiry가 session expiry보다 먼저 오도록 compile한다; +- production signer/provider의 NTP/clock health가 허용 skew를 넘으면 신규 grant admission을 + fail closed한다; +- signing identity 권한보다 강한 grant를 만들 수 없음을 qualification한다; +- CORS는 exact origin/method/header allowlist로 pre-provision한다. + +### 18.2 Presigned PUT + +Grant에 고정할 항목: + +- method; +- exact immutable internal key; +- expiry; +- content type; +- checksum header; +- encryption headers; +- expected owner/provider profile; +- atomic create-only precondition; +- exact provider-enforced content length 또는 더 엄격한 hard byte ceiling; +- named profile이 요구하는 Object Lock mode/retain-until/legal-hold header; +- allowed signed headers. + +R2 `direct-put` card에서 atomic create-only는 필수다. 같은 URL이 expiry 전 재사용되거나 concurrent +사용되어도 immutable key를 덮어쓸 수 없어야 한다. AWS profile은 signed +`If-None-Match: *`와 bucket-policy enforcement를 qualification한다. Provider가 이를 지원하지 +않으면 `direct-put=UNSUPPORTED`이며 trusted ingress 또는 server-mediated upload로 보낸다. + +Presigned PUT만으로 exact body size를 모든 client/provider 조합에서 강제했다고 주장하지 않는다. +R2 direct admission은 다음 중 하나가 실제 fault/security test로 hard ceiling을 증명해야 한다. + +- signed exact `Content-Length`가 provider/client/proxy 조합에서 강제됨; +- browser POST policy의 `content-length-range`; +- controlled ingress proxy가 body를 provider write 전에 제한함. + +그 증거가 없으면 server-mediated upload를 사용한다. Upload 후 HEAD/quarantine/delete는 +publication integrity와 cleanup 수단일 뿐 storage/transfer cost DoS에 대한 admission bound가 +아니다. 사후 검증만 가능한 profile은 별도 R1 soft-limit profile로 낮추고 maximum provider +exposure를 명시한다. + +AWS direct upload와 per-object Object Lock을 조합하는 profile은: + +- retention/legal-hold header를 exact signed condition으로 묶고; +- Object Lock upload에 요구되는 `Content-MD5` 또는 qualified SDK checksum algorithm을 강제하고; +- exact version의 retention/legal-hold를 scoped `GetObjectRetention`/`GetObjectLegalHold` evidence로 + 검증한다. + +이 조합을 구현하지 않으면 bucket-default retention만 사용하거나 named direct+retention profile을 +`UNSUPPORTED`로 둔다. Generic 403을 `HELD`로 매핑하지 않는다. + +### 18.3 POST policy + +Browser POST는 optional capability다. + +Pinned AWS SDK 2.30.0 `S3Presigner`는 이 문서가 요구하는 browser POST-policy signer를 제공한다고 +가정하지 않는다. 별도 audited SigV4 POST policy signer와 golden/security test가 없으면 해당 +provider profile은 `directPost=UNSUPPORTED`다. + +- exact bucket/key; +- content-length-range; +- content type prefix가 아닌 exact/좁은 allowlist; +- checksum; +- encryption; +- success status; +- expiration + +을 policy condition으로 고정한다. Policy/fields도 bearer secret으로 redact한다. + +### 18.4 Completion + +Client가 direct upload 완료 API를 호출하면 application은: + +1. session ID와 operation ID/fingerprint를 확인한다; +2. expected object exact HEAD를 수행한다; +3. size/checksum/encryption/version을 검증한다; +4. required scan을 수행한다; +5. 그 뒤에만 publish 또는 DB pending attachment로 진행한다. + +Completion endpoint를 여러 번 호출해도 same terminal receipt를 반환한다. + +### 18.5 Public signing endpoint + +S3-compatible deployment는 application이 접근하는 internal endpoint와 browser가 접근할 public +signing endpoint가 다를 수 있다. + +Binding은 둘을 분리한다. + +- control endpoint: adapter SDK network target; +- presign endpoint: client가 실제 도달할 authority/scheme; +- approved host/scheme allowlist; +- path-style/virtual-host signing mode; +- proxy forwarded-host를 무조건 신뢰하지 않음. + +Request header나 arbitrary URL로 presign authority를 선택하지 않는다. + +### 18.6 Grant issue, response loss, reissue + +Grant response도 bearer credential delivery이므로 stable session/request와 durable generation을 +가진다. + +```text +SESSION_RESERVED + -> GRANT_PREPARED(generation, constraintsDigest, signingTime, expiry, credentialRevision) + -> GRANT_ISSUED + -> UPLOAD_VERIFICATION_IN_PROGRESS + -> DATA_UPLOADED | EXPIRED | ABORTED +``` + +Rules: + +- grant 발급 자체가 stable grant-operation ID와 fingerprint를 가진다; +- URL을 만들기 전에 constraints와 generation을 CAS로 `GRANT_PREPARED`한다; +- signing 뒤 반환 전에 `GRANT_ISSUED` evidence를 기록하되 URL 자체는 저장/log하지 않는다; +- response loss retry는 same session/fingerprint/generation을 lookup한다; +- frozen signing input과 credential revision으로 byte-identical grant를 안전하게 재생성할 수 있을 + 때만 같은 grant를 반환한다; +- 재생성이 불가능하면 CAS로 새 generation을 발급하며 old URL이 revoke됐다고 가정하지 않는다; +- frozen revision이 없거나 active-generation/exposure limit 때문에 안전한 replay/reissue가 + 불가능하면 `GRANT_REPLAY_UNAVAILABLE`로 fail closed한다; +- old/new generation은 같은 immutable key, exact content identity, hard size, create-only + constraint만 가진다; +- active generation 수와 worst-case expiry/in-flight exposure를 제한한다; +- grant response의 operation ID/fingerprint가 다르면 conflict; +- completion은 어느 generation을 사용했는지 client claim만 믿지 않고 exact object를 검증한다. + +Single direct upload abort/reaper는 모든 issued generation의 expiry + clock skew + qualified +provider in-flight horizon 전에는 terminal delete/absence를 주장하지 않는다. Finite horizon이 +없으면 controlled ingress를 사용하거나 indeterminate로 유지한다. + +Direct download grant는 expected reference lifecycle revision에 대해 `GRANT_ISSUED` record를 +conditional CAS한 시점을 linearization point로 삼는다. + +- retirement가 먼저 linearize되면 CAS가 실패하고 생성한 URL은 폐기하며 응답하지 않는다; +- grant issue가 먼저 linearize되면 이후 retirement가 일어나도 이미 issued URL의 잔여 유효성을 + 인정한다; +- URL response가 network에서 늦게 도착해도 ordering은 control CAS로 판정한다. + +Direct download reissue도 old URL의 expiry 전 유효성을 인정하고 active grant/audit count에 +포함한다. Secret delivery ACK loss를 “발급되지 않음”으로 취급하지 않는다. + +## 19. Multipart protocol + +### 19.1 사용 기준 + +Multipart는 다음 조건에서만 사용한다. + +- object size/profile이 configured threshold 이상; +- provider card가 exact capability를 지원; +- part count/size/concurrency budget이 계산 가능; +- abort/reconciliation/lifecycle backstop이 준비됨. + +Small object를 무조건 multipart로 보내지 않는다. + +### 19.2 Session + +`startMultipart`: + +1. operation/fingerprint reserve; +2. immutable internal key 생성; +3. provider multipart create; +4. provider upload ID를 private encrypted/control record에 저장; +5. opaque session ID와 bounded parameters 반환. + +반환 필드: + +- opaque session ID; +- minimum/maximum part size; +- maximum part count; +- maximum concurrent grants/uploads; +- session expiry; +- required checksum; +- final expected size/digest requirement. + +Provider upload ID를 public API에 노출하지 않는다. + +### 19.3 Part upload + +- part number는 1부터 시작하는 bounded integer; +- final part를 제외한 minimum size 규칙을 provider card가 제공; +- client는 arbitrary key/upload ID를 지정하지 않는다; +- part grant는 exact session/key/part/checksum/exact content length/expiry에 묶인다; +- grant expiry는 session expiry보다 짧거나 같다; +- out-of-order part upload는 허용하되 complete는 consecutive `1..N`을 오름차순으로 고정한다; +- final part만 configured minimum보다 작을 수 있다; +- part별 current grant generation은 하나이며 acknowledge 뒤 신규 grant를 발급하지 않는다; +- same part 재발급은 exact length와 full part digest가 같을 때만 새 generation으로 허용한다; +- prior grant는 revoke됐다고 보지 않으며 expiry + skew + qualified in-flight margin 전에는 새 + generation acknowledge/complete를 허용하지 않거나 새 multipart session으로 교체한다; +- provider가 signed part checksum을 강제하지 못하면 direct multipart R2를 비활성화하거나 + controlled ingress를 사용한다; +- direct mode에서 grant issuance count, declared bytes, active expiry window, worst-case replay + exposure를 tenant/destination/global budget으로 제한한다; +- 하나의 presigned part URL이 expiry 전 반복/concurrent 사용될 수 있으므로 provider-side 실제 + request concurrency/transfer bytes가 bounded됐다고 주장하지 않는다; +- strict transport admission은 revocable controlled ingress 또는 provider-enforced primitive가 + 필요하다. + +Browser direct multipart acknowledgement: + +1. CORS가 exact origin/method/request headers와 필요한 `ETag`/checksum response + `Expose-Headers`만 허용한다. +2. Client는 grant와 함께 받은 acknowledgement nonce, part number, bounded provider ETag/checksum + claim을 server에 보낸다. +3. `acknowledgePart`는 session/grant nonce/expiry/fence/exact part length/checksum을 검증한다. +4. Provider `ListParts` 또는 exact provider evidence로 part 존재와 ETag/checksum을 검증한다. +5. 검증된 provider evidence를 private ledger에 CAS로 기록한다. +6. Server는 provider value를 숨긴 `PartReceiptToken`을 반환한다. + +Forged, stale, wrong-session, superseded-attempt claim은 conflict/security audit이며 ledger에 넣지 +않는다. Complete request는 public provider ETag 목록이 아니라 server가 발급한 opaque part +token만 받는다. + +`COMPLETE_IN_PROGRESS` CAS 전에: + +1. 신규 grant/acknowledge admission을 닫는다. +2. 모든 relevant grant generation의 expiry/in-flight horizon을 만족하거나 controlled ingress + drain evidence를 얻는다. +3. paginated `ListParts`를 다시 읽는다. +4. current ledger revision, ETag, algorithm/type/scope checksum, exact length와 비교한다. +5. 불일치하면 complete하지 않고 re-acknowledge/reconcile한다. + +Complete CAS 뒤 신규 grant/acknowledge는 거부한다. Final object full SHA-256이 expected content +identity와 다르면 publish하지 않고 quarantine한다. + +### 19.4 Complete + +Complete 전: + +- required part numbers의 연속성; +- duplicate/missing part; +- each opaque token/session binding; +- expected total size; +- aggregate checksum policy; +- session expiry/state + +를 검증한다. + +Complete response loss는 “실패했으므로 다시 complete”가 아니다. + +Canonical transition: + +```text +ACCEPTING_PARTS + -> COMPLETE_IN_PROGRESS + pendingEffect.kind = MULTIPART_COMPLETE + certainty = NOT_SENT | SENT | INDETERMINATE + -> COMPLETED +``` + +Indeterminate complete는 `COMPLETE_IN_PROGRESS`와 pending-effect certainty를 유지한 채 exact key +HEAD, multipart state, size/checksum/version을 비교해 +`APPLIED`/`NOT_APPLIED`/계속 `INDETERMINATE`로 resolve한다. + +AWS conditional complete가 concurrent delete/write와 경합해 `409 Conflict`를 반환하면 기존 +upload ID에 complete만 재시도하지 않는다. Official provider semantics가 요구하는 경우 새 +`CreateMultipartUpload`부터 전체 session을 재시작하며, original operation record에는 old session +abort/cleanup과 replacement session link를 보존한다. Conditional complete에서 `404`, `409`, +`412`를 각각 provider/card evidence에 따라 분리한다. + +### 19.5 Abort와 orphan + +- abort admission 전에 새 part grant 발급을 막고 application-known attempt를 fence한다; +- 이미 발급한 presigned part URL과 provider가 수락한 in-flight request는 application fence로 + revoke됐다고 주장하지 않는다; +- application abort는 logical하게 idempotent; +- already completed session을 abort success로 오인하지 않는다; +- abort response loss를 reconcile한다; +- abort record는 `pendingEffect.kind=MULTIPART_ABORT`와 certainty를 유지한다; +- provider가 in-flight part의 late success를 허용하면 `Abort -> paginated ListParts -> 필요 시 + Abort 반복`으로 part가 없음을 확인한다; +- earliest terminal check는 `latest issued grant expiry + qualified clock skew + qualified maximum + provider in-flight/request duration` 뒤다; +- presigned expiry는 request-start admission이며 transfer cutoff가 아니므로 expiry만으로 + in-flight 종료를 증명하지 않는다; +- provider가 maximum in-flight horizon을 증명하지 못하면 controlled/revocable ingress를 + 사용하거나 abort certainty를 `INDETERMINATE`로 유지하고 repeated reaper + lifecycle + backstop만 claim한다; +- `NoSuchUpload`는 exact final object/session/control state와 함께 해석해 completed session을 + aborted로 오인하지 않는다; +- maximum abort attempt/deadline 뒤 empty evidence가 없으면 `MULTIPART_ABORT_INDETERMINATE`로 + 유지한다; +- session TTL 뒤 reaper가 exact control state를 claim한다; +- provider lifecycle의 incomplete-multipart abort rule을 backstop으로 설정한다; +- lifecycle rule만을 유일한 cleanup으로 사용하지 않는다; +- reaper와 lifecycle 사이 race를 terminal state/evidence로 처리한다. + +Finite deterministic abort를 R2 profile이 요구하면 위 maximum horizon 또는 revocable controlled +ingress evidence가 필수다. Incomplete-multipart lifecycle age도 이 horizon과 reconciliation +margin보다 길어야 한다. + +### 19.6 Provider limits + +AWS가 제공하는 maximum object/part limits를 portable application default로 그대로 사용하지 않는다. +Destination은 훨씬 낮은 안전한 limit를 고정하고, provider card가 이를 만족하는지만 판정한다. + +## 20. Conditional mutation과 outcome/error + +### 20.1 Mutation outcome + +모든 mutation은 다음 outcome을 사용한다. + +- `APPLIED`; +- `ALREADY_APPLIED`; +- `NOT_APPLIED`; +- `PRECONDITION_FAILED`; +- `INDETERMINATE`; +- `HELD`; +- `UNSUPPORTED`. + +`void`, boolean 한 개, generic success/failure로 축약하지 않는다. + +### 20.2 Preconditions + +지원하는 logical precondition: + +- create if absent; +- operation record state/version equals; +- reference absent; +- reference points to expected object/version; +- object exact version equals; +- retire only published; +- purge only retired and retention elapsed; +- multipart session state equals; +- request fingerprint equals. + +Provider primitive가 이 precondition을 안전하게 구현하지 못하면 해당 card를 R2로 활성화하지 +않는다. check-then-act를 atomic conditional mutation처럼 보고하지 않는다. + +### 20.3 Error taxonomy + +Application-visible typed category: + +- `INVALID_REQUEST`; +- `OBJECT_NOT_FOUND`; +- `OBJECT_NOT_PUBLISHED`; +- `OBJECT_QUARANTINED`; +- `OPERATION_CONFLICT`; +- `OPERATION_EXPIRED`; +- `PRECONDITION_FAILED`; +- `RANGE_NOT_SATISFIABLE`; +- `OBJECT_TOO_LARGE`; +- `METADATA_TOO_LARGE`; +- `MEDIA_TYPE_NOT_ALLOWED`; +- `CHECKSUM_MISMATCH`; +- `RETENTION_HELD`; +- `UNSUPPORTED_CAPABILITY`; +- `POLICY_REVISION_UNAVAILABLE`; +- `BINDING_SECURITY_MISMATCH`; +- `DEPENDENCY_ACCESS_DENIED`; +- `CAPACITY_EXHAUSTED`; +- `THROTTLED`; +- `DEPENDENCY_UNAVAILABLE`; +- `TIMEOUT`; +- `CANCELLED`; +- `CONTENT_PRODUCTION_FAILED`; +- `CONTENT_CONSUMPTION_FAILED`; +- `DIRECT_SESSION_EXPIRED`; +- `GRANT_REPLAY_UNAVAILABLE`; +- `SCAN_INDETERMINATE`; +- `PROVIDER_PROTOCOL_VIOLATION`; +- `PUBLISH_INDETERMINATE`; +- `CORRUPT_CONTROL_RECORD`; +- `UNSUPPORTED_CONTROL_SCHEMA`; +- `INTERNAL_ERROR`. + +각 category는: + +- safe public code/message; +- retryability; +- reconciliation requirement; +- health impact; +- metric outcome; +- audit severity + +를 table-driven mapping으로 가진다. + +### 20.4 Provider error mapping + +AWS/MinIO/filesystem raw exception은 adapter 안에서: + +- operation; +- provider error/status/code; +- bytes sent/received 여부; +- response presence; +- request id의 safe hash; +- exact precondition; +- retry attempt; +- mutation certainty + +를 고려해 normalize한다. + +HTTP 404만 보고 모두 not-found로 매핑하지 않는다. Wrong owner/bucket/permission/endpoint가 +404처럼 보일 수 있는 경우 startup binding과 operation context를 함께 사용한다. + +### 20.5 Retry + +- validation, checksum mismatch, conflict, held는 retry하지 않는다; +- throttling/unavailable은 bounded retry 대상일 수 있다; +- body producer가 non-repeatable이면 upload transport retry를 제한한다; +- immutable create의 response loss는 reconcile 먼저; +- multipart part는 exact part identity/checksum으로 retry 가능 여부를 판정한다; +- complete/delete/reference CAS는 unknown outcome 규칙을 따른다; +- SDK internal retry도 total amplification budget에 포함한다. + +Provider-wide `max-attempts`만으로 mutation 안전을 결정하지 않는다. Put, complete, reference CAS, +delete, abort별 retry policy와 physical attempt telemetry를 고정한다. Mutation에서: + +- SDK가 2회 이상 physical attempt를 수행했거나; +- response body/ack가 유실되었거나; +- AWS `CompleteMultipartUpload`처럼 initial HTTP 200 뒤 embedded error가 가능한 operation이면 + +final SDK exception/success 하나만 보고 certainty를 확정하지 않고 operation-specific evidence와 +reconciliation 규칙을 적용한다. + +## 21. Provider model + +### 21.1 Provider identity + +다음 exact provider type을 사용한다. + +- `filesystem-local-dev`; +- `filesystem-local-persistent` optional; +- `aws-s3-general-purpose`; +- `s3-compatible-minio--`. + +`s3`, `s3-compatible`, `filesystem` 같은 넓은 이름 하나로 production 보장을 선언하지 않는다. + +### 21.2 Capability descriptor + +Provider startup qualification 결과: + +```text +ObjectStorageCapabilityDescriptor + providerType + providerVersion + bindingRevision + qualificationTimestamp + evidenceRevision + evidenceExpiresAt + operations { + managedUpload: CapabilityEvidence + managedDownload: CapabilityEvidence + rangeRead: CapabilityEvidence + controlPlaneCas: CapabilityEvidence + directPut: CapabilityEvidence + directPost: CapabilityEvidence + directGet: CapabilityEvidence + managedMultipart: CapabilityEvidence + directMultipart: CapabilityEvidence + retirement: CapabilityEvidence + exactPurge: CapabilityEvidence + } + guaranteeAxes { + visibility + crashDurability + consistency + mutationOutcomeCertainty + versionIdentity + authoritativeAbsence + } + namedOperationProfiles { + transferMode + checksumAlgorithm/type/scope/encoding + encryptionProfile + retentionProfile + immutableCreate + exactSizeEnforcement + responseLossReconciliation + } + limits + qualificationEvidenceRevision +``` + +`CapabilityEvidence`: + +```text +status = SUPPORTED | UNSUPPORTED | UNVERIFIABLE +source = STATIC_ATTESTATION | STARTUP_PROBE | CI_QUALIFICATION +evidenceDigest +observedAt +validUntil +providerAndDeploymentIdentity +limitations +``` + +Flat boolean의 AND로 조합 capability를 승인하지 않는다. 예를 들어 각각의 checksum, direct +multipart, SSE-KMS, retention이 지원되어도 그 조합이 지원된다는 뜻이 아니다. Destination은 +exact named operation profile을 요구하고 binding compiler는 그 profile 전체에 대한 +`SUPPORTED` + unexpired evidence만 수용한다. + +Direct upload profile이 per-object retention을 요구하면 Object Lock/retention header와 +permission도 signed grant condition에 포함되어야 한다. 그렇지 않은 direct+retention 조합은 +별개 기능이 각각 supported여도 `UNSUPPORTED`다. + +Descriptor 값은 code default가 아니라: + +- provider/version allowlist; +- static deployment attestation; +- safe startup probe; +- integration/qualification evidence + +를 합성한 결과다. + +`UNVERIFIABLE`, expired evidence, guarantee axis가 requirement보다 낮은 상태는 +`SUPPORTED`로 취급하지 않는다. Atomic visibility와 crash durability, consistency와 outcome +certainty를 한 “durable/strong” boolean으로 합치지 않는다. + +### 21.3 Common semantic subset + +Common R2 baseline 후보: + +- private immutable create; +- exact GET/HEAD; +- bounded single range; +- SHA-256 logical digest; +- stable opaque reference; +- control record conditional mutation; +- response-loss reconciliation; +- version-aware retire/purge; +- encryption-at-rest evidence; +- finite timeout/resource budget. + +Provider가 하나라도 증명하지 못하면 destination requirement를 낮춰야 하는 것이 아니라 해당 +provider/destination binding이 startup에서 실패한다. + +### 21.4 Provider-specific capability + +다음은 common subset이 아니다. + +- AWS Object Lock; +- AWS DSSE-KMS; +- provider native checksum 조합; +- MinIO의 specific retention/lifecycle behavior; +- filesystem atomic/durability primitive; +- provider copy; +- provider notification; +- provider replication/region behavior. + +Application이 optional capability를 요구할 때 exact provider card를 통해서만 접근한다. + +## 22. Filesystem providers + +### 22.1 `filesystem-local-dev` + +목적: + +- local sample와 unit/contract test; +- network 없는 개발; +- object/reference/control codec 빠른 검증. + +제한: + +- R0/R1까지만; +- single process/node; +- production profile 금지; +- presign/multipart/KMS/versioning/Object Lock 미지원; +- local disk 소실을 durable storage로 간주하지 않음; +- container image layer 또는 read-only root default 사용 금지. + +활성화하려면 explicit dev/test profile과 absolute configured root가 필요하다. + +### 22.2 `filesystem-local-persistent` + +운영에서 local persistent disk가 정말 요구될 때 별도 card로 qualification한다. + +필수 조건: + +- dedicated mounted volume identity 검증; +- mount missing 시 local directory fallback 금지; +- restrictive root owner/permission; +- no-follow directory traversal; +- exclusive create; +- temp/control record atomic replace; +- file `force`와 directory durability strategy; +- disk/inode/free-space alert; +- quota와 reaper; +- single-node 또는 external fencing 범위 명시; +- backup/restore와 fsck/corruption runbook. + +NFS/shared mount는 이 card에 포함하지 않는다. 필요하면 Fileserver 설계와 별도 provider 설계를 +한다. + +### 22.3 Path safety + +Filesystem key resolution은 string normalize만으로 끝내지 않는다. + +- application raw key 입력 자체를 제거; +- canonical internal segments만 사용; +- root를 startup에서 real path로 pin; +- intermediate symlink/reparse point 거부; +- supported platform에서 `SecureDirectoryStream` 또는 directory-handle-relative operation 사용; +- temp/data/control root 분리; +- root alias와 empty final segment 거부; +- create/delete 시 exact file type과 link count 정책; +- recursive delete 금지; +- cleanup traversal 중 mount/device boundary 정책; +- TOCTOU race fault test. + +지원 플랫폼에서 필요한 safe primitive를 제공하지 않으면 provider card를 낮춘다. + +### 22.4 Write/durability + +Managed write: + +1. target directory 아래 private temp/exclusive file 생성; +2. restrictive permission 적용; +3. bounded stream과 hash; +4. file flush/force; +5. immutable final name으로 no-replace publish; +6. 필요한 directory force; +7. manifest/control CAS; +8. temp cleanup. + +Atomic visibility와 crash durability는 별도 descriptor field다. `ATOMIC_MOVE` 하나로 둘을 모두 +증명하지 않는다. + +### 22.5 Filesystem metadata + +Content type, digest, exact size, generation, state를 filename/xattr에만 의존하지 않는다. Versioned +private manifest를 사용한다. Xattr은 optional evidence일 뿐 portable truth가 아니다. + +## 23. AWS S3 general-purpose provider + +### 23.1 범위 + +초기 AWS card는 regional general-purpose bucket에 한정한다. + +- directory bucket/S3 Express 제외; +- access point/MRAP 제외; +- Requester Pays와 MFA Delete는 initial card에서 제외하고 필요 시 별도 permission/cost/operator + workflow card로 qualification; +- private bucket; +- versioning required 여부는 destination profile에 명시; +- active published object는 online-readable storage-class profile에 고정; +- restore workflow가 없는 initial card는 current data를 archive retrieval이 필요한 tier로 전환하는 + lifecycle을 거부; +- lifecycle, ownership, BPA, encryption은 pre-provisioned; +- runtime은 object/control prefix operation만 수행. + +### 23.2 Consistency + +AWS S3가 현재 제공하는 strong read-after-write/list consistency는 이 exact provider card에만 +적용한다. + +- PUT/DELETE/HEAD/GET 후 object data lookup; +- control record conditional protocol; +- LIST maintenance discovery. + +Bucket configuration, IAM propagation, DNS/network, replication 의미를 같은 consistency로 +확장하지 않는다. MinIO 또는 다른 compatible store로 일반화하지 않는다. + +### 23.3 Client 선택 + +초기 managed streaming transport는 Java-based `S3AsyncClient`를 우선 검토한다. 그러나 R2 +multipart orchestration을 SDK의 opaque automatic multipart에 맡기지 않는다. + +선정 이유: + +- unknown content length streaming 지원; +- standard SDK HTTP/timeouts/retry/metrics와 통합; +- provider client lifecycle을 한 곳에서 소유. + +R2 managed multipart는 adapter가 low-level: + +- `CreateMultipartUpload`; +- `UploadPart`; +- `ListParts`; +- `CompleteMultipartUpload`; +- `AbortMultipartUpload` + +를 직접 호출하고 provider upload ID, part evidence, pending effect, complete/abort certainty를 durable +control record에 보존한다. SDK-managed automatic multipart가 이 evidence/hook를 public하게 +노출하지 않으면 R2 card에 사용할 수 없다. + +Pinned 2.30.0의 automatic multipart는 upload ID/part ledger를 application adapter에 노출하지 +않고 실패 cleanup을 deterministic reconciliation protocol로 제공하지 않는다. 따라서 별도 +qualification 전에는 R1 convenience/transport evidence로만 취급한다. Maximum size와 integer +overflow를 포함한 boundary test 없이 large-object fallback으로 사용하지 않는다. + +CRT-based client는 high-throughput optional card다. 다음을 별도로 qualification한 뒤에만 사용한다. + +- SDK/HTTP configuration 차이; +- retry/timeout 의미; +- metric visibility; +- native library packaging; +- memory/direct-buffer footprint; +- shutdown and cancellation; +- checksum/multipart behavior. + +“더 빠르다”는 이유만으로 baseline을 교체하지 않는다. + +### 23.4 SDK version + +현재 build는 AWS SDK BOM `2.30.0`을 pin한다. 구현 계획은: + +- 승인된 exact version의 API/bug/security evidence를 다시 확인; +- 2.30.0부터 적용되는 default upload checksum calculation behavior를 characterization; +- `requestChecksumCalculation`, `responseChecksumValidation`, explicit algorithm을 provider + profile에 고정; +- AWS/MinIO/presign별 default CRC32와 explicit SHA-256 compatibility를 test; +- pinned 2.30.0 `CompleteMultipartUploadRequest.Builder.mpuObjectSize(Integer)` 경계를 반영해 + FULL_OBJECT multipart checksum profile이 `Integer.MAX_VALUE`를 넘으면 startup에서 거부하거나, + audited raw-header path/승인된 SDK upgrade 뒤에만 허용; +- 2 GiB 경계와 overflow test; +- dependency lock 갱신; +- provider qualification version 기록; +- version upgrade compatibility/fault test; +- deprecated/changed conditional header support 확인 + +을 포함한다. 이 문서가 미래 SDK의 API 존재를 보장하지 않는다. + +### 23.5 Conditional request + +Immutable data/control create에 `If-None-Match: *` 또는 해당 SDK의 exact conditional primitive를 +사용한다. CAS/delete에는 expected ETag/version/precondition을 사용한다. + +- request builder가 header를 제공하는지 exact pinned SDK에서 검증; +- proxy/gateway가 header를 보존하는지 integration test; +- conditional complete의 `404`/`409`/`412`와 transient/permission 오류를 분리; +- AWS가 `409` 뒤 multipart 전체 재시작을 요구하는 operation은 기존 upload ID complete retry + 금지; +- unsupported provider는 check-then-put으로 downgrade하지 않음; +- bucket policy로 conditional write를 강제할 수 있으면 deployment control에 포함. + +AWS bucket policy가 conditional create를 강제할 때 `PutObject`/`CompleteMultipartUpload`에는 +조건을 요구하되, conditional header를 받지 않는 `CreateMultipartUpload`/`UploadPart` 같은 +`s3:ObjectCreationOperation` 단계는 official policy shape에 맞게 exempt한다. 그렇지 않으면 +multipart를 403으로 막을 수 있다. ETag `If-Match` write/delete에 필요한 scoped +`s3:GetObject` permission도 qualification한다. + +### 23.6 Expected bucket owner + +모든 supported request에 expected bucket owner를 설정한다. Startup binding의 account/bucket +attestation과 함께 confused-deputy/misrouting을 줄인다. + +Expected-owner mismatch는 not-found가 아니라 hard configuration/security failure다. + +### 23.7 HEAD, checksum, authoritative absence + +- checksum을 HEAD/GET response로 요구할 때 `ChecksumMode.ENABLED`를 명시한다; +- checksum evidence는 algorithm/type/scope를 함께 읽고 business full digest와 무조건 비교하지 + 않는다; +- SSE-KMS checksum 조회에 필요한 `kms:Decrypt`와 provider 문서가 요구하는 KMS permission을 + qualification한다; +- HEAD 요청에 PUT용 encryption header를 보내지 않는다; +- 403/404만으로 absent, forbidden, wrong owner를 단정하지 않는다. + +AWS `HeadObject`는 missing key에서 caller의 `s3:ListBucket` 권한에 따라 404 또는 403을 반환할 수 +있다. Descriptor에 `authoritativeAbsence` evidence를 둔다. + +- destination prefix로 제한한 `s3:ListBucket`와 version workflow에 필요한 + `s3:ListBucketVersions` 권한으로 negative lookup을 증명하거나; +- absence를 끝까지 `INDETERMINATE`로 유지한다. + +Startup probe는 existing sentinel뿐 아니라 missing-key negative lookup과 forbidden-key +differentiation을 검증한다. MinIO도 exact permission/error behavior를 별도 card로 test한다. + +### 23.8 Encryption + +Destination이 다음 named encryption profile 중 하나를 요구한다. + +- `sse-s3`; +- `sse-kms:`; +- `dsse-kms:` optional. + +Application request에 raw KMS key ARN을 넣지 않는다. Binding compiler가 logical profile을 +pre-approved key와 encryption context로 resolve한다. + +검증: + +- single PUT에는 selected SSE request header; +- multipart에는 `CreateMultipartUpload`에 selected SSE-KMS/DSSE 설정; +- `UploadPart`와 `CompleteMultipartUpload`는 create 설정을 상속하며 SSE-C처럼 동일 KMS request + header를 반복 전송하지 않음; +- part/complete/final response와 HEAD encryption attestation; +- response/head encryption mode; +- KMS key identity/version policy; +- `GenerateDataKey`, `Decrypt` 등 exact operation에 필요한 KMS permission; +- copy 시 source/destination encryption; +- KMS throttling/error mapping; +- presign에 필요한 signed header. + +SSE-C는 baseline에서 제외한다. Key material을 application memory/header/log에 전달하지 않는다. + +### 23.9 Ownership와 public access + +Production requirement: + +- Block Public Access; +- bucket-owner-enforced object ownership; +- ACL disabled; +- public bucket policy 없음; +- access logging/CloudTrail data event 정책은 risk profile에 따라 활성화; +- runtime principal은 exact bucket/prefix/action 최소 권한; +- maintenance principal은 runtime principal과 분리 가능; +- public delivery는 application authorization + stream/presign만 사용. + +### 23.10 Versioning + +Versioning이 required인 destination: + +- startup attestation/probe로 enabled 확인; +- exact version ID를 private evidence에 보존; +- read/retire/purge가 version-aware; +- versioned read/purge role에 scoped `s3:GetObjectVersion`/`s3:DeleteObjectVersion` permission; +- delete marker와 object version을 구분; +- lifecycle noncurrent-version retention을 검증; +- suspended 상태를 enabled로 간주하지 않음. + +AWS bucket에 versioning을 처음 enable한 직후의 propagation window는 `enabled` 조회 한 번으로 +readiness를 승인하지 않는다. IaC attestation에 activation timestamp를 넣고 provider가 권고한 +soak 기간 뒤 sentinel create/read/delete/version test를 통과해야 신규 admission을 연다. + +### 23.11 Retention/Object Lock + +Object Lock card는: + +- exact provider/version의 enablement constraint와 irreversible setting; +- governance/compliance mode; +- default retention; +- legal hold permission; +- versioning; +- bypass-governance 권한 부재 또는 엄격한 별도 break-glass; +- clock/reference time; +- audit + +을 qualification한다. + +AWS general-purpose bucket은 current provider semantics에 따라 existing bucket enablement를 별도 +qualification한다. Older MinIO/other distribution의 creation-time-only 제약을 AWS에 +일반화하거나, 반대로 current AWS 동작을 old MinIO에 일반화하지 않는다. + +Application delete가 hold를 만나면 `HELD`이며 success purge로 보고하지 않는다. + +### 23.12 Lifecycle + +Pre-provisioned lifecycle은 다음을 backstop한다. + +- incomplete multipart expiration; +- noncurrent version retention; +- expired delete marker; +- separately copied immutable terminal-audit archive가 replay/retention horizon을 지난 뒤 만료되는 + narrow prefix. + +Lifecycle만으로 application attachment 상태를 판정하지 않는다. Rule ID와 expected digest를 +deployment attestation에 고정한다. + +Incomplete-multipart lifecycle age는 maximum active session + grant expiry + clock-skew + +reconciliation margin보다 길어야 한다. 정상 장기 upload를 lifecycle이 먼저 abort하지 않는지 +fault test한다. + +Baseline의 published/staged/quarantined data가 같은 immutable `data/v1` prefix를 사용하므로, +provider lifecycle은 control state를 보고 staged object만 안전하게 골라낼 수 없다. 따라서 +staged/quarantined data cleanup은 fenced reconciler만 수행한다. Lifecycle을 여기에 적용하려면 +state-safe immutable prefix 또는 immutable lifecycle marker가 publication과 race 없이 유지된다는 +별도 protocol/fault evidence가 먼저 필요하다. Bucket policy도 lifecycle engine의 잘못된 +deletion을 application state로 막는 대체 수단이 아니다. + +Live/current/non-terminal `control/v1` record에는 provider age-based lifecycle을 적용하지 않는다. +Lifecycle은 pending effect, replay-until, handoff fence, schema, live reference를 이해하지 못한다. +Control cleanup은 fenced reconciler가 exact state와 horizon을 검증해 수행한다. + +## 24. MinIO provider + +### 24.1 Exact qualification + +MinIO는 “S3-compatible”이라는 이유로 AWS card를 상속하지 않는다. + +Card는 최소 다음을 pin한다. + +- exact product/distribution; +- tested server version/range; +- deployment topology; +- Java SDK version; +- versioning; +- retention/Object Lock; +- lifecycle; +- checksum behavior; +- conditional request behavior; +- presign/path-style/virtual-host behavior; +- multipart complete/abort semantics; +- error code mapping; +- consistency/failure assumptions. + +### 24.2 Current Testcontainers evidence + +현재 MinIO integration test는: + +- OSS image `minio/minio:RELEASE.2024-01-16T16-07-38Z`; +- bucket create; +- byte[] put/get; +- exists/delete round trip + +정도의 functional topology 증거다. 이는 R1이며 다음을 증명하지 않는다. + +- streaming heap bound; +- conditional race; +- node/process/network failure; +- response-loss reconciliation; +- multipart orphan; +- versioning/retention; +- TLS/credentials/ownership; +- presigned public endpoint; +- rolling upgrade; +- backup/restore. + +§41의 current MinIO AIStor 문서는 미래 AIStor card를 설계하기 위한 primary reference이며 이 +OSS 2024 image의 동작 증거가 아니다. Current OSS card는 exact image digest, release/source +provenance, AWS-SDK-based contract/fault test로 별도 qualification한다. 현재 adapter는 MinIO +Java SDK가 아니라 AWS SDK를 사용하므로 MinIO Java SDK 문서도 current implementation evidence로 +사용하지 않는다. + +### 24.3 Endpoint + +- production은 HTTPS; +- certificate/hostname verification을 끄지 않는다; +- internal/public presign endpoint를 분리; +- path-style 설정은 exact deployment와 DNS에 맞춤; +- arbitrary endpoint override 금지; +- loopback/RFC1918 endpoint 허용은 explicit environment policy; +- region/signature expectation을 startup에서 검증; +- redirect를 provider equivalence로 따라가지 않는다. + +### 24.4 Feature downgrade 금지 + +MinIO가 특정 checksum/conditional/retention behavior를 지원하지 않으면: + +- 해당 capability evidence를 `UNSUPPORTED` 또는 증거가 불충분하면 `UNVERIFIABLE`로 둔다; +- 필요한 destination binding은 startup 실패; +- AWS semantics를 client-side check-then-act로 흉내 내지 않는다; +- 별도 protocol이 안전하다면 provider-specific 설계와 fault evidence 후 추가한다. + +## 25. Configuration schema + +### 25.1 Canonical prefix + +새 canonical prefix: + +```text +app.object-storage +``` + +기존 `ca-skeleton.objectstorage.*`는 migration 기간 legacy alias로만 탐지한다. + +- canonical과 legacy가 동시에 존재하면 startup 실패; +- legacy를 silent precedence로 덮지 않는다; +- migration warning에 secret/value를 출력하지 않는다; +- removal release를 문서화한다. + +### 25.2 Top-level + +예시: + +```yaml +app: + object-storage: + enabled: false + required-destinations: [] + providers: {} + destinations: {} + maintenance: + enabled: false +``` + +`enabled` default는 `false`다. Provider type과 destination은 default가 없다. + +### 25.3 Provider binding + +개념 예시: + +```yaml +app: + object-storage: + enabled: true + providers: + poster-s3: + type: aws-s3-general-purpose + bucket: ${OBJECT_STORAGE_POSTER_BUCKET} + region: ${AWS_REGION} + expected-owner: ${OBJECT_STORAGE_EXPECTED_OWNER} + credentials: + mode: default-chain + endpoint: + control: null + public-presign: null + addressing: virtual-hosted + encryption-profiles: + poster-default: + type: sse-kms + key-ref: poster-object-key + timeouts: + api-call: 20s + api-attempt: 8s + connect: 2s + tls-negotiation: 3s + acquire: 1s + read: 10s + write: 10s + pool: + max-concurrency: 64 + max-pending-acquires: 128 + retry: + strategy: standard + max-attempts: 3 +``` + +숫자는 예시이며 performance/fault test 없이 production default로 복사하지 않는다. + +### 25.4 Destination binding + +```yaml +app: + object-storage: + destinations: + poster-image: + provider-ref: poster-s3 + namespace: poster-image-v1 + allowed-operations: + - managed-upload + - server-download + - direct-download + maximum-object-bytes: 10485760 + required-content-digest: sha-256 + publication: + mode: scan-gated-reference + encryption-profile: poster-default + retention-profile: poster-standard + direct-upload-profile: disabled +``` + +실제 business media allowlist와 image decode 정책은 sample application typed settings/policy가 +소유할 수 있다. Adapter destination의 maximum bytes와 checksum/encryption은 infrastructure +safety ceiling이다. 둘 다 존재하면 더 엄격한 값을 적용한다. + +### 25.5 Settings type + +- immutable nested records 또는 constructor-bound settings; +- Bean Validation; +- `Duration`, `DataSize`, typed enum; +- provider별 sealed/validated variant; +- unknown property fail 정책; +- duplicate normalized destination/provider ID 거부; +- secret은 `String` field로 직접 바인딩하지 않고 secret reference/credential provider 사용; +- `toString`, validation message, actuator configprops에서 secret redaction; +- endpoint URI의 scheme/userinfo/query/fragment 검증. + +### 25.6 Invalid configuration + +다음은 startup hard failure다. + +- enabled인데 provider/destination 없음; +- required destination 누락; +- unknown provider type/version; +- destination의 provider ref 누락; +- plaintext production endpoint; +- endpoint에 userinfo/query/fragment; +- static credential pair 일부만 존재; +- production static literal credential; +- AWS provider의 expected owner 누락 또는 MinIO/filesystem provider의 required deployment + identity/mount attestation 누락; +- bucket/base root invalid; +- auto-create production option; +- unsupported capability requirement; +- size/part/retry/timeout가 0, 음수, overflow 또는 전체 budget과 모순; +- same namespace collision; +- public presign host가 allowlist 밖; +- scan-required인데 scan seam 없음; +- retention required인데 provider card 불충족; +- legacy/canonical key 동시 사용. + +## 26. Activation, lifecycle, bootstrap + +### 26.1 Exact activation + +Activation 순서: + +```text +settings bind/validate + -> enabled? + -> provider definitions compile + -> destination references compile + -> required capabilities compare + -> safe qualification/attestation verify + -> clients/presigners create + -> maintenance jobs register + -> readiness card publish +``` + +Disabled일 때: + +- filesystem root 생성 없음; +- AWS credential resolution 없음; +- client/event-loop/thread 없음; +- DNS/network 없음; +- scheduler 없음; +- health indicator 없음; +- warning spam 없음. + +### 26.2 No default provider + +`matchIfMissing=true`를 제거한다. Local development도 명시적으로: + +```yaml +app.object-storage.enabled: true +app.object-storage.providers.local.type: filesystem-local-dev +``` + +를 선택한다. + +### 26.3 Provisioning boundary + +Runtime startup은 다음을 만들거나 바꾸지 않는다. + +- bucket; +- KMS key; +- IAM policy; +- lifecycle; +- versioning; +- Object Lock; +- Block Public Access; +- ownership controls; +- CORS. + +IaC/deployment pipeline이 미리 provision한다. Runtime은 safe read/probe 또는 signed attestation으로 +검증한다. + +### 26.4 Startup qualification + +모든 runtime principal에 broad configuration read 권한을 주지 않는다. 두 mode를 지원한다. + +1. safe probe mode: + - expected owner가 있는 exact bucket/head; + - reserved sentinel prefix에 create/head/get/delete; + - conditional create/CAS; + - checksum/encryption response; + - optional multipart probe; +2. deployment attestation mode: + - IaC가 생성한 canonical capability document; + - bucket/account/region/policy/lifecycle/encryption/versioning digest; + - signer identity와 expiry; + - runtime은 signature/digest와 minimal data-plane probe만 검증. + +Probe object는 dedicated namespace, short TTL, bounded size, audit tag를 사용한다. Production user +namespace를 오염시키지 않는다. + +Attestation은 background에서 expiry 전에 refresh한다. + +- bounded last-known-good grace는 signed policy에 명시된 경우에만 사용; +- grace 중 readiness는 degraded이며 신규 write/direct grant admission을 막을 수 있다; +- expiry 뒤 required destination은 신규 mutation/grant를 fail closed; +- 이미 published exact-version read는 별도 read-only continuity policy와 live safe probe가 + 허용할 때만 유지; +- refresh failure가 current binding으로 silent recompile을 일으키지 않음; +- expiry/refresh/last-known-good 사용을 metric/audit한다. + +### 26.5 Bootstrap dependency + +현재 `app-bootstrap` registry는 objectstorage leaf에 production dependency를 허용하지 않는다. +실제 production runtime에 이 capability를 포함하려면: + +1. use case/runtime owner를 확정한다; +2. registry의 `allowed_dependencies`를 설계 승인 후 갱신한다; +3. Gradle dependency를 추가한다; +4. architecture verification을 통과한다; +5. required destination startup test를 추가한다. + +Classpath scan에 우연히 발견되는 configuration을 composition 근거로 사용하지 않는다. + +### 26.6 Lifecycle + +- client/presigner/event-loop executor ownership 명시; +- Spring context stop 시 새 operation admission 중지; +- grace period 동안 in-flight managed upload/download 완료; +- direct session은 durable state라 process shutdown과 분리; +- grace 초과 operation을 cancellation/indeterminate로 기록; +- SDK client/executor close; +- maintenance lease release; +- shutdown hook 하나에만 의존하지 않음. + +## 27. Security design + +### 27.1 Credential + +Production 우선순위: + +1. workload identity/instance/container role; +2. short-lived assumed role; +3. approved external credential process; +4. static credential은 local/test 전용. + +- default credential chain의 exact allowed source를 environment별로 검토; +- developer credential source가 production에서 우연히 선택되지 않게 한다; +- credential expiration/refresh failure를 metric/readiness에 반영; +- access key ID조차 일반 log에 출력하지 않는다; +- credential provider 객체는 provider configuration이 소유한다. + +### 27.2 Least privilege + +역할 분리 후보: + +- runtime managed transfer; +- presign issuer; +- reconciliation/cleanup; +- scanner read/quarantine; +- deployment qualification; +- break-glass retention administration. + +각 역할은 bucket-wide wildcard 대신 destination prefix와 action을 제한한다. Presign issuer 권한은 +grant 가능한 최대 권한의 상한이다. + +### 27.3 Network + +- TLS 1.2 이상; +- hostname/certificate verification; +- outbound DNS/host/port allowlist; +- VPC endpoint/private network 사용 시 policy와 DNS qualification; +- proxy 사용 시 CONNECT/authority/credential leak 검증; +- endpoint override는 static approved binding만 허용; +- SSRF-style request-controlled host/key/presign authority 금지. + +### 27.4 Content safety + +- extension과 client media type을 신뢰하지 않는다; +- maximum decompressed/archive expansion 같은 business risk는 scanner/application이 제한; +- dangerous format은 quarantine; +- scan engine failure/timeout은 clean이 아니다; +- malicious object는 isolation/retention/audit 정책에 따라 처리; +- public download에 `Content-Disposition`, `X-Content-Type-Options` 등 inbound response 정책 적용; +- active content를 same-origin inline으로 제공하지 않는다. + +### 27.5 Confidentiality + +- 모든 provider object/control record private; +- encryption at rest profile required; +- control record에 secret/presigned URL/PII 최소화; +- sensitive metadata는 application DB 또는 encrypted manifest; +- object/reference/key를 metric tag로 금지; +- debug body logging 금지; +- heap dump/core dump risk와 buffer zeroing 필요성을 data classification별로 검토. + +### 27.6 Audit + +감사 event: + +- operation reserved/terminal; +- direct grant issued/expired; +- scan verdict; +- publication/retirement/purge; +- retention held/break-glass; +- qualification mismatch; +- cleanup decision; +- corruption/indeterminate manual resolution. + +Audit에는 safe hashed operation/reference token, destination, action, actor/correlation, outcome, +policy revision을 기록한다. URL, raw key, filename, content는 기록하지 않는다. + +### 27.7 Threat-control-evidence matrix + +| Threat | Required control | Required evidence | +| --- | --- | --- | +| raw key/path traversal or symlink escape | generated canonical key, handle-relative no-follow filesystem access | property/race/security test | +| overwrite/reused direct URL | immutable key, atomic create-only, bucket policy | concurrent/replay provider test | +| oversized upload cost DoS | provider-enforced hard length or bounded ingress | excess-body security/cost-bound test | +| URL/credential leakage | redaction, short TTL, secret-safe telemetry | captured log/trace/audit negative test | +| malicious active content | quarantine, exact-version scan fence, fail-closed publish | clean/malicious/stale/timeout workflow test | +| confused bucket/account/endpoint | expected owner/deployment identity, endpoint allowlist, TLS | wrong-owner/host/cert startup test | +| checksum substitution | typed full/composite/part checksum, expected SHA-256 | algorithm/scope mismatch test | +| mutation ACK loss | pending effect, phase-specific indeterminate reconciliation | dropped-response fault test | +| DB/object split-brain | pending DB state, outbox handoff fence, explicit abort authorization | crash-at-every-gap test | +| forged multipart part claim | nonce/session/fence, ListParts verification, opaque token | forged/stale/cross-session test | +| cleanup data loss | supported schema, exact version, handoff/retention fence, report-only | cleanup-vs-late-commit fault test | +| privilege/hold bypass | retire/purge port split, least privilege, no default governance bypass | IAM/retention negative test | +| stale policy/route | frozen policy revision, retained route registry, no current fallback | rolling migration/revision removal test | +| body/resource exhaustion | aggregate buffers/concurrency/timeouts/cancellation | heap/direct-memory/FD/slow-peer test | + +## 28. Resource budget, timeout, retry, cancellation + +### 28.1 Budget dimension + +Provider/global/destination/tenant별로 제한한다. + +- maximum object bytes; +- maximum delivered range bytes; +- chunk/buffer bytes; +- concurrent managed uploads/downloads; +- concurrent multipart sessions; +- parts per session; +- concurrent parts per session; +- total in-flight part bytes; +- HTTP connection concurrency; +- pending connection acquire; +- control record size; +- pending operation count; +- cleanup batch size; +- scan backlog; +- presign issue rate. + +`byte[]` 전체 materialization을 없애도 concurrent buffer 곱이 heap/direct-memory를 초과할 수 +있으므로 aggregate budget test가 필요하다. + +Managed/server-mediated path는 actual in-flight resource를 admission control한다. Direct presigned +path는 grant issuance와 worst-case replay exposure만 제어하며 provider-side actual request +concurrency/bytes를 strict bound했다고 주장하지 않는다. + +### 28.2 Deadline decomposition + +하나의 “timeout” 필드로 합치지 않는다. + +- application operation deadline; +- connection acquire; +- DNS/connect; +- TLS negotiation; +- SDK API call; +- SDK API attempt; +- socket read/write idle; +- producer/consumer stall; +- scan; +- reconciliation; +- graceful shutdown; +- presign/session expiry. + +Child timeout의 합과 retry backoff가 parent deadline을 넘지 않게 compile한다. + +### 28.3 Retry amplification + +최악의 physical attempt: + +```text +application retry + x operation-kernel retry + x SDK retry + x multipart part count + x concurrent workers +``` + +Binding compiler가 최대 증폭을 계산하고 upper bound를 넘으면 startup 실패시킨다. Metric은 +logical operation과 physical SDK attempt를 분리한다. + +### 28.4 Backpressure + +- bounded executor/queue; +- semaphore admission; +- connection acquire queue 상한; +- multipart concurrency 상한; +- producer가 sink보다 빠르게 무한 buffer하지 않음; +- consumer가 느리면 provider read를 bounded 방식으로 늦춤; +- overload는 `CAPACITY_EXHAUSTED`/429·503 mapping; +- admission 거부가 thread starvation보다 먼저 발생. + +### 28.5 Cancellation + +Cancellation point: + +- before producer start; +- between chunks; +- while SDK future/HTTP body active; +- between multipart parts; +- scan wait; +- reconciliation wait. + +Cancellation 뒤: + +- provider request cancel/response body close; +- partial single upload/multipart abort; +- operation state persist; +- resources/semaphore release; +- caller에게 `CANCELLED` 또는 mutation certainty에 따른 `INDETERMINATE`; +- cancellation을 success로 기록하지 않음. + +### 28.6 Retryable producer + +Managed upload의 producer를 SDK가 임의로 재호출하게 하지 않는다. + +- single-pass producer는 one logical data production; +- transport retry가 body replay를 요구하면 adapter-owned bounded spool 또는 explicit repeatable + producer factory가 있어야 한다; +- spool은 private filesystem/object, quota, encryption, cleanup을 갖춘 별도 optional 전략; +- repeatability가 없으면 mutation을 reconcile하거나 new operation을 요구한다; +- input servlet stream을 재사용 가능하다고 가정하지 않는다. + +## 29. Reconciliation, cleanup, LIST + +### 29.1 Reconciler + +Reconciler는: + +- exact operation ID/reference/session으로 lookup; +- state/version CAS lease; +- bounded batch/deadline; +- provider exact HEAD/GET; +- normalized evidence comparison; +- deterministic continuation/compensation; +- terminal receipt restoration; +- audit/metric + +을 수행한다. + +Application retry path의 inline resolve와 background worker가 같은 kernel을 공유한다. + +### 29.2 Candidate discovery + +Discovery source: + +- durable queue/outbox; +- deterministic age-partitioned control prefix; +- bounded LIST with continuation cursor; +- retry/dead-letter registry. + +LIST의 문제: + +- large namespace 비용; +- pagination; +- concurrent add/delete; +- provider별 ordering/consistency; +- permission 제한. + +따라서 LIST는 후보를 놓치지 않도록 반복하는 maintenance 수단이며 단일 object truth가 아니다. + +### 29.3 Cleanup eligibility + +삭제 전 모두 만족: + +- owned namespace; +- supported schema; +- operation terminal/expired 상태; +- minimum age; +- active lease/fence 없음; +- published reference가 가리키지 않음; +- active application handoff claim 없음; +- verified/staged business object이면 application이 DB/outbox를 조회한 뒤 발급한 exact + `ObjectAbortAuthorization`; +- provider retention/legal hold 없음; +- exact version precondition; +- report-only 결과와 delete 계획 audit. + +Age, LIST 부재, expired worker lease만으로 `ObjectAbortAuthorization`을 대체하지 않는다. +Application 확인이 불가능하면 destructive cleanup을 보류한다. + +### 29.4 Cleanup mode + +- `disabled`; +- `report-only`; +- `delete`. + +Production 첫 활성화는 report-only 기간과 샘플 검토 뒤 delete로 전환한다. Mode 변경은 audit하고 +blast-radius limit를 둔다. + +### 29.5 Unknown/newer object + +Unknown prefix, malformed record, newer schema, missing ownership evidence는: + +- 삭제하지 않는다; +- metric/audit/alert; +- quarantine candidate report; +- manual or upgraded reconciler 대상. + +### 29.6 Notifications + +Provider object event는 reconciliation을 빠르게 하는 hint일 수 있다. + +- event 중복/순서 뒤바뀜/유실을 허용; +- exact state lookup 후 처리; +- event payload를 truth로 사용하지 않음; +- inbound event verification은 inbound/messaging adapter 소유; +- objectstorage outbound leaf가 messaging sibling adapter를 의존하지 않음. + +## 30. Versioning, retention, retirement, purge + +### 30.1 Lifecycle 용어 + +- detach: business entity가 reference를 더 이상 사용하지 않음; +- retire: reference를 download/publication 대상에서 제거; +- delete marker: provider current view 변경; +- purge: exact physical object version 제거; +- expire: policy age 도달; +- held: retention/legal hold로 purge 금지. + +용어를 섞지 않는다. + +### 30.2 Immutable replacement + +Replace는: + +1. 새 immutable object/reference publish; +2. DB CAS로 새 reference 선택; +3. old reference retire; +4. retention 후 old exact version purge. + +같은 key overwrite가 아니다. + +### 30.3 Retention source + +Retention은: + +- business minimum; +- security/quarantine; +- audit/control record; +- provider lifecycle; +- legal hold + +의 합성이다. 가장 긴/강한 requirement를 적용한다. Application이 짧은 TTL을 보내 provider +compliance retention을 줄일 수 없다. + +### 30.4 Physical purge + +- exact object version/token; +- current reference graph 확인; +- retention/hold 조회; +- conditional delete; +- delete response loss reconcile; +- versioned provider에서 delete marker와 version delete 구분; +- manifest/control record tombstone 보존; +- audit/replay/retention horizon 뒤에도 §11.4의 epoch seal/rejection record protocol을 거쳐 + control record를 정리. + +AWS versioned bucket에서 noncurrent version의 exact purge identity는 `versionId`다. `If-Match`가 +current version에 대해 평가되는 의미를 noncurrent-version CAS로 일반화하지 않는다. + +- unversioned/current object: provider가 증명한 ETag `If-Match`와 exact key; +- noncurrent version: exact `versionId`, reference graph/retention fence, provider-specific delete + evidence; +- delete marker: marker version ID를 별도 type으로 구분. + +Provider가 concurrent safety를 증명하지 못하면 purge card를 `UNVERIFIABLE`로 둔다. + +### 30.5 Quarantine retention + +Malicious object는 즉시 public visibility에서 격리하지만 physical delete 시점은 security/audit +policy가 결정한다. Scanner verdict evidence와 content 접근 권한을 최소화한다. + +## 31. Observability + +### 31.1 Metrics + +예시: + +- `object_storage_operation_total{destination,provider,operation,outcome}`; +- `object_storage_operation_duration_seconds{destination,provider,operation}`; +- `object_storage_bytes_total{destination,provider,direction,outcome}`; +- `object_storage_inflight{destination,provider,operation}`; +- `object_storage_sdk_attempt_total{provider,operation,outcome}`; +- `object_storage_reconciliation_total{provider,resolution}`; +- `object_storage_indeterminate_current{destination,provider,operation}`; +- `object_storage_orphan_candidate_current{destination,provider,type}`; +- `object_storage_multipart_session_current{destination,provider,state}`; +- `object_storage_cleanup_total{provider,mode,outcome}`; +- `object_storage_credential_refresh_total{provider,outcome}`; +- `object_storage_capability_mismatch_total{provider,capability}`; +- `object_storage_buffer_bytes{provider,direction}`; +- `object_storage_pool_pending_acquire{provider}`. + +허용 tag: + +- logical destination; +- exact provider type; +- operation family; +- normalized outcome/error; +- capability/card revision의 bounded token. + +금지 tag: + +- bucket/key/path; +- object/reference/operation raw ID; +- tenant/user; +- filename/media metadata; +- endpoint; +- presigned URL; +- provider request ID. + +### 31.2 Trace + +한 logical operation span 아래: + +- binding; +- admission; +- producer/consumer; +- provider attempt; +- integrity verify; +- control transition; +- scan wait; +- reconciliation + +span을 둔다. Content와 URL을 attribute로 넣지 않는다. Provider request ID가 필요하면 bounded hash와 +restricted debug log로만 연결한다. + +### 31.3 Logging + +- terminal transition과 operator action은 structured info/audit; +- transient attempt는 rate-limited debug; +- payload/body, URL, credential, raw key 금지; +- exception message sanitize; +- same operation retry log storm 억제; +- cleanup report는 bounded sample + aggregate count. + +### 31.4 Health + +- liveness는 object storage를 호출하지 않는다; +- readiness는 required destination/card만 평가한다; +- optional destination 장애는 degraded로 노출하되 전체 readiness 정책은 composition에서 결정; +- health request마다 bucket/list/write를 하지 않는다; +- cached qualification + low-rate sentinel probe; +- dependency outage, credential refresh, control corruption, capacity saturation을 구분. + +### 31.5 SLO + +Provider/card별: + +- managed upload success/latency; +- published download success/latency; +- direct completion verification latency; +- indeterminate resolution age; +- orphan backlog age; +- multipart abandon age; +- scan pending age; +- credential expiry horizon + +를 정의한다. Provider SDK success rate만으로 business publication SLO를 계산하지 않는다. + +## 32. Readiness card + +### 32.1 Card ID + +한 module card가 아니라 capability별 card: + +- `object-storage-managed-upload-single`; +- `object-storage-managed-upload-multipart`; +- `object-storage-managed-download`; +- `object-storage-direct-upload-single`; +- `object-storage-direct-upload-multipart`; +- `object-storage-direct-download`; +- `object-storage-quarantine-publication`; +- `object-storage-retention`; +- `object-storage-reconciliation`. + +Registry entry는 exact provider type/version과 destination profile을 dimension으로 가진다. + +### 32.2 R0 + +- application port/value object compile; +- adapter/provider seam compile; +- settings disabled by default; +- no framework/provider type leak; +- unit tests for validation/fingerprint/state. + +### 32.3 R1 + +- local-dev and version-pinned MinIO functional topology; +- bounded managed stream; +- exact head/range; +- checksum happy/mismatch; +- immutable create/conflict; +- control record codec/CAS basic; +- lifecycle/close tests; +- sample consumer contract. + +R1은 production security/failure recovery를 뜻하지 않는다. + +### 32.4 R2 + +모든 R2 card의 공통 evidence: + +- production-like TLS/credential/ownership/encryption; +- startup qualification; +- connection/DNS/TLS/read/write timeout; +- throttling and retry amplification; +- request/response loss; +- process kill/crash recovery; +- rolling schema compatibility; +- observability/redaction; +- no silent test skip. + +Card별 추가 evidence: + +| Card | Required evidence | +| --- | --- | +| managed-upload-single | bounded producer/heap, immutable create, checksum, hard size, single-put response-loss reconciliation | +| managed-upload-multipart | adapter-owned session/upload ID/part ledger, bounded buffers/parts, complete/abort/409/response-loss, orphan cleanup, full digest | +| managed-download | exact published version, full/range read, bounded consumer/heap, truncated/slow/failed consumer, response-body close | +| direct-upload-single | atomic create-only, provider-enforced hard size ceiling, signed checksum/header, expiry/CORS/public endpoint, completion HEAD/verification | +| direct-upload-multipart | session/part ledger, CORS exposed evidence, acknowledge handshake, part budget, complete/abort/409/response-loss, orphan cleanup | +| direct-download | authorization-before-grant, published exact version, expiry/signature-age, response headers, URL redaction/revocation limitation | +| quarantine-publication | unpublished narrow read, scanner fence/policy, clean/malicious/indeterminate, handoff/DB crash gaps, cleanup authorization race | +| retention | versioning, exact version purge, delete marker, lifecycle, retention/legal hold, privileged purge separation | +| reconciliation | pending-effect phase, authoritative absence, conditional race/CAS, process kill, replay tombstone, backup/restore reconciliation | + +Card가 요구하지 않는 multipart, presign, retention evidence를 억지로 요구하지 않는다. 반대로 다른 +card의 evidence를 가져와 해당 card가 준비됐다고 주장하지 않는다. Exact named operation profile +조합이 matrix의 모든 relevant evidence를 만족해야 한다. + +### 32.5 R3 + +- multi-node failover/fencing; +- rolling provider/application upgrade; +- regional/cluster disaster recovery; +- restore ordering and integrity audit; +- sustained scale/soak; +- quota/capacity exhaustion; +- credential/KMS rotation under load; +- provider version upgrade/rollback; +- operational game day and runbook evidence. + +### 32.6 Card claim rule + +Readiness claim은 다음 형태다. + +```text +card + provider exact type/version + destination profile + evidence revision +``` + +예: + +```text +object-storage-managed-upload-single + / aws-s3-general-purpose + / poster-image-v1 + / R2 + / evidence-2026-08-... +``` + +“Objectstorage R2”처럼 범위를 생략한 표현은 금지한다. + +## 33. Test strategy + +### 33.1 Application-core unit/property + +- ID/reference syntax and check digits; +- operation key/epoch seal/rejection/rotation; +- canonical request fingerprint golden vector; +- content digest representation; +- state transition table; +- same operation/same fingerprint; +- same operation/different fingerprint; +- range arithmetic/overflow; +- deadline/budget validation; +- typed error/outcome exhaustiveness; +- framework/AWS type absence. + +### 33.2 Provider-neutral contract + +모든 qualifying provider에 같은 semantic suite: + +- empty/one-byte/chunk-boundary/maximum-size upload; +- immutable create and conflict; +- exact descriptor/version; +- full and range read; +- short/slow/failing producer; +- slow/failing consumer; +- checksum match/mismatch; +- cancel/resource close; +- same operation terminal replay; +- different fingerprint conflict; +- conditional retire/delete; +- indeterminate resolve; +- control schema compatibility. + +Provider unsupported capability test는 silent skip 대신 descriptor와 expected `UNSUPPORTED`를 +검증한다. + +### 33.3 Filesystem + +- traversal/root alias/absolute path/Unicode ambiguity; +- symlink swap and nested symlink; +- exclusive create race; +- process kill before/after force/publish/control CAS; +- disk full, inode exhaustion, permission denied, read-only mount; +- mount identity mismatch; +- cleanup unknown/newer schema; +- file descriptor leak; +- restrictive permission. + +### 33.4 MinIO + +- version-pinned Testcontainers; +- actual streaming; +- conditional create/control CAS; +- multipart part/complete/abort; +- presigned PUT/GET and signed headers; +- grant response-loss/reissue/retirement linearization; +- checksum/head; +- versioning/lifecycle/retention where card claims; +- network cut via Toxiproxy; +- process restart; +- concurrent operations; +- endpoint/path-style/public presign; +- no Docker이면 selected readiness task가 성공으로 끝나지 않음. + +Developer fast test는 Docker 없이 skip할 수 있지만 readiness task는 required environment 부재를 +failure로 처리한다. + +### 33.5 AWS sandbox + +AWS-only R2 evidence: + +- actual account/region/bucket owner; +- TLS/VPC endpoint if used; +- workload role/temporary credential refresh; +- Block Public Access/object ownership; +- SSE-KMS/DSSE profile; +- conditional write/delete; +- strong consistency assumption test boundary; +- versioning/delete marker/noncurrent purge; +- lifecycle/incomplete multipart; +- Object Lock/retention optional card; +- KMS and S3 throttling; +- credential/permission revocation; +- presigned URL expiration and signed checksum; +- request IDs captured safely. + +Sandbox resource provisioning/cleanup은 IaC와 unique namespace를 사용한다. + +### 33.6 Fault/concurrency + +- two writers same operation/same fingerprint; +- same operation/different fingerprint; +- two new operations replacing same attachment; +- DB UploadIntent reserve/pending/abort fence race; +- direct grant response loss, multiple outstanding generations, expiry/in-flight horizon; +- same multipart part reissue, stale late request, acknowledge/complete race; +- operation epoch multi-node cutover/seal/rollback; +- response dropped after put/complete/delete/reference CAS; +- DB crash at every §15 gap; +- worker lease expiry and takeover; +- cleanup versus late finalize; +- retention activated during purge; +- scanner delayed/duplicate/stale verdict; +- rolling old/new control schema. + +### 33.7 Performance/resource + +- object size가 커져도 heap이 size와 선형 증가하지 않음; +- configured chunk/pool/multipart aggregate bound; +- direct memory/FD/thread stability; +- slow producer/consumer; +- connection pool saturation; +- retry storm; +- large concurrent range download; +- graceful shutdown with in-flight operation; +- long soak with cleanup/reconciliation. + +### 33.8 Security/config + +- disabled zero-side-effect context; +- canonical/legacy conflict; +- secret redaction; +- invalid endpoint/owner/credential pair; +- no plaintext production endpoint; +- no auto-create; +- unsupported required capability startup failure; +- presigned URL absent from logs/traces; +- raw key/path/URI absent from public receipt; +- ACL/public access configuration mismatch; +- malicious filename/metadata/control character. + +## 34. Gradle, CI, supply chain + +### 34.1 Focused task + +현재 owner leaf: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test --console=plain +``` + +향후 task 후보: + +```text +:adapter:outbound:objectstorage:objectStorageUnitTest +:adapter:outbound:objectstorage:objectStorageFilesystemContractTest +:adapter:outbound:objectstorage:objectStorageMinioContractTest +:adapter:outbound:objectstorage:objectStorageMinioFaultTest +:adapter:outbound:objectstorage:objectStorageAwsQualificationTest +:adapter:outbound:objectstorage:objectStorageSecurityTest +:adapter:outbound:objectstorage:objectStorageResourceTest +``` + +Task 이름과 exact selected provider/card를 machine-readable readiness registry에 연결한다. + +### 34.2 CI lanes + +- PR fast: unit, architecture, filesystem, mocked mapping; +- PR container: MinIO common contract; +- scheduled fault: MinIO/Toxiproxy/process restart; +- protected AWS: sandbox qualification/security; +- release gate: required production cards, schema compatibility, supply-chain scan; +- soak/game day: R3 evidence. + +### 34.3 Architecture verification + +필수: + +```bash +./gradlew verifyCleanArchitectureDependencies +./gradlew test +./gradlew check +``` + +추가 ArchUnit/compile checks: + +- `application-core`에 `software.amazon.awssdk`, Spring, `Path`, inbound type 없음; +- objectstorage leaf가 sibling adapter/sample/bootstrap을 의존하지 않음; +- controller가 repository/SDK/persistence entity를 사용하지 않음; +- raw inbound DTO가 application/domain으로 유출되지 않음. + +### 34.4 Dependency + +- AWS SDK BOM/lock exact pin; +- async HTTP implementation 선택과 transitive dependency review; +- CRT 사용 시 native binary provenance/SBOM; +- CVE/license scan; +- checksum/crypto provider 정책; +- Testcontainers/MinIO image digest pin; +- dependency update 후 provider qualification 재실행; +- unused provider dependency를 runtime image에서 제거할 필요가 생기면 leaf split 검토. + +## 35. Migration plan + +### Phase 0 — Truth and characterization + +변경: + +- 현재 CRUD/overwrite/default activation/sample transaction을 characterization test; +- existing settings/env/runtime usage inventory; +- public `file://`/`s3://` response 소비자 확인; +- current MinIO test를 R1로 명명. + +Acceptance: + +- 현재 동작과 위험이 evidence로 고정; +- production readiness claim 없음. + +Rollback: + +- code behavior change 없음. + +### Phase 1 — Framework-free semantic contract + +변경: + +- ID/reference/version/digest/range/error/outcome; +- streaming callback; +- managed publication/inspection/transfer port; +- existing port는 deprecated compatibility seam; +- default disabled settings skeleton. + +Acceptance: + +- application-core pure unit/architecture test; +- byte[] path를 새 business flow가 사용하지 않음; +- provider type leak 0. + +Rollback: + +- old port consumer 유지, 새 binding disabled. + +### Phase 2 — Provider-neutral kernel and local R1 + +변경: + +- key/reference/control codec; +- operation fingerprint/state machine; +- local-dev provider; +- bounded stream/checksum/immutable create; +- single-node reconciliation; +- disabled zero-side-effect composition. + +Acceptance: + +- provider-neutral contract; +- filesystem security/crash tests; +- R1 card만 게시. + +Rollback: + +- new capability disabled, old local example 유지. + +### Phase 3 — S3 managed transfer common subset + +변경: + +- async S3 client; +- finite timeout/pool/retry; +- streaming put/get/head/range; +- conditional data/control operation; +- checksum/encryption/version evidence; +- exact AWS/MinIO provider package 분리. + +Acceptance: + +- MinIO contract/fault R1; +- AWS sandbox common-subset partial target evidence, Phase 6 전 R2 card claim 금지; +- heap/resource bound. + +Rollback: + +- destination provider binding을 qualified previous provider로 전환; +- operation/reference schema backward-readable. + +### Phase 4 — Direct transfer and multipart + +변경: + +- presigner; +- opaque session/part token; +- multipart ledger/complete/abort/reconcile; +- public signing endpoint; +- browser POST optional. + +Acceptance: + +- URL/header/expiry/redaction; +- complete response-loss; +- orphan cleanup/lifecycle backstop; +- provider/card exact evidence. + +Rollback: + +- direct card disabled; +- managed server upload/download 유지; +- existing sessions drain/expire/reconcile. + +### Phase 5 — Staged scan/publication and sample migration + +변경: + +- staged port; +- scan verdict seam; +- durable reference publish; +- Poster pending/ready/retired state; +- short DB transaction/outbox worker; +- public DTO opaque reference. + +Acceptance: + +- every crash gap test; +- unscanned object inaccessible; +- concurrent replacement deterministic; +- old object retirement; +- no raw locator exposure. + +Rollback: + +- new upload admission 중지; +- pending operation drain/reconcile; +- existing published reference reader 유지; +- old API 제거 전 dual-read compatibility. + +### Phase 6 — Production security and maintenance R2 + +변경: + +- exact settings/qualification; +- workload credential/expected owner/TLS; +- ownership/BPA/encryption/versioning/lifecycle; +- reaper/report-only/delete; +- exact reconciliation card를 위한 disposable namespace bounded backup/restore reconciliation; +- readiness/metrics/audit/runbooks. + +Acceptance: + +- exact required card R2; +- protected fault/security tests; +- cleanup report review; +- no false global claim. + +Rollback: + +- maintenance delete -> report-only; +- provider/card admission off; +- published reads 유지; +- manual reconciliation queue 보존. + +### Phase 7 — R3 and split review + +변경: + +- multi-node fencing; +- rolling upgrade, regional/cluster disaster-recovery game day, restore under failover, scale; +- provider leaf split decision; +- optional retention/direct/high-throughput cards. + +Acceptance: + +- R3 evidence and game day; +- split/no-split ADR; +- restore integrity audit. + +Rollback: + +- optional card disable; +- last qualified provider/schema write version. + +## 36. Existing API compatibility and removal + +### 36.1 Legacy port + +기존 `ObjectStoragePort`는 migration 동안: + +- `@Deprecated`와 explicit legacy name; +- production profile disabled; +- separate legacy root/bucket prefix; +- public URI 반환을 신규 API가 재사용하지 않음; +- usage metric; +- removal deadline + +을 가진다. + +Legacy와 new namespace가 겹치면 startup 실패한다. + +### 36.2 Existing data + +Raw key를 저장한 Poster data migration: + +1. raw key inventory; +2. object exact HEAD/digest/media/size; +3. immutable new reference manifest 생성; +4. DB row를 opaque reference로 CAS migration; +5. dual-read 기간; +6. public response에서 locator 제거; +7. legacy object retirement; +8. unknown/missing/corrupt data report. + +Migration이 existing key를 무조건 rename/copy/delete하지 않는다. Provider/version/retention에 따라 +별도 plan을 생성한다. + +### 36.3 API contract + +기존 upload response의 `key`/`location` 제거는 breaking change다. + +새 response 후보: + +```json +{ + "reference": "osr1....", + "size": 12345, + "mediaType": "image/png", + "digest": { + "algorithm": "SHA-256", + "value": "..." + }, + "state": "READY" +} +``` + +실제 public field/version은 inbound API 설계와 snapshot test로 승인한다. Presigned URL은 별도 +authorization endpoint의 ephemeral response이며 stored object DTO에 영구 포함하지 않는다. + +## 37. 구현 계획 작성 전 확정 항목 + +정본 구현 순서와 아래 결정의 현재 freeze/approval gate는 +[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md)에 +기록한다. Tasks 1–11은 Batch A–B와 Batch C 초입에서 구현됐고 Tasks 12–30은 아직 미착수다. +이 구현 진행이나 +계획 승인이 public API, scanner provider 또는 AWS/IaC 외부 변경 권한을 자동으로 부여하지 +않는다. + +구현 계획은 다음 결정을 task 단위로 명시해야 한다. + +- exact Java type/package 이름; +- old/new port coexistence 기간; +- control record serialization format; +- conditional CAS primitive; +- reference text format과 check digit; +- async S3 HTTP implementation; +- approved AWS SDK version; +- local-dev root; +- exact AWS/MinIO test versions; +- application deadline/cancellation representation; +- scanner capability owner와 port; +- sample Poster schema/outbox migration; +- bootstrap registry edge 필요 여부; +- canonical env/settings/secrets registry 변경; +- machine-readable readiness registry schema; +- cleanup lease/fence; +- API breaking-change versioning. + +이 중 architecture 또는 public contract를 바꾸는 선택은 brainstorming/설계 승인 없이 구현 +task에서 임의 결정하지 않는다. + +## 38. Completion criteria + +### 38.1 Design complete + +- 현재 코드와 sample workflow의 증거가 기록됨; +- alternatives와 selected architecture가 기록됨; +- port/identity/state/protocol/provider/config/security/test/migration 결정이 연결됨; +- official primary references로 변동 가능한 provider 의미가 뒷받침됨; +- 상위 설계에서 dedicated design으로 링크됨; +- 독립 리뷰에서 blocker/high가 해소됨; +- 문서 검증과 LLM Wiki capture 또는 차단 사유가 기록됨. + +### 38.2 Implementation complete + +다음이 모두 있어야 하며 이 문서 작성만으로 충족되지 않는다. + +- approved implementation plan; +- test-first code; +- focused/common/full architecture verification; +- exact provider/card readiness evidence; +- sample workflow migration; +- settings/env/secrets/runtime docs; +- runbook and observability; +- Wiki branch-note; +- human code review. + +### 38.3 R2 complete + +Provider/card별 §32.4 evidence와 production-like qualification이 있어야 한다. MinIO byte[] happy +path, mocked AWS test, filesystem unit test만으로는 R2가 아니다. + +## 39. 금지하는 완료 표현 + +다음 표현은 해당 exact evidence 없이 사용하지 않는다. + +- “ETag은 object MD5다.” +- “presigned URL은 한 번만 쓸 수 있다.” +- “S3-compatible이므로 AWS S3와 동일하다.” +- “PUT이 성공했으므로 DB와 object가 원자적으로 commit됐다.” +- “DB rollback이 object upload도 취소했다.” +- “versioning이 켜져 있으므로 삭제됐다.” +- “delete가 204라 physical version이 사라졌다.” +- “HEAD bucket이 성공했으므로 모든 capability가 준비됐다.” +- “TLS와 SSE를 켰으므로 secure하다.” +- “auto-create가 편리하므로 production에서도 안전하다.” +- “normalize/startsWith로 symlink 공격을 막았다.” +- “MinIO test가 통과했으므로 AWS production ready다.” +- “multipart complete timeout이므로 적용되지 않았다.” +- “retry하면 정확히 한 번 upload된다.” +- “LIST에 없으므로 object가 없다.” +- “scan timeout이므로 clean으로 간주한다.” +- “direct upload 완료 callback을 받았으므로 검증됐다.” +- “objectstorage module이 R2다.” + +## 40. 운영 runbook 요구 + +- required destination startup qualification mismatch; +- wrong bucket/account/region/endpoint; +- credential expiry/refresh/rotation; +- KMS deny/throttle/key disabled; +- TLS/certificate/DNS/VPC endpoint failure; +- connection pool/acquire saturation; +- managed upload/download timeout; +- checksum mismatch/corrupt object; +- scan backlog/scanner outage/malicious verdict; +- publication indeterminate; +- control record corruption/newer schema; +- multipart complete indeterminate; +- abandoned multipart growth; +- orphan/staged/quarantined backlog; +- cleanup report-only에서 delete 전환; +- cleanup wrong-scope kill switch; +- versioning suspended/delete marker growth; +- retention/legal hold; +- presigned URL leak; +- grant response loss/reissue exposure; +- public endpoint/CORS drift; +- MinIO node/restart/upgrade; +- AWS regional/provider outage; +- disk/inode/mount loss for filesystem provider; +- graceful shutdown with in-flight operations; +- database pending attachment backlog; +- UploadIntent/handoff fence stuck or abort race; +- operation epoch rotation/seal/compaction stuck; +- backup/restore 후 reference/data/control reconciliation; +- operation/reference lookup hot partition; +- provider SDK upgrade rollback; +- readiness card downgrade. + +각 runbook은: + +- detection signal; +- safe first action; +- admission/maintenance kill switch; +- evidence collection; +- reconciliation command; +- destructive step의 dry-run/report-only; +- rollback; +- incident/audit link + +를 포함한다. + +## 41. Primary references + +### AWS S3 semantics + +- [Amazon S3 User Guide](https://docs.aws.amazon.com/AmazonS3/latest/userguide/) +- [S3 conditional writes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html) +- [S3 conditional deletes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-deletes.html) +- [DeleteObject API and conditional headers](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) +- [HeadObject API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html) +- [CompleteMultipartUpload API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html) +- [AbortMultipartUpload API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html) +- [Checking object integrity](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html) +- [Multipart upload overview](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html) +- [Multipart upload limits](https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html) +- [Abort a multipart upload](https://docs.aws.amazon.com/AmazonS3/latest/userguide/abort-mpu.html) +- [Abort incomplete multipart uploads with lifecycle](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html) +- [Presigned URL capabilities and limitations](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) +- [Signature V4 POST policy](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-HTTPPOSTConstructPolicy.html) +- [S3 versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) +- [S3 versioning enablement examples and propagation note](https://docs.aws.amazon.com/AmazonS3/latest/userguide/manage-versioning-examples.html) +- [S3 delete markers](https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html) +- [S3 Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html) +- [S3 server-side encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingEncryption.html) +- [S3 SSE-KMS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html) +- [S3 Object Ownership](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html) +- [S3 security best practices](https://docs.aws.amazon.com/AmazonS3/latest/userguide/security-best-practices.html) +- [Expected bucket owner](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-owner-condition.html) +- [S3 network isolation and TLS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/network-isolation.html) + +### AWS SDK for Java 2.x + +- [S3 asynchronous multipart client](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/s3-async-client-multipart.html) +- [S3 client examples and client comparison](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-s3.html) +- [CRT-based S3 client](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/crt-based-s3-client.html) +- [S3 checksums](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/s3-checksums.html) +- [API timeout configuration](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/timeouts.html) +- [Retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html) +- [HTTP client configuration](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/http-configuration.html) +- [SDK metrics](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/metrics.html) +- [SDK troubleshooting](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/troubleshooting.html) +- [SDK best practices](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/best-practices.html) +- [Default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html) + +### MinIO AIStor future-card context and upload security + +다음 MinIO 문서는 current AIStor product용이며 현재 OSS 2024 Testcontainers image의 readiness +evidence로 사용하지 않는다. + +- [MinIO versioning](https://docs.min.io/aistor/administration/objects-and-versioning/versioning/) +- [MinIO object locking and immutability](https://docs.min.io/aistor/administration/object-locking-and-immutability/) +- [MinIO lifecycle rule patterns](https://docs.min.io/aistor/administration/object-lifecycle-management/lifecycle-rule-patterns/) +- [MinIO Java SDK API](https://docs.min.io/aistor/developers/sdk/java/api/) +- [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) diff --git a/src/adapter/outbound/objectstorage/CLAUDE.md b/src/adapter/outbound/objectstorage/CLAUDE.md index e665427..848a96f 100644 --- a/src/adapter/outbound/objectstorage/CLAUDE.md +++ b/src/adapter/outbound/objectstorage/CLAUDE.md @@ -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 ``` diff --git a/src/adapter/outbound/objectstorage/README.md b/src/adapter/outbound/objectstorage/README.md index fc7f3c1..b009f7e 100644 --- a/src/adapter/outbound/objectstorage/README.md +++ b/src/adapter/outbound/objectstorage/README.md @@ -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 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 ``` diff --git a/src/adapter/outbound/objectstorage/build.gradle b/src/adapter/outbound/objectstorage/build.gradle index 0c25eb6..526ad61 100644 --- a/src/adapter/outbound/objectstorage/build.gradle +++ b/src/adapter/outbound/objectstorage/build.gradle @@ -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() } diff --git a/src/adapter/outbound/objectstorage/gradle.lockfile b/src/adapter/outbound/objectstorage/gradle.lockfile index 6543162..54a31f6 100644 --- a/src/adapter/outbound/objectstorage/gradle.lockfile +++ b/src/adapter/outbound/objectstorage/gradle.lockfile @@ -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= diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java index c71b18d..73f8f59 100644 --- a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java @@ -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. * *

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 diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java index ac7f8b9..9c30d68 100644 --- a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java @@ -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}. */ diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/CrockfordBase32.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/CrockfordBase32.java new file mode 100644 index 0000000..f4e47e0 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/CrockfordBase32.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectControlKeyCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectControlKeyCodec.java new file mode 100644 index 0000000..e9e12e8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectControlKeyCodec.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectDataKeyCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectDataKeyCodec.java new file mode 100644 index 0000000..2ebb7df --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectDataKeyCodec.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectHandleCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectHandleCodec.java new file mode 100644 index 0000000..f35a1fb --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectHandleCodec.java @@ -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()); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectPolicySnapshotCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectPolicySnapshotCodec.java new file mode 100644 index 0000000..67067d5 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectPolicySnapshotCodec.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectReferenceCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectReferenceCodec.java new file mode 100644 index 0000000..0e6077e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectReferenceCodec.java @@ -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]); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodec.java new file mode 100644 index 0000000..3704a0a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodec.java @@ -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"); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/CompiledObjectStorageDestination.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/CompiledObjectStorageDestination.java new file mode 100644 index 0000000..70535fb --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/CompiledObjectStorageDestination.java @@ -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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/CompiledObjectStorageProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/CompiledObjectStorageProvider.java new file mode 100644 index 0000000..9ae8d2f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/CompiledObjectStorageProvider.java @@ -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 requireProviderBinding(Class bindingType) { + if (!bindingType.isInstance(providerSpecificBinding)) { + throw new IllegalArgumentException("compiled provider binding has an unexpected type"); + } + return bindingType.cast(providerSpecificBinding); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java new file mode 100644 index 0000000..6e94c0d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java @@ -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"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectStorageActivationGuard.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectStorageActivationGuard.java new file mode 100644 index 0000000..1705a48 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectStorageActivationGuard.java @@ -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 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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java new file mode 100644 index 0000000..544dff2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java @@ -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 contributions; + private final Set activeProfiles; + private final boolean scanSeamAvailable; + private final Clock clock; + + public ObjectStorageBindingCompiler( + List contributions, + Set activeProfiles, + boolean scanSeamAvailable) { + this(contributions, activeProfiles, scanSeamAvailable, Clock.systemUTC()); + } + + public ObjectStorageBindingCompiler( + List contributions, + Set activeProfiles, + boolean scanSeamAvailable, + Clock clock) { + Objects.requireNonNull(contributions, "contributions must be non-null"); + Map 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 providers = compileProviders(settings); + Map 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 compileProviders( + ObjectStorageCapabilitySettings settings) { + Map compiled = new LinkedHashMap<>(); + for (Map.Entry 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 compileDestinations( + ObjectStorageCapabilitySettings settings, + Map providers) { + Map compiled = new LinkedHashMap<>(); + Set routes = new java.util.HashSet<>(); + for (Map.Entry 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 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 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 providers, + Map destinations) { + + public CompiledBindings { + providers = Map.copyOf(providers); + destinations = Map.copyOf(destinations); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityAssembler.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityAssembler.java new file mode 100644 index 0000000..d8648f1 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityAssembler.java @@ -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 contributions; + private final ObjectStorageBindingCompiler compiler; + + public ObjectStorageCapabilityAssembler( + List 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 byType = new LinkedHashMap<>(); + for (ObjectStorageProviderContribution contribution : contributions) { + for (String providerType : contribution.providerTypes()) { + byType.put(providerType, contribution); + } + } + Map selected = new LinkedHashMap<>(); + List 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 providers; + private final Map byDestination; + private final Map byRoute; + private final AtomicBoolean closed = new AtomicBoolean(); + + private AssembledCapability( + ObjectStorageBindingCompiler.CompiledBindings bindings, + Map providers) { + this.bindings = bindings; + this.providers = Map.copyOf(providers); + Map destinations = new LinkedHashMap<>(); + Map 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 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"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java new file mode 100644 index 0000000..a4ff046 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java @@ -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 contributions, Environment environment) { + Set 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 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(); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java new file mode 100644 index 0000000..346fd88 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java @@ -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 providers, + Map 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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageDestinationSettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageDestinationSettings.java new file mode 100644 index 0000000..eb8e5a2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageDestinationSettings.java @@ -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 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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java new file mode 100644 index 0000000..edaea15 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java @@ -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. + * + *

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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java new file mode 100644 index 0000000..784343e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java @@ -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 {} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderContribution.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderContribution.java new file mode 100644 index 0000000..da0692f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderContribution.java @@ -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. + * + *

{@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 providerTypes() { + return Set.of(providerType()); + } + + ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings); + + default Object compileProviderBinding(String providerId, ObjectStorageProviderSettings settings) { + return null; + } + + SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider); +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderSettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderSettings.java new file mode 100644 index 0000000..58ea89e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderSettings.java @@ -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) {} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java new file mode 100644 index 0000000..41a53fa --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java @@ -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()); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectDirectGrantAdapter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectDirectGrantAdapter.java new file mode 100644 index 0000000..2c0559b --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectDirectGrantAdapter.java @@ -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() {} +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java new file mode 100644 index 0000000..57723eb --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java @@ -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 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 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 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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectReadAdapter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectReadAdapter.java new file mode 100644 index 0000000..24ed0f5 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectReadAdapter.java @@ -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 inspect(ObjectReference reference) { + Optional 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 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) {} +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java new file mode 100644 index 0000000..85aaa9a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java @@ -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) {} +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/SelectedObjectStorageProviderFactory.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/SelectedObjectStorageProviderFactory.java new file mode 100644 index 0000000..1926669 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/SelectedObjectStorageProviderFactory.java @@ -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 Optional managedMultipartProvider(Class providerType) { + Objects.requireNonNull(providerType, "providerType must be non-null"); + return providerType.isInstance(managedMultipartProvider) + ? Optional.of(providerType.cast(managedMultipartProvider)) + : Optional.empty(); + } + + public Optional directTransferProvider(Class providerType) { + Objects.requireNonNull(providerType, "providerType must be non-null"); + return providerType.isInstance(directTransferProvider) + ? Optional.of(providerType.cast(directTransferProvider)) + : Optional.empty(); + } + + public Optional directMultipartProvider(Class 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(); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonObjectControlRecordCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonObjectControlRecordCodec.java new file mode 100644 index 0000000..2aad935 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonObjectControlRecordCodec.java @@ -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 + 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 + readCapabilities(String canonical) { + EnumSet 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 enumValue(Class 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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonReader.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonReader.java new file mode 100644 index 0000000..181741d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonReader.java @@ -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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonWriter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonWriter.java new file mode 100644 index 0000000..cb68ae9 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonWriter.java @@ -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('"'); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ControlRecordSupport.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ControlRecordSupport.java new file mode 100644 index 0000000..fb3b7ff --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ControlRecordSupport.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlConflictException.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlConflictException.java new file mode 100644 index 0000000..96ba2e2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlConflictException.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlCorruptionException.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlCorruptionException.java new file mode 100644 index 0000000..f37e0ab --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlCorruptionException.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlMutation.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlMutation.java new file mode 100644 index 0000000..5e557c1 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlMutation.java @@ -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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecord.java new file mode 100644 index 0000000..f11454d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecord.java @@ -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 + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java new file mode 100644 index 0000000..0d46ec3 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java @@ -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); +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordEnvelope.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordEnvelope.java new file mode 100644 index 0000000..8952f71 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordEnvelope.java @@ -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]"; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlStore.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlStore.java new file mode 100644 index 0000000..00219ff --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlStore.java @@ -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 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"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlVersion.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlVersion.java new file mode 100644 index 0000000..3c50927 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlVersion.java @@ -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]"; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectMultipartGrantRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectMultipartGrantRecord.java new file mode 100644 index 0000000..e74088f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectMultipartGrantRecord.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectMultipartSessionRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectMultipartSessionRecord.java new file mode 100644 index 0000000..d7ee355 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectMultipartSessionRecord.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectSessionRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectSessionRecord.java new file mode 100644 index 0000000..2e8b267 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectDirectSessionRecord.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectManifestRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectManifestRecord.java new file mode 100644 index 0000000..33e0de9 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectManifestRecord.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectMultipartPartRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectMultipartPartRecord.java new file mode 100644 index 0000000..b33587f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectMultipartPartRecord.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectMultipartSessionRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectMultipartSessionRecord.java new file mode 100644 index 0000000..057db59 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectMultipartSessionRecord.java @@ -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; + }; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectOperationRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectOperationRecord.java new file mode 100644 index 0000000..9d77725 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectOperationRecord.java @@ -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(); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java new file mode 100644 index 0000000..cec085f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectReferencePointerRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectReferencePointerRecord.java new file mode 100644 index 0000000..a72bfda --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectReferencePointerRecord.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectReferenceRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectReferenceRecord.java new file mode 100644 index 0000000..6c38f41 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectReferenceRecord.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectStagedObjectRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectStagedObjectRecord.java new file mode 100644 index 0000000..fb3f573 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectStagedObjectRecord.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/UnsupportedObjectControlSchemaException.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/UnsupportedObjectControlSchemaException.java new file mode 100644 index 0000000..b70eb65 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/UnsupportedObjectControlSchemaException.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectGrantGeneration.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectGrantGeneration.java new file mode 100644 index 0000000..a383f61 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectGrantGeneration.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectGrantProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectGrantProvider.java new file mode 100644 index 0000000..ebf7f8c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectGrantProvider.java @@ -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 signedHeaders; + private final Instant expiresAt; + + public DirectGrantMaterial( + URI requestUri, Map 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 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"); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCompletionVerifier.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCompletionVerifier.java new file mode 100644 index 0000000..4078d3d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCompletionVerifier.java @@ -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 requested, + List 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"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinator.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinator.java new file mode 100644 index 0000000..038e985 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinator.java @@ -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 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 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 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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartGrantLedger.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartGrantLedger.java new file mode 100644 index 0000000..4708542 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartGrantLedger.java @@ -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"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartProvider.java new file mode 100644 index 0000000..d98d11e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartProvider.java @@ -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 parts); + + DirectGrantProvider.VerifiedUpload resolveComplete( + ObjectDirectMultipartSessionRecord session, List 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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectPartAcknowledgementVerifier.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectPartAcknowledgementVerifier.java new file mode 100644 index 0000000..f039dc1 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectPartAcknowledgementVerifier.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectPublishedObjectResolver.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectPublishedObjectResolver.java new file mode 100644 index 0000000..44097b3 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectPublishedObjectResolver.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinator.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinator.java new file mode 100644 index 0000000..768779d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinator.java @@ -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 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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsPolicy.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsPolicy.java new file mode 100644 index 0000000..94bb156 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsPolicy.java @@ -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 allowedMethods() { + return Set.of("GET", "HEAD", "PUT"); + } + + public static Set allowedRequestHeaders() { + return Set.of( + "content-length", + "content-type", + "if-none-match", + "x-amz-checksum-sha256", + "x-amz-server-side-encryption"); + } + + public static Set exposedResponseHeaders() { + return Set.of("etag", "x-amz-checksum-sha256", "x-amz-version-id"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferPolicy.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferPolicy.java new file mode 100644 index 0000000..b940946 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferPolicy.java @@ -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 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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferSessionRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferSessionRecord.java new file mode 100644 index 0000000..225bd10 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferSessionRecord.java @@ -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 + + "]"; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactor.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactor.java new file mode 100644 index 0000000..2cf1d61 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactor.java @@ -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 headers) { + Objects.requireNonNull(headers, "signed headers must be non-null"); + return headers.keySet().stream().sorted().collect(Collectors.joining(",", "[", "]")); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/FilesystemLocalDevProviderContribution.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/FilesystemLocalDevProviderContribution.java new file mode 100644 index 0000000..f5c7775 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/FilesystemLocalDevProviderContribution.java @@ -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 + capabilitySupport() { + EnumMap 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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectControlStore.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectControlStore.java new file mode 100644 index 0000000..91cae22 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectControlStore.java @@ -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 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 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"); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectDataStore.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectDataStore.java new file mode 100644 index 0000000..8c0e5d9 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectDataStore.java @@ -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 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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProvider.java new file mode 100644 index 0000000..e559bd8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProvider.java @@ -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 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 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 capabilitySupport() { + EnumMap 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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectPathGuard.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectPathGuard.java new file mode 100644 index 0000000..8e3b9fc --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectPathGuard.java @@ -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 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)); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectStreamTransfer.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectStreamTransfer.java new file mode 100644 index 0000000..ab238c3 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectStreamTransfer.java @@ -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) -> {}; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/DirectGrantSessionState.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/DirectGrantSessionState.java new file mode 100644 index 0000000..c3501c6 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/DirectGrantSessionState.java @@ -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 +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/KernelValueSupport.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/KernelValueSupport.java new file mode 100644 index 0000000..e0ab04a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/KernelValueSupport.java @@ -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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/MultipartUploadState.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/MultipartUploadState.java new file mode 100644 index 0000000..f655f01 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/MultipartUploadState.java @@ -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 +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectBindingRevision.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectBindingRevision.java new file mode 100644 index 0000000..080de5e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectBindingRevision.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectEffectCertainty.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectEffectCertainty.java new file mode 100644 index 0000000..03f1b0b --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectEffectCertainty.java @@ -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 +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectIntegrityVerificationService.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectIntegrityVerificationService.java new file mode 100644 index 0000000..d2c91ff --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectIntegrityVerificationService.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochRecord.java new file mode 100644 index 0000000..2a97c12 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochRecord.java @@ -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 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 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"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochState.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochState.java new file mode 100644 index 0000000..f4eb50d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochState.java @@ -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 +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernel.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernel.java new file mode 100644 index 0000000..e07d343 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernel.java @@ -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(); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelResult.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelResult.java new file mode 100644 index 0000000..5b1a0bd --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelResult.java @@ -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 + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java new file mode 100644 index 0000000..e3dde31 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java @@ -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); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachine.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachine.java new file mode 100644 index 0000000..9056501 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachine.java @@ -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 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"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPolicyRevision.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPolicyRevision.java new file mode 100644 index 0000000..fdec949 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPolicyRevision.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +/** Immutable identifier for a compiled object publication policy. */ +public record ObjectPolicyRevision(String value) { + + public ObjectPolicyRevision { + value = KernelValueSupport.requireRevision("policy revision", value); + } + + public static ObjectPolicyRevision of(String value) { + return new ObjectPolicyRevision(value); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPolicySnapshot.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPolicySnapshot.java new file mode 100644 index 0000000..d3e8ae6 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPolicySnapshot.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +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.Objects; +import java.util.Set; + +/** Frozen provider-neutral policy used by operation replay and reconciliation. */ +public record ObjectPolicySnapshot( + ObjectBindingRevision bindingRevision, + ObjectPolicyRevision policyRevision, + ObjectPublicationRequirement publicationRequirement, + ObjectRetentionRequirement retentionRequirement, + ObjectEncryptionRequirement encryptionRequirement, + Set capabilityRequirements, + long maximumObjectBytes, + int chunkBytes, + Duration minimumReplayHorizon) { + + private static final int MAXIMUM_CHUNK_BYTES = 16 * 1024 * 1024; + + public ObjectPolicySnapshot { + Objects.requireNonNull(bindingRevision, "bindingRevision must be non-null"); + Objects.requireNonNull(policyRevision, "policyRevision 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 (capabilityRequirements == null) { + throw new IllegalArgumentException("capabilityRequirements must be non-null"); + } + capabilityRequirements = Set.copyOf(capabilityRequirements); + if (maximumObjectBytes < 1) { + throw new IllegalArgumentException("maximumObjectBytes must be positive"); + } + if (chunkBytes < 1 || chunkBytes > MAXIMUM_CHUNK_BYTES) { + throw new IllegalArgumentException("chunkBytes is outside the supported range"); + } + Objects.requireNonNull(minimumReplayHorizon, "minimumReplayHorizon must be non-null"); + if (minimumReplayHorizon.isNegative() || minimumReplayHorizon.isZero()) { + throw new IllegalArgumentException("minimumReplayHorizon must be positive"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffService.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffService.java new file mode 100644 index 0000000..5601b9a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffService.java @@ -0,0 +1,171 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec; +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageCapabilityAssembler; +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.ObjectPublicationHandoffRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectStagedObjectRecord; +import dev.caskeleton.application.objectstorage.model.ObjectHandoffReceipt; +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.port.ObjectPublicationHandoffPort; +import dev.caskeleton.application.objectstorage.request.ObjectAbortAuthorization; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffReleaseRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffRenewRequest; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; + +/** Exact stage/version handoff lease with monotonic CAS fences. */ +public final class ObjectPublicationHandoffService implements ObjectPublicationHandoffPort { + + private final ObjectStorageCapabilityAssembler.AssembledCapability capability; + private final Clock clock; + + public ObjectPublicationHandoffService( + 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 ObjectHandoffReceipt claimForPublication(ObjectHandoffClaimRequest request) { + RuntimeState state = state(request.operationKey(), request.stageHandle()); + if (!state.stage().exactVersion().equals(request.exactVersion())) { + throw new IllegalArgumentException("handoff exact version conflicts"); + } + Instant now = clock.instant(); + ObjectPublicationHandoffRecord claimed = + ObjectPublicationHandoffRecord.claimed( + request.operationKey(), + request.stageHandle(), + request.exactVersion(), + request.claimant(), + now, + now.plus(request.leaseDuration())); + ObjectMutationOutcome outcome = ObjectMutationOutcome.APPLIED; + try { + state.store().create(state.key(), claimed); + } catch (ObjectControlConflictException conflict) { + ObjectControlStore.StoredControlRecord existing = loadHandoff(state); + ObjectPublicationHandoffRecord current = handoff(existing); + ObjectPublicationHandoffRecord replacement = + current.claim(request.claimant(), now, request.leaseDuration()); + if (replacement.equals(current)) { + claimed = current; + outcome = ObjectMutationOutcome.REPLAYED; + } else { + claimed = + (ObjectPublicationHandoffRecord) + state + .store() + .compareAndSet( + state.key(), new ObjectControlMutation(existing.version(), replacement)) + .record(); + } + } + return receipt(claimed, outcome); + } + + @Override + public ObjectHandoffReceipt renewClaim(ObjectHandoffRenewRequest request) { + RuntimeState state = state(request.operationKey(), request.stageHandle()); + ObjectControlStore.StoredControlRecord stored = loadHandoff(state); + ObjectPublicationHandoffRecord renewed = + handoff(stored) + .renew(request.fence(), request.claimant(), clock.instant(), request.leaseDuration()); + renewed = + (ObjectPublicationHandoffRecord) + state + .store() + .compareAndSet(state.key(), new ObjectControlMutation(stored.version(), renewed)) + .record(); + return receipt(renewed, ObjectMutationOutcome.APPLIED); + } + + @Override + public ObjectMutationReceipt releaseClaim(ObjectHandoffReleaseRequest request) { + RuntimeState state = state(request.operationKey(), request.stageHandle()); + ObjectControlStore.StoredControlRecord stored = loadHandoff(state); + ObjectPublicationHandoffRecord released = + handoff(stored).release(request.fence(), request.claimant(), clock.instant()); + state.store().compareAndSet(state.key(), new ObjectControlMutation(stored.version(), released)); + return mutation(request.operationKey(), ObjectMutationOutcome.APPLIED); + } + + @Override + public ObjectMutationReceipt authorizeAbort(ObjectAbortAuthorization authorization) { + RuntimeState state = state(authorization.operationKey(), authorization.stageHandle()); + if (!state.stage().exactVersion().equals(authorization.exactVersion())) { + throw new IllegalArgumentException("abort exact version conflicts"); + } + ObjectControlStore.StoredControlRecord stored = loadHandoff(state); + ObjectPublicationHandoffRecord authorized = + handoff(stored).authorizeAbort(authorization.handoffFence(), clock.instant()); + state + .store() + .compareAndSet(state.key(), new ObjectControlMutation(stored.version(), authorized)); + return mutation(authorization.operationKey(), ObjectMutationOutcome.APPLIED); + } + + private RuntimeState state( + dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation, + dev.caskeleton.application.objectstorage.identity.ObjectStageHandle stageHandle) { + ObjectStorageCapabilityAssembler.DestinationRuntime runtime = + capability.destination(operation.destination()); + ObjectControlStore store = runtime.providerFactory().controlStore(); + ObjectStagedObjectRecord stage = + store + .read(ObjectControlKeyCodec.stagedObject(runtime.destination().route(), stageHandle)) + .map(ObjectControlStore.StoredControlRecord::record) + .filter(ObjectStagedObjectRecord.class::isInstance) + .map(ObjectStagedObjectRecord.class::cast) + .orElseThrow(() -> new IllegalArgumentException("staged object is absent")); + if (!stage.operationKey().equals(operation)) { + throw new IllegalArgumentException("handoff operation conflicts"); + } + return new RuntimeState( + store, + ObjectControlKeyCodec.publicationHandoff(runtime.destination().route(), stageHandle), + stage); + } + + private static ObjectControlStore.StoredControlRecord loadHandoff(RuntimeState state) { + return state + .store() + .read(state.key()) + .orElseThrow(() -> new IllegalStateException("publication handoff is absent")); + } + + private static ObjectPublicationHandoffRecord handoff( + ObjectControlStore.StoredControlRecord stored) { + if (!(stored.record() instanceof ObjectPublicationHandoffRecord handoff)) { + throw new IllegalStateException("publication handoff family is corrupt"); + } + return handoff; + } + + private static ObjectHandoffReceipt receipt( + ObjectPublicationHandoffRecord record, ObjectMutationOutcome outcome) { + return new ObjectHandoffReceipt( + record.operationKey(), + record.stageHandle(), + record.fence(), + record.leaseExpiresAt(), + outcome); + } + + private ObjectMutationReceipt mutation( + dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation, + ObjectMutationOutcome outcome) { + return new ObjectMutationReceipt( + operation, outcome, ObjectOperationError.NONE, clock.instant()); + } + + private record RuntimeState( + ObjectControlStore store, String key, ObjectStagedObjectRecord stage) {} +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectReferencePublicationService.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectReferencePublicationService.java new file mode 100644 index 0000000..42aee3c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectReferencePublicationService.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec; +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectReferenceCodec; +import dev.caskeleton.adapter.outbound.objectstorage.config.CompiledObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException; +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.ObjectReferencePointerRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectStagedObjectRecord; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import java.time.Instant; +import java.util.Objects; + +/** Manifest-first immutable reference publisher for an exact staged provider version. */ +public final class ObjectReferencePublicationService { + + public ObjectReference publish( + ObjectRouteToken route, + ObjectPolicySnapshot policy, + CompiledObjectStorageProvider provider, + ObjectControlStore control, + ObjectStagedObjectRecord stage, + Instant now) { + Objects.requireNonNull(route, "route must be non-null"); + Objects.requireNonNull(policy, "policy must be non-null"); + Objects.requireNonNull(provider, "provider must be non-null"); + Objects.requireNonNull(control, "control must be non-null"); + Objects.requireNonNull(stage, "stage must be non-null"); + ObjectScanVerdictPolicy.requireFinalizable(stage.publicationRequirement(), stage.scanState()); + if (!stage.integrityVerified()) { + throw new IllegalStateException("staged object integrity is not verified"); + } + ObjectReference reference = ObjectReferenceCodec.published(route, stage.objectId()); + ObjectManifestRecord manifest = + new ObjectManifestRecord( + 1, + "manifest:" + stage.objectId().canonicalText(), + 1, + stage.objectId(), + route, + provider.providerType(), + provider.providerVersion(), + policy.bindingRevision(), + stage.contentIdentity().exactLength(), + stage.contentIdentity().fullDigest(), + stage.mediaType(), + stage.exactVersion(), + policy.encryptionRequirement().name(), + policy.retentionRequirement().name(), + now); + ObjectReferencePointerRecord pointer = + new ObjectReferencePointerRecord( + 1, "pointer:" + reference.canonicalText(), 1, reference, 1, stage.exactVersion(), now); + ObjectReferenceRecord lifecycle = + new ObjectReferenceRecord( + 1, + "reference:" + reference.canonicalText(), + 1, + reference, + stage.objectId(), + stage.exactVersion(), + PublishedReferenceState.PUBLISHED, + now); + createOrRequireSame( + control, + ObjectControlKeyCodec.manifest( + route, stage.objectId(), ObjectControlKeyCodec.ManifestRevision.of(1)), + manifest); + createOrRequireSame(control, ObjectControlKeyCodec.reference(route, reference), pointer); + createOrRequireSame( + control, ObjectControlKeyCodec.referenceLifecycle(route, reference), lifecycle); + return reference; + } + + private static void createOrRequireSame( + ObjectControlStore control, String key, ObjectControlRecord candidate) { + try { + control.create(key, candidate); + } catch (ObjectControlConflictException conflict) { + ObjectControlRecord existing = + control + .read(key) + .map(ObjectControlStore.StoredControlRecord::record) + .orElseThrow(() -> conflict); + if (!existing.equals(candidate)) { + throw conflict; + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectRouteToken.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectRouteToken.java new file mode 100644 index 0000000..9cb6242 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectRouteToken.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +/** Stable, non-secret route token retained for the lifetime of bound objects and operations. */ +public record ObjectRouteToken(String canonicalText) { + + public ObjectRouteToken { + if (canonicalText == null || !canonicalText.matches("[0-9abcdefghjkmnpqrstvwxyz]{12}")) { + throw new IllegalArgumentException("object route token is not canonical"); + } + } + + public static ObjectRouteToken parse(String canonicalText) { + return new ObjectRouteToken(canonicalText); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictPolicy.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictPolicy.java new file mode 100644 index 0000000..41e544e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictPolicy.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectScanState; +import java.util.Objects; + +/** Fail-closed publication gate for integrity-only and scan-gated staged objects. */ +public final class ObjectScanVerdictPolicy { + + private ObjectScanVerdictPolicy() {} + + public static void requireFinalizable( + ObjectPublicationRequirement requirement, ObjectScanState scanState) { + Objects.requireNonNull(requirement, "requirement must be non-null"); + Objects.requireNonNull(scanState, "scanState must be non-null"); + boolean allowed = + switch (requirement) { + case INTEGRITY_VERIFIED -> scanState == ObjectScanState.NOT_REQUIRED; + case SCAN_CLEAN -> scanState == ObjectScanState.CLEAN; + }; + if (!allowed) { + throw new IllegalStateException("staged object lacks the required publication evidence"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PendingObjectEffect.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PendingObjectEffect.java new file mode 100644 index 0000000..18d7784 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PendingObjectEffect.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import java.util.Objects; + +/** Bounded, non-secret exact intent persisted before external mutation. */ +public record PendingObjectEffect( + Kind kind, + String attemptId, + String exactTargetEvidence, + String desiredState, + String precondition, + String requestEvidenceDigest) { + + public PendingObjectEffect { + Objects.requireNonNull(kind, "kind must be non-null"); + attemptId = bounded("attemptId", attemptId, 96); + exactTargetEvidence = bounded("exactTargetEvidence", exactTargetEvidence, 256); + desiredState = bounded("desiredState", desiredState, 96); + precondition = bounded("precondition", precondition, 256); + requestEvidenceDigest = hexDigest(requestEvidenceDigest); + } + + 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 String hexDigest(String value) { + Objects.requireNonNull(value, "requestEvidenceDigest must be non-null"); + if (!value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("requestEvidenceDigest is invalid"); + } + return value; + } + + public enum Kind { + DATA_PUT, + DIRECT_GRANT_ISSUE, + DIRECT_UPLOAD_VERIFY, + DIRECT_DOWNLOAD_GRANT_ISSUE, + MULTIPART_INITIATE, + MULTIPART_PART, + MULTIPART_COMPLETE, + MULTIPART_ABORT, + REFERENCE_CAS, + REFERENCE_RETIRE, + OBJECT_PURGE, + CONTROL_RECORD_CAS + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PublicationOperationState.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PublicationOperationState.java new file mode 100644 index 0000000..cce69c8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PublicationOperationState.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +/** Stable phase of a managed or staged publication operation. */ +public enum PublicationOperationState { + RESERVED, + DATA_UPLOAD_IN_PROGRESS, + DATA_UPLOADED, + INTEGRITY_VERIFIED, + SCAN_PENDING, + SCAN_CLEAN, + REFERENCE_PUBLISH_IN_PROGRESS, + PUBLISHED, + ABORTED, + QUARANTINED, + EXPIRED, + FAILED, + CORRUPT; + + public boolean terminal() { + return switch (this) { + case PUBLISHED, ABORTED, QUARANTINED, EXPIRED, FAILED, CORRUPT -> true; + default -> false; + }; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PublishedReferenceState.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PublishedReferenceState.java new file mode 100644 index 0000000..3634318 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PublishedReferenceState.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +/** Business and privileged physical lifecycle of an immutable published reference. */ +public enum PublishedReferenceState { + PUBLISHED, + RETIREMENT_PENDING, + RETIRED, + PURGE_ELIGIBLE, + PURGED +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ScanOperationState.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ScanOperationState.java new file mode 100644 index 0000000..0f91e09 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ScanOperationState.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +/** Independent malware/integrity scan sub-state. */ +public enum ScanOperationState { + NOT_REQUIRED, + PENDING, + CLEAN, + MALICIOUS, + INDETERMINATE +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernel.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernel.java new file mode 100644 index 0000000..1540687 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernel.java @@ -0,0 +1,416 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +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.codec.ObjectRequestFingerprintCodec; +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageCapabilityAssembler; +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.ObjectPublicationHandoffRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectStagedObjectRecord; +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.ObjectPublishReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectStageReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest; +import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest; +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.util.Arrays; +import java.util.HexFormat; +import java.util.Objects; + +/** Durable unpublished stage, exact verification, finalize, and authorized abort coordinator. */ +public final class StagedObjectPublicationKernel { + + private final ObjectStorageCapabilityAssembler.DestinationRuntime runtime; + private final Clock clock; + private final ObjectIntegrityVerificationService integrity = + new ObjectIntegrityVerificationService(); + private final ObjectReferencePublicationService references = + new ObjectReferencePublicationService(); + + public StagedObjectPublicationKernel( + ObjectStorageCapabilityAssembler.DestinationRuntime runtime, Clock clock) { + this.runtime = Objects.requireNonNull(runtime, "runtime must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + public ObjectStageReceipt stage(ObjectStageRequest request, ObjectContentProducer producer) { + requireDestination(request.operationKey().destination()); + requireStagePolicy(request); + String fingerprint = fingerprint(request); + ObjectId objectId = objectId(request, fingerprint); + String dataKey = + ObjectDataKeyCodec.encode( + runtime.destination().route(), objectId, ObjectDataKeyCodec.Generation.of(1)); + ObjectOperationKernel operations = new ObjectOperationKernel(control()); + ObjectOperationKernelResult reservation = + operations.reserve( + request.operationKey(), + fingerprint, + "STAGE", + runtime.destination().route(), + runtime.destination().policySnapshot(), + request.contentIdentity(), + clock.instant()); + if (reservation.decision() == ObjectOperationKernelResult.Decision.CONFLICT) { + throw new IllegalArgumentException("stage operation identity conflicts"); + } + ObjectStagedObjectRecord existing = loadStage(objectId); + if (existing != null) { + requireExact(existing, request.contentIdentity()); + return new ObjectStageReceipt( + request.operationKey(), + existing.stageHandle(), + existing.exactVersion(), + existing.contentIdentity(), + ObjectMutationOutcome.REPLAYED); + } + ObjectOperationRecord operation = reservation.record(); + if (operation.stablePhase() == PublicationOperationState.RESERVED) { + operation = + operations.advancePublication( + operation, PublicationOperationState.DATA_UPLOAD_IN_PROGRESS, null, clock.instant()); + } + if (operation.stablePhase() != PublicationOperationState.DATA_UPLOAD_IN_PROGRESS) { + throw new IllegalStateException("stage operation is not uploadable"); + } + ObjectStorageProviderOperation providerOperation = + new ObjectStorageProviderOperation( + request.operationKey(), + dataKey, + request.contentIdentity(), + request.declaredMediaType(), + request.budget(), + request.cancellation(), + runtime.providerBinding().chunkBytes(), + runtime.providerBinding().maximumObjectBytes()); + if (operation.pendingEffect() == null) { + operation = + operations.markEffectSent( + operation, + new PendingObjectEffect( + PendingObjectEffect.Kind.DATA_PUT, + request.operationKey().operationId().value(), + sha256Hex(dataKey), + PublicationOperationState.DATA_UPLOADED.name(), + "create-if-absent", + fingerprint)); + } + ObjectStorageProvider.StoredData stored = resolveOrCreate(providerOperation, producer); + if (!stored.contentIdentity().equals(request.contentIdentity())) { + throw new IllegalStateException("staged content conflicts with declared identity"); + } + if (operation.effectCertainty() != ObjectEffectCertainty.CONFIRMED) { + operation = operations.confirmEffect(operation, clock.instant()); + } + operations.advancePublication( + operation, + PublicationOperationState.DATA_UPLOADED, + stored.contentIdentity(), + clock.instant()); + ObjectStagedObjectRecord staged = + ObjectStagedObjectRecord.staged( + request.operationKey(), + ObjectHandleCodec.stage(runtime.destination().route(), objectId), + objectId, + dataKey, + stored.exactVersion(), + stored.contentIdentity(), + request.declaredMediaType(), + request.publicationRequirement(), + clock.instant()); + control().create(stageKey(staged.stageHandle()), staged); + return new ObjectStageReceipt( + request.operationKey(), + staged.stageHandle(), + staged.exactVersion(), + staged.contentIdentity(), + ObjectMutationOutcome.APPLIED); + } + + public ObjectVerificationReceipt verifyIntegrity(ObjectVerifyRequest request) { + requireDestination(request.operationKey().destination()); + ObjectControlStore.StoredControlRecord stored = storedStage(request.stageHandle()); + ObjectStagedObjectRecord stage = requireStage(stored, request.operationKey()); + requireExact(stage, request.expectedContent()); + if (!stage.exactVersion().equals(request.exactVersion())) { + throw new IllegalArgumentException("stage version conflicts"); + } + if (stage.integrityVerified()) { + return verification(stage, ObjectMutationOutcome.REPLAYED); + } + ObjectStagedObjectRecord verified = + integrity.verify(stage, runtime.providerFactory().provider(), clock.instant()); + control() + .compareAndSet( + stageKey(stage.stageHandle()), new ObjectControlMutation(stored.version(), verified)); + ObjectOperationRecord operation = loadOperation(request.operationKey()); + ObjectOperationKernel kernel = new ObjectOperationKernel(control()); + operation = + kernel.advancePublication( + operation, + PublicationOperationState.INTEGRITY_VERIFIED, + verified.contentIdentity(), + clock.instant()); + if (verified.publicationRequirement() + == dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement.SCAN_CLEAN) { + kernel.advancePublication( + operation, + PublicationOperationState.SCAN_PENDING, + verified.contentIdentity(), + clock.instant()); + } + return verification(verified, ObjectMutationOutcome.APPLIED); + } + + public ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request) { + requireDestination(request.operationKey().destination()); + ObjectStagedObjectRecord stage = + requireStage(storedStage(request.stageHandle()), request.operationKey()); + if (!stage.exactVersion().equals(request.exactVersion()) + || stage.publicationRequirement() != request.publicationRequirement()) { + throw new IllegalArgumentException("finalize intent conflicts with staged evidence"); + } + ObjectScanVerdictPolicy.requireFinalizable(stage.publicationRequirement(), stage.scanState()); + ObjectOperationRecord operation = loadOperation(request.operationKey()); + boolean replay = operation.stablePhase() == PublicationOperationState.PUBLISHED; + ObjectReference reference; + if (replay) { + reference = + dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectReferenceCodec.published( + runtime.destination().route(), stage.objectId()); + } else { + PublicationOperationState required = + stage.publicationRequirement() + == dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement + .SCAN_CLEAN + ? PublicationOperationState.SCAN_CLEAN + : PublicationOperationState.INTEGRITY_VERIFIED; + if (operation.stablePhase() != required) { + throw new IllegalStateException("staged operation is not finalizable"); + } + ObjectOperationKernel kernel = new ObjectOperationKernel(control()); + operation = + kernel.advancePublication( + operation, + PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS, + stage.contentIdentity(), + clock.instant()); + reference = + references.publish( + runtime.destination().route(), + runtime.destination().policySnapshot(), + runtime.providerBinding(), + control(), + stage, + stage.updatedAt()); + kernel.advancePublication( + operation, PublicationOperationState.PUBLISHED, stage.contentIdentity(), clock.instant()); + } + return new ObjectPublishReceipt( + request.operationKey(), + new RequestFingerprint(loadOperation(request.operationKey()).requestFingerprint()), + reference, + stage.exactVersion(), + stage.contentIdentity(), + stage.mediaType(), + replay ? ObjectMutationOutcome.REPLAYED : ObjectMutationOutcome.APPLIED, + clock.instant(), + runtime.destination().policySnapshot().policyRevision().value()); + } + + public ObjectMutationReceipt abort(ObjectAbortRequest request) { + requireDestination(request.operationKey().destination()); + ObjectStagedObjectRecord stage = + requireStage(storedStage(request.stageHandle()), request.operationKey()); + ObjectPublicationHandoffRecord handoff = + control() + .read( + ObjectControlKeyCodec.publicationHandoff( + runtime.destination().route(), request.stageHandle())) + .map(ObjectControlStore.StoredControlRecord::record) + .filter(ObjectPublicationHandoffRecord.class::isInstance) + .map(ObjectPublicationHandoffRecord.class::cast) + .orElseThrow(() -> new IllegalStateException("abort authorization is absent")); + if (!handoff.abortAuthorized() + || handoff.fence() != request.authorization().handoffFence() + || !stage.exactVersion().equals(request.authorization().exactVersion())) { + throw new IllegalStateException("abort authorization is stale or conflicts"); + } + boolean retired = + runtime + .providerFactory() + .provider() + .retire( + new ObjectStorageProvider.RetireOperation( + stage.canonicalDataKey(), stage.exactVersion())); + if (!retired) { + throw new IllegalStateException("staged provider object could not be retired"); + } + ObjectOperationRecord operation = loadOperation(request.operationKey()); + new ObjectOperationKernel(control()) + .terminate(operation, PublicationOperationState.ABORTED, clock.instant()); + return new ObjectMutationReceipt( + request.operationKey(), + ObjectMutationOutcome.APPLIED, + ObjectOperationError.NONE, + clock.instant()); + } + + private ObjectStorageProvider.StoredData resolveOrCreate( + ObjectStorageProviderOperation operation, ObjectContentProducer producer) { + ObjectStorageProvider provider = runtime.providerFactory().provider(); + return switch (provider.resolveCreate(operation)) { + case APPLIED -> + provider + .inspect(operation.canonicalDataKey()) + .orElseThrow(() -> new IllegalStateException("resolved staged data is absent")); + case NOT_APPLIED -> provider.create(operation, producer); + default -> throw new IllegalStateException("staged create remains indeterminate"); + }; + } + + private ObjectControlStore control() { + return runtime.providerFactory().controlStore(); + } + + private ObjectControlStore.StoredControlRecord storedStage( + dev.caskeleton.application.objectstorage.identity.ObjectStageHandle stage) { + return control() + .read(stageKey(stage)) + .orElseThrow(() -> new IllegalArgumentException("staged object is absent")); + } + + private ObjectStagedObjectRecord loadStage(ObjectId objectId) { + return control() + .read(stageKey(ObjectHandleCodec.stage(runtime.destination().route(), objectId))) + .map(ObjectControlStore.StoredControlRecord::record) + .filter(ObjectStagedObjectRecord.class::isInstance) + .map(ObjectStagedObjectRecord.class::cast) + .orElse(null); + } + + private ObjectOperationRecord loadOperation( + dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation) { + return control() + .read(ObjectControlKeyCodec.operation(runtime.destination().route(), operation)) + .map(ObjectControlStore.StoredControlRecord::record) + .filter(ObjectOperationRecord.class::isInstance) + .map(ObjectOperationRecord.class::cast) + .orElseThrow(() -> new IllegalStateException("publication operation is absent")); + } + + private static ObjectStagedObjectRecord requireStage( + ObjectControlStore.StoredControlRecord stored, + dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation) { + if (!(stored.record() instanceof ObjectStagedObjectRecord stage) + || !stage.operationKey().equals(operation)) { + throw new IllegalArgumentException("staged object operation conflicts"); + } + return stage; + } + + private void requireDestination( + dev.caskeleton.application.objectstorage.identity.ObjectDestinationId destination) { + if (!runtime.destination().destinationId().equals(destination)) { + throw new IllegalArgumentException("staged destination conflicts"); + } + } + + private void requireStagePolicy(ObjectStageRequest request) { + ObjectPolicySnapshot policy = runtime.destination().policySnapshot(); + if (request.publicationRequirement() != policy.publicationRequirement() + || request.retentionRequirement() != policy.retentionRequirement() + || request.encryptionRequirement() != policy.encryptionRequirement() + || !policy.capabilityRequirements().containsAll(request.capabilityRequirements()) + || request.contentIdentity().exactLength() > policy.maximumObjectBytes()) { + throw new IllegalArgumentException("stage request violates compiled destination policy"); + } + } + + private static void requireExact( + ObjectStagedObjectRecord stage, + dev.caskeleton.application.objectstorage.model.ObjectContentIdentity expected) { + if (!stage.contentIdentity().equals(expected)) { + throw new IllegalArgumentException("staged content identity conflicts"); + } + } + + private String stageKey( + dev.caskeleton.application.objectstorage.identity.ObjectStageHandle stage) { + return ObjectControlKeyCodec.stagedObject(runtime.destination().route(), stage); + } + + private static ObjectVerificationReceipt verification( + ObjectStagedObjectRecord stage, ObjectMutationOutcome outcome) { + return new ObjectVerificationReceipt( + stage.operationKey(), + stage.stageHandle(), + stage.exactVersion(), + stage.contentIdentity(), + outcome); + } + + private static String fingerprint(ObjectStageRequest request) { + return ObjectRequestFingerprintCodec.sha256Hex( + new ObjectRequestFingerprintCodec.RequestIntent( + 1, + request.operationKey().destination(), + ObjectRequestFingerprintCodec.OperationKind.STAGE, + 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(ObjectStageRequest request, String fingerprint) { + String seed = + request.operationKey().destination().value() + + "\n" + + request.operationKey().epoch().value() + + "\n" + + request.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 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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/Ed25519LegacyAdoptionApprovalVerifier.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/Ed25519LegacyAdoptionApprovalVerifier.java new file mode 100644 index 0000000..41a4778 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/Ed25519LegacyAdoptionApprovalVerifier.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.storage.migration.LegacyObjectAdoptionApproval; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionApprovalVerifierPort; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Signature; +import java.time.Clock; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Map; + +/** Fail-closed detached two-person Ed25519 approval verifier. */ +@SuppressWarnings("removal") +public final class Ed25519LegacyAdoptionApprovalVerifier + implements LegacyObjectAdoptionApprovalVerifierPort { + + private final LegacyAdoptionApprovalCodec codec; + private final Map trustedApprovers; + private final Clock clock; + private final Duration maximumValidity; + + public Ed25519LegacyAdoptionApprovalVerifier( + LegacyAdoptionApprovalCodec codec, + Map trustedApprovers, + Clock clock, + Duration maximumValidity) { + this.codec = codec; + this.trustedApprovers = Map.copyOf(trustedApprovers); + this.clock = clock; + this.maximumValidity = maximumValidity; + if (maximumValidity == null + || maximumValidity.isZero() + || maximumValidity.isNegative() + || maximumValidity.compareTo(Duration.ofDays(7)) > 0) { + throw new IllegalArgumentException("legacy adoption approval validity is invalid"); + } + } + + @Override + public LegacyObjectAdoptionApproval verify( + byte[] canonicalApprovalDocument, LegacyObjectAdoptionRequest expectedRequest) { + LegacyAdoptionApprovalDocument document = codec.decode(canonicalApprovalDocument); + if (document.schemaVersion() != 1 + || document.firstApproverKeyId().equals(document.secondApproverKeyId()) + || !document.manifestSha256().equals(expectedRequest.manifestSha256()) + || !document.legacyNamespaceDigest().equals(expectedRequest.legacyNamespaceDigest()) + || !document.targetNamespaceDigest().equals(expectedRequest.targetNamespaceDigest()) + || !document + .destination() + .equals(expectedRequest.publicationRequest().operationKey().destination().value()) + || !document + .operationEpoch() + .equals(expectedRequest.publicationRequest().operationKey().epoch().value()) + || !document + .operationId() + .equals(expectedRequest.publicationRequest().operationKey().operationId().value()) + || clock.instant().isBefore(document.notBefore()) + || !clock.instant().isBefore(document.expiresAt()) + || Duration.between(document.notBefore(), document.expiresAt()).compareTo(maximumValidity) + > 0) { + throw rejected(); + } + byte[] payload = codec.signingPayload(document); + verifySignature(document.firstApproverKeyId(), document.firstSignature(), payload); + verifySignature(document.secondApproverKeyId(), document.secondSignature(), payload); + ObjectOperationKey operation = + new ObjectOperationKey( + ObjectDestinationId.of(document.destination()), + ObjectOperationEpoch.of(document.operationEpoch()), + ObjectOperationId.of(document.operationId())); + return new LegacyObjectAdoptionApproval( + operation, + document.manifestSha256(), + document.legacyNamespaceDigest(), + ObjectDestinationId.of(document.destination()), + document.targetNamespaceDigest(), + document.notBefore(), + document.expiresAt(), + document.nonce(), + sha256Hex(canonicalApprovalDocument)); + } + + private void verifySignature(String keyId, byte[] signature, byte[] payload) { + PublicKey key = trustedApprovers.get(keyId); + if (key == null) { + throw rejected(); + } + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(key); + verifier.update(payload); + if (!verifier.verify(signature)) { + throw rejected(); + } + } catch (GeneralSecurityException exception) { + throw new IllegalArgumentException("legacy adoption approval is rejected", exception); + } + } + + private static String sha256Hex(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private static IllegalArgumentException rejected() { + return new IllegalArgumentException("legacy adoption approval is rejected"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalCodec.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalCodec.java new file mode 100644 index 0000000..4332c6c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalCodec.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; + +/** Strict JDK-only length-prefixed canonical binary codec. */ +public final class LegacyAdoptionApprovalCodec { + + private static final int MAXIMUM_DOCUMENT_BYTES = 64 * 1024; + private static final int MAXIMUM_FIELD_BYTES = 1024; + private static final int MAXIMUM_SIGNATURE_BYTES = 256; + + public byte[] signingPayload(LegacyAdoptionApprovalDocument document) { + return write( + output -> { + output.writeInt(document.schemaVersion()); + string(output, document.destination()); + string(output, document.operationEpoch()); + string(output, document.operationId()); + string(output, document.manifestSha256()); + string(output, document.legacyNamespaceDigest()); + string(output, document.targetNamespaceDigest()); + string(output, "APPLY"); + output.writeLong(document.notBefore().toEpochMilli()); + output.writeLong(document.expiresAt().toEpochMilli()); + string(output, document.nonce()); + string(output, document.firstApproverKeyId()); + string(output, document.secondApproverKeyId()); + }); + } + + public byte[] encode(LegacyAdoptionApprovalDocument document) { + byte[] payload = signingPayload(document); + byte[] encoded = + write( + output -> { + bytes(output, payload, MAXIMUM_DOCUMENT_BYTES); + bytes(output, document.firstSignature(), MAXIMUM_SIGNATURE_BYTES); + bytes(output, document.secondSignature(), MAXIMUM_SIGNATURE_BYTES); + }); + if (encoded.length > MAXIMUM_DOCUMENT_BYTES) { + throw invalid(); + } + return encoded; + } + + public LegacyAdoptionApprovalDocument decode(byte[] encoded) { + if (encoded == null || encoded.length == 0 || encoded.length > MAXIMUM_DOCUMENT_BYTES) { + throw invalid(); + } + try { + DataInputStream document = new DataInputStream(new ByteArrayInputStream(encoded)); + byte[] payload = bytes(document, MAXIMUM_DOCUMENT_BYTES); + byte[] firstSignature = bytes(document, MAXIMUM_SIGNATURE_BYTES); + byte[] secondSignature = bytes(document, MAXIMUM_SIGNATURE_BYTES); + if (document.read() != -1) { + throw invalid(); + } + DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload)); + int schema = input.readInt(); + String destination = string(input); + String epoch = string(input); + String operation = string(input); + String manifest = string(input); + String legacyNamespace = string(input); + String targetNamespace = string(input); + if (!"APPLY".equals(string(input))) { + throw invalid(); + } + Instant notBefore = Instant.ofEpochMilli(input.readLong()); + Instant expiresAt = Instant.ofEpochMilli(input.readLong()); + String nonce = string(input); + String firstKey = string(input); + String secondKey = string(input); + if (input.read() != -1) { + throw invalid(); + } + LegacyAdoptionApprovalDocument decoded = + new LegacyAdoptionApprovalDocument( + schema, + destination, + epoch, + operation, + manifest, + legacyNamespace, + targetNamespace, + notBefore, + expiresAt, + nonce, + firstKey, + secondKey, + firstSignature, + secondSignature); + if (!Arrays.equals(encoded, encode(decoded))) { + throw invalid(); + } + return decoded; + } catch (IOException | RuntimeException exception) { + if (exception instanceof IllegalArgumentException illegal) { + throw illegal; + } + throw new IllegalArgumentException("legacy adoption approval document is invalid", exception); + } + } + + private static byte[] write(IoConsumer writer) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + writer.accept(output); + } + return bytes.toByteArray(); + } catch (IOException exception) { + throw new IllegalStateException("in-memory approval encoding failed", exception); + } + } + + private static void string(DataOutputStream output, String value) throws IOException { + if (value == null) { + throw invalid(); + } + bytes(output, value.getBytes(StandardCharsets.UTF_8), MAXIMUM_FIELD_BYTES); + } + + private static String string(DataInputStream input) throws IOException { + byte[] bytes = bytes(input, MAXIMUM_FIELD_BYTES); + String value = new String(bytes, StandardCharsets.UTF_8); + if (!Arrays.equals(bytes, value.getBytes(StandardCharsets.UTF_8))) { + throw invalid(); + } + return value; + } + + private static void bytes(DataOutputStream output, byte[] value, int maximum) throws IOException { + if (value == null || value.length == 0 || value.length > maximum) { + throw invalid(); + } + output.writeInt(value.length); + output.write(value); + } + + private static byte[] bytes(DataInputStream input, int maximum) throws IOException { + int length = input.readInt(); + if (length < 1 || length > maximum) { + throw invalid(); + } + byte[] value = input.readNBytes(length); + if (value.length != length) { + throw new EOFException("approval field is truncated"); + } + return value; + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("legacy adoption approval document is invalid"); + } + + @FunctionalInterface + private interface IoConsumer { + void accept(T value) throws IOException; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalDocument.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalDocument.java new file mode 100644 index 0000000..a96a2a8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalDocument.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import java.time.Instant; +import java.util.Arrays; + +/** Detached canonical two-signature approval document for reviewed APPLY execution. */ +public final class LegacyAdoptionApprovalDocument { + + private final int schemaVersion; + private final String destination; + private final String operationEpoch; + private final String operationId; + private final String manifestSha256; + private final String legacyNamespaceDigest; + private final String targetNamespaceDigest; + private final Instant notBefore; + private final Instant expiresAt; + private final String nonce; + private final String firstApproverKeyId; + private final String secondApproverKeyId; + private final byte[] firstSignature; + private final byte[] secondSignature; + + public LegacyAdoptionApprovalDocument( + int schemaVersion, + String destination, + String operationEpoch, + String operationId, + String manifestSha256, + String legacyNamespaceDigest, + String targetNamespaceDigest, + Instant notBefore, + Instant expiresAt, + String nonce, + String firstApproverKeyId, + String secondApproverKeyId, + byte[] firstSignature, + byte[] secondSignature) { + this.schemaVersion = schemaVersion; + this.destination = destination; + this.operationEpoch = operationEpoch; + this.operationId = operationId; + this.manifestSha256 = manifestSha256; + this.legacyNamespaceDigest = legacyNamespaceDigest; + this.targetNamespaceDigest = targetNamespaceDigest; + this.notBefore = notBefore; + this.expiresAt = expiresAt; + this.nonce = nonce; + this.firstApproverKeyId = firstApproverKeyId; + this.secondApproverKeyId = secondApproverKeyId; + this.firstSignature = + firstSignature == null ? null : Arrays.copyOf(firstSignature, firstSignature.length); + this.secondSignature = + secondSignature == null ? null : Arrays.copyOf(secondSignature, secondSignature.length); + } + + public int schemaVersion() { + return schemaVersion; + } + + public String destination() { + return destination; + } + + public String operationEpoch() { + return operationEpoch; + } + + public String operationId() { + return operationId; + } + + public String manifestSha256() { + return manifestSha256; + } + + public String legacyNamespaceDigest() { + return legacyNamespaceDigest; + } + + public String targetNamespaceDigest() { + return targetNamespaceDigest; + } + + public Instant notBefore() { + return notBefore; + } + + public Instant expiresAt() { + return expiresAt; + } + + public String nonce() { + return nonce; + } + + public String firstApproverKeyId() { + return firstApproverKeyId; + } + + public String secondApproverKeyId() { + return secondApproverKeyId; + } + + public byte[] firstSignature() { + return firstSignature == null ? null : Arrays.copyOf(firstSignature, firstSignature.length); + } + + public byte[] secondSignature() { + return secondSignature == null ? null : Arrays.copyOf(secondSignature, secondSignature.length); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayRecord.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayRecord.java new file mode 100644 index 0000000..9c19cf8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayRecord.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import java.time.Instant; + +/** Durable nonce binding; no raw locator or provider location may enter this record. */ +public record LegacyAdoptionApprovalReplayRecord( + String nonce, + ObjectOperationKey operationKey, + String manifestSha256, + String namespaceBindingDigest, + String approvalDigest, + State state, + Instant updatedAt, + long revision) { + + public LegacyAdoptionApprovalReplayRecord { + if (nonce == null + || !nonce.matches("[A-Za-z0-9_-]{16,128}") + || operationKey == null + || !hex64(manifestSha256) + || !hex64(namespaceBindingDigest) + || !hex64(approvalDigest) + || state == null + || updatedAt == null + || revision < 0) { + throw new IllegalArgumentException("legacy adoption replay record is invalid"); + } + } + + public enum State { + CLAIMED, + TERMINAL + } + + private static boolean hex64(String value) { + return value != null && value.matches("[0-9a-f]{64}"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayStore.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayStore.java new file mode 100644 index 0000000..83b6771 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayStore.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +/** Durable compare-and-set nonce replay boundary. */ +public interface LegacyAdoptionApprovalReplayStore { + + ClaimResult claim(LegacyAdoptionApprovalReplayRecord proposed); + + void markTerminal( + String nonce, long expectedRevision, LegacyAdoptionApprovalReplayRecord terminal); + + enum ClaimResult { + CLAIMED, + EXACT_REPLAY, + TERMINAL_REPLAY + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java new file mode 100644 index 0000000..0d69996 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; +import dev.caskeleton.application.objectstorage.port.ManagedObjectPublicationPort; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionApproval; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionPort; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionReceipt; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.HexFormat; + +/** Report-first legacy inspection and reviewed, operation-keyed adoption apply. */ +@SuppressWarnings("removal") +public final class LegacyObjectAdoptionService implements LegacyObjectAdoptionPort { + + private final LegacyObjectInspector inspector; + private final ManagedObjectPublicationPort publications; + private final LegacyAdoptionApprovalReplayStore replayStore; + private final Clock clock; + + public LegacyObjectAdoptionService( + LegacyObjectInspector inspector, + ManagedObjectPublicationPort publications, + LegacyAdoptionApprovalReplayStore replayStore, + Clock clock) { + this.inspector = inspector; + this.publications = publications; + this.replayStore = replayStore; + this.clock = clock; + } + + @Override + public LegacyObjectAdoptionReceipt adopt(LegacyObjectAdoptionRequest request) { + LegacyObjectInspection inspected = inspector.inspect(request.locator()); + if (!inspected.contentIdentity().equals(request.publicationRequest().contentIdentity()) + || !inspected.mediaType().equals(request.publicationRequest().declaredMediaType())) { + throw new IllegalArgumentException("legacy object differs from the reviewed manifest"); + } + if (request.mode() == LegacyObjectAdoptionRequest.Mode.REPORT_ONLY) { + return new LegacyObjectAdoptionReceipt( + request.publicationRequest().operationKey(), + inspected.contentIdentity(), + inspected.mediaType(), + inspected.exactVersion(), + null, + null, + ObjectMutationOutcome.NO_CHANGE, + clock.instant()); + } + + LegacyObjectAdoptionApproval approval = requireApproval(request); + String namespaceBinding = + sha256Hex(request.legacyNamespaceDigest() + ":" + request.targetNamespaceDigest()); + LegacyAdoptionApprovalReplayRecord replay = + new LegacyAdoptionApprovalReplayRecord( + approval.nonce(), + approval.operationKey(), + approval.manifestSha256(), + namespaceBinding, + approval.approvalDigest(), + LegacyAdoptionApprovalReplayRecord.State.CLAIMED, + clock.instant(), + 0); + LegacyAdoptionApprovalReplayStore.ClaimResult claim = replayStore.claim(replay); + var published = publications.publish(request.publicationRequest(), inspected.producer()); + if (claim != LegacyAdoptionApprovalReplayStore.ClaimResult.TERMINAL_REPLAY) { + replayStore.markTerminal( + replay.nonce(), + replay.revision(), + new LegacyAdoptionApprovalReplayRecord( + replay.nonce(), + replay.operationKey(), + replay.manifestSha256(), + replay.namespaceBindingDigest(), + replay.approvalDigest(), + LegacyAdoptionApprovalReplayRecord.State.TERMINAL, + clock.instant(), + replay.revision() + 1)); + } + return new LegacyObjectAdoptionReceipt( + published.operationKey(), + published.contentIdentity(), + published.mediaType(), + inspected.exactVersion(), + published.reference(), + published.exactVersion(), + published.outcome(), + published.appliedAt()); + } + + private LegacyObjectAdoptionApproval requireApproval(LegacyObjectAdoptionRequest request) { + LegacyObjectAdoptionApproval approval = request.approval(); + if (approval == null + || !approval.operationKey().equals(request.publicationRequest().operationKey()) + || !approval.manifestSha256().equals(request.manifestSha256()) + || !approval.legacyNamespaceDigest().equals(request.legacyNamespaceDigest()) + || !approval + .targetDestination() + .equals(request.publicationRequest().operationKey().destination()) + || !approval.targetNamespaceDigest().equals(request.targetNamespaceDigest()) + || clock.instant().isBefore(approval.notBefore()) + || !clock.instant().isBefore(approval.expiresAt())) { + throw new IllegalArgumentException( + "legacy adoption approval does not authorize this request"); + } + return approval; + } + + private 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); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspection.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspection.java new file mode 100644 index 0000000..e11be71 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspection.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; + +/** Exact immutable inspection result; the raw locator is deliberately absent. */ +public record LegacyObjectInspection( + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType, + ObjectVersionToken exactVersion, + ObjectContentProducer producer) { + + public LegacyObjectInspection { + if (contentIdentity == null || mediaType == null || exactVersion == null || producer == null) { + throw new IllegalArgumentException("legacy object inspection is incomplete"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspector.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspector.java new file mode 100644 index 0000000..4794bc6 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspector.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import dev.caskeleton.application.storage.migration.LegacyObjectLocator; + +/** Privileged legacy namespace reader used only by explicit migration composition. */ +@FunctionalInterface +public interface LegacyObjectInspector { + + LegacyObjectInspection inspect(LegacyObjectLocator locator); +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinator.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinator.java new file mode 100644 index 0000000..d6e5c16 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinator.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +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.ObjectMultipartSessionRecord; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState; +import java.time.Instant; +import java.util.Objects; +import java.util.function.Supplier; + +/** Persists initiate intent before invoking the low-level multipart provider. */ +public final class ManagedMultipartCoordinator { + + private final ObjectControlStore controlStore; + + public ManagedMultipartCoordinator(ObjectControlStore controlStore) { + this.controlStore = Objects.requireNonNull(controlStore, "controlStore must be non-null"); + } + + public ObjectMultipartSessionRecord initiate( + String controlKey, + ObjectMultipartSessionRecord reserved, + Supplier providerInitiate, + Instant now) { + Objects.requireNonNull(reserved, "reserved must be non-null"); + if (reserved.multipartState() != MultipartUploadState.SESSION_RESERVED) { + throw new IllegalArgumentException("multipart session is not reserved"); + } + ObjectControlStore.StoredControlRecord stored = + controlStore + .read(controlKey) + .filter(current -> current.record().equals(reserved)) + .orElseThrow(() -> new ObjectControlConflictException("multipart session is stale")); + ObjectMultipartSessionRecord initiating = + copy( + reserved, + reserved.revision() + 1, + MultipartUploadState.INITIATE_IN_PROGRESS, + "pending", + now); + ObjectControlStore.StoredControlRecord sent = + controlStore.compareAndSet( + controlKey, new ObjectControlMutation(stored.version(), initiating)); + String providerSessionEvidence = + Objects.requireNonNull(providerInitiate.get(), "provider upload ID must be non-null"); + ObjectMultipartSessionRecord accepting = + copy( + initiating, + initiating.revision() + 1, + MultipartUploadState.ACCEPTING_PARTS, + providerSessionEvidence, + now); + return (ObjectMultipartSessionRecord) + controlStore + .compareAndSet(controlKey, new ObjectControlMutation(sent.version(), accepting)) + .record(); + } + + private static ObjectMultipartSessionRecord copy( + ObjectMultipartSessionRecord source, + long revision, + MultipartUploadState state, + String providerEvidence, + Instant now) { + return new ObjectMultipartSessionRecord( + source.schemaVersion(), + source.recordId(), + revision, + source.sessionId(), + state, + source.grantState(), + providerEvidence, + source.partCount(), + now); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartCompletionEvidence.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartCompletionEvidence.java new file mode 100644 index 0000000..9236dc5 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartCompletionEvidence.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import java.util.Objects; + +/** Provider-private multipart completion evidence paired with the verified logical identity. */ +public record MultipartCompletionEvidence( + ObjectContentIdentity contentIdentity, + String providerVersionId, + String privateEtag, + String providerChecksum) { + + public MultipartCompletionEvidence { + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + if (privateEtag == null || privateEtag.isBlank()) { + throw new IllegalArgumentException("multipart completion ETag is absent"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartOperationResolver.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartOperationResolver.java new file mode 100644 index 0000000..9642d22 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartOperationResolver.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Bounded recovery decisions for initiate and completion response loss. */ +public final class MultipartOperationResolver { + + public InitiateResolution resolveInitiate( + String exactDataKey, + Instant lowerBound, + Instant upperBound, + List candidates) { + Objects.requireNonNull(exactDataKey, "exactDataKey must be non-null"); + Objects.requireNonNull(lowerBound, "lowerBound must be non-null"); + Objects.requireNonNull(upperBound, "upperBound must be non-null"); + List matching = + candidates.stream() + .filter(candidate -> candidate.canonicalDataKey().equals(exactDataKey)) + .filter(candidate -> !candidate.initiatedAt().isBefore(lowerBound)) + .filter(candidate -> !candidate.initiatedAt().isAfter(upperBound)) + .toList(); + if (matching.size() == 1) { + return new InitiateResolution( + ObjectStorageProvider.Resolution.APPLIED, + Optional.of(matching.getFirst().providerUploadId())); + } + return new InitiateResolution(ObjectStorageProvider.Resolution.INDETERMINATE, Optional.empty()); + } + + public ObjectStorageProvider.Resolution resolveCompletion( + Optional stored, ObjectContentIdentity expected) { + Objects.requireNonNull(stored, "stored must be non-null"); + Objects.requireNonNull(expected, "expected must be non-null"); + if (stored.isEmpty()) { + return ObjectStorageProvider.Resolution.INDETERMINATE; + } + return stored.orElseThrow().contentIdentity().equals(expected) + ? ObjectStorageProvider.Resolution.APPLIED + : ObjectStorageProvider.Resolution.PRECONDITION_FAILED; + } + + public record UploadCandidate( + String canonicalDataKey, String providerUploadId, Instant initiatedAt) { + + public UploadCandidate { + Objects.requireNonNull(canonicalDataKey, "canonicalDataKey must be non-null"); + if (providerUploadId == null || providerUploadId.isBlank()) { + throw new IllegalArgumentException("provider upload ID is absent"); + } + Objects.requireNonNull(initiatedAt, "initiatedAt must be non-null"); + } + } + + public record InitiateResolution( + ObjectStorageProvider.Resolution resolution, Optional providerUploadId) { + + public InitiateResolution { + Objects.requireNonNull(resolution, "resolution must be non-null"); + Objects.requireNonNull(providerUploadId, "providerUploadId must be non-null"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartPartLedger.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartPartLedger.java new file mode 100644 index 0000000..a6b2510 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartPartLedger.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken; +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Immutable exact-key part ledger; ordered completion never depends on provider LIST ordering. */ +public final class MultipartPartLedger { + + private final ObjectControlStore store; + private final ObjectRouteToken route; + private final DirectTransferSessionId sessionId; + + public MultipartPartLedger( + 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 Decision record(ObjectMultipartPartRecord part) { + Objects.requireNonNull(part, "part must be non-null"); + if (!part.sessionId().equals(sessionId)) { + throw new IllegalArgumentException("multipart part belongs to another session"); + } + String key = ObjectControlKeyCodec.multipartPart(route, sessionId, part.partNumber()); + try { + store.create(key, part); + return Decision.RECORDED; + } catch (ObjectControlConflictException conflict) { + ObjectMultipartPartRecord existing = + store + .read(key) + .map(ObjectControlStore.StoredControlRecord::record) + .filter(ObjectMultipartPartRecord.class::isInstance) + .map(ObjectMultipartPartRecord.class::cast) + .orElseThrow( + () -> + new ObjectControlConflictException( + "multipart part conflict has no readable evidence")); + if (existing.equals(part)) { + return Decision.REPLAYED; + } + throw new ObjectControlConflictException("multipart part evidence conflicts"); + } + } + + public List ordered(int expectedPartCount) { + S3ClientPolicyBridge.requirePartCount(expectedPartCount); + List parts = new ArrayList<>(expectedPartCount); + for (int number = 1; number <= expectedPartCount; number++) { + MultipartPartNumber partNumber = MultipartPartNumber.of(number); + ObjectMultipartPartRecord part = + store + .read(ObjectControlKeyCodec.multipartPart(route, sessionId, partNumber)) + .map(ObjectControlStore.StoredControlRecord::record) + .filter(ObjectMultipartPartRecord.class::isInstance) + .map(ObjectMultipartPartRecord.class::cast) + .orElseThrow( + () -> new ObjectControlConflictException("multipart part ledger has a gap")); + parts.add(part); + } + return List.copyOf(parts); + } + + public enum Decision { + RECORDED, + REPLAYED + } + + private static final class S3ClientPolicyBridge { + + private S3ClientPolicyBridge() {} + + private static void requirePartCount(int count) { + dev.caskeleton.adapter.outbound.objectstorage.s3.S3ClientPolicy.requirePartCount(count); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartUploadPlan.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartUploadPlan.java new file mode 100644 index 0000000..27e7f34 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartUploadPlan.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +import dev.caskeleton.adapter.outbound.objectstorage.s3.S3ClientPolicy; + +/** Frozen deterministic single/multipart decision and finite part/admission bounds. */ +public record MultipartUploadPlan( + Mode mode, + long objectBytes, + long partBytes, + int partCount, + int maximumConcurrency, + long maximumInFlightBytes, + boolean fullObjectChecksum) { + + public static MultipartUploadPlan compile( + long objectBytes, + long singlePutThreshold, + long configuredPartBytes, + int maximumConcurrency, + long maximumInFlightBytes, + boolean fullObjectChecksum) { + if (objectBytes < 0 + || singlePutThreshold < 1 + || configuredPartBytes < S3ClientPolicy.MINIMUM_NON_FINAL_PART_BYTES + || maximumConcurrency < 1 + || maximumInFlightBytes < configuredPartBytes) { + throw new IllegalArgumentException("multipart plan bounds are invalid"); + } + if (fullObjectChecksum) { + S3ClientPolicy.requireSdkCompatibleMpuObjectSize(objectBytes); + } + Mode mode = objectBytes <= singlePutThreshold ? Mode.SINGLE : Mode.MULTIPART; + int partCount = + mode == Mode.SINGLE + ? 1 + : Math.toIntExact(Math.floorDiv(Math.max(0, objectBytes - 1), configuredPartBytes) + 1); + S3ClientPolicy.requirePartCount(partCount); + if (mode == Mode.MULTIPART + && Math.multiplyExact(configuredPartBytes, maximumConcurrency) > maximumInFlightBytes) { + throw new IllegalArgumentException("multipart concurrency exceeds the in-flight byte bound"); + } + return new MultipartUploadPlan( + mode, + objectBytes, + configuredPartBytes, + partCount, + maximumConcurrency, + maximumInFlightBytes, + fullObjectChecksum); + } + + public long partBytes(int partNumber) { + if (partNumber < 1 || partNumber > partCount) { + throw new IllegalArgumentException("multipart part number is outside the frozen plan"); + } + if (mode == Mode.SINGLE) { + return objectBytes; + } + long offset = Math.multiplyExact((long) partNumber - 1, partBytes); + long result = Math.min(partBytes, objectBytes - offset); + S3ClientPolicy.requirePartSize(result, partNumber == partCount); + return result; + } + + public enum Mode { + SINGLE, + MULTIPART + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProvider.java new file mode 100644 index 0000000..95a0003 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProvider.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.objectstorage.provider; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer; +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; +import java.util.Optional; + +/** Provider-neutral bounded data-plane contract used only behind the semantic application ports. */ +public interface ObjectStorageProvider { + + ObjectStorageProviderDescriptor descriptor(); + + StoredData create(ObjectStorageProviderOperation operation, ObjectContentProducer producer); + + Optional inspect(String canonicalDataKey); + + void transfer(ReadOperation operation, ObjectContentConsumer consumer); + + boolean retire(RetireOperation operation); + + Resolution resolveCreate(ObjectStorageProviderOperation operation); + + enum Capability { + IMMUTABLE_CREATE, + EXACT_INSPECT, + FULL_READ, + RANGE_READ, + CONDITIONAL_RETIREMENT, + RESPONSE_LOSS_RESOLUTION, + MULTI_NODE_LINEARIZABLE_CAS, + POWER_LOSS_DURABILITY, + DIRECT_UPLOAD, + DIRECT_MULTIPART + } + + enum Support { + SUPPORTED, + UNSUPPORTED + } + + enum Resolution { + APPLIED, + NOT_APPLIED, + PRECONDITION_FAILED, + INDETERMINATE + } + + record StoredData( + String canonicalDataKey, + ObjectContentIdentity contentIdentity, + ObjectVersionToken exactVersion) { + + public StoredData { + ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey); + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + } + } + + record ReadOperation( + String canonicalDataKey, + ObjectVersionToken exactVersion, + ObjectContentIdentity contentIdentity, + ObjectReadRange range, + CallBudget budget, + CancellationView cancellation, + int maximumChunkBytes, + long maximumDeliveredBytes) { + + public ReadOperation { + ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + Objects.requireNonNull(range, "range must be non-null") + .requireMaximumDeliveredBytes(maximumDeliveredBytes); + if (range.endExclusive() > contentIdentity.exactLength()) { + throw new IllegalArgumentException("range exceeds exact content length"); + } + Objects.requireNonNull(budget, "budget must be non-null"); + Objects.requireNonNull(cancellation, "cancellation must be non-null"); + if (maximumChunkBytes < 1 || maximumChunkBytes > 16 * 1024 * 1024) { + throw new IllegalArgumentException("maximumChunkBytes is outside the supported range"); + } + } + } + + record RetireOperation(String canonicalDataKey, ObjectVersionToken exactVersion) { + + public RetireOperation { + ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderDescriptor.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderDescriptor.java new file mode 100644 index 0000000..4601398 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderDescriptor.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.objectstorage.provider; + +import dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageCapabilityDescriptor; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Exact provider identity, finite bounds, and truthful supported/unsupported capability map. */ +public record ObjectStorageProviderDescriptor( + String providerType, + String providerVersion, + long maximumObjectBytes, + int maximumChunkBytes, + Map support, + Optional capabilityDescriptor) { + + public ObjectStorageProviderDescriptor( + String providerType, + String providerVersion, + long maximumObjectBytes, + int maximumChunkBytes, + Map support) { + this( + providerType, + providerVersion, + maximumObjectBytes, + maximumChunkBytes, + support, + Optional.empty()); + } + + public ObjectStorageProviderDescriptor { + providerType = bounded("providerType", providerType, 64); + providerVersion = bounded("providerVersion", providerVersion, 128); + if (maximumObjectBytes < 1 || maximumChunkBytes < 1) { + throw new IllegalArgumentException("provider bounds must be positive"); + } + Objects.requireNonNull(support, "support must be non-null"); + support = Map.copyOf(support); + if (support.size() != ObjectStorageProvider.Capability.values().length) { + throw new IllegalArgumentException( + "every provider capability needs an explicit support value"); + } + Objects.requireNonNull(capabilityDescriptor, "capabilityDescriptor must be non-null"); + } + + 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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderException.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderException.java new file mode 100644 index 0000000..46a3676 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderException.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.objectstorage.provider; + +import java.util.Objects; + +/** Bounded provider failure classification without physical locator disclosure. */ +public final class ObjectStorageProviderException extends RuntimeException { + + private final Failure failure; + + public ObjectStorageProviderException(Failure failure, String message) { + super(message); + this.failure = Objects.requireNonNull(failure, "failure must be non-null"); + } + + public ObjectStorageProviderException(Failure failure, String message, Throwable cause) { + super(message, cause); + this.failure = Objects.requireNonNull(failure, "failure must be non-null"); + } + + public Failure failure() { + return failure; + } + + public enum Failure { + CONFLICT, + NOT_FOUND, + PRECONDITION_FAILED, + CONTENT_MISMATCH, + PERMISSION_DENIED, + OWNER_MISMATCH, + REGION_MISMATCH, + THROTTLED, + RETENTION_BLOCKED, + INDETERMINATE, + UNKNOWN, + CANCELLED, + DEADLINE_EXCEEDED, + IO_FAILURE, + CORRUPT_DATA, + UNSUPPORTED + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderOperation.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderOperation.java new file mode 100644 index 0000000..08961f7 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/ObjectStorageProviderOperation.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.objectstorage.provider; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; + +/** Exact bounded internal create request; the physical key is adapter-generated and canonical. */ +public record ObjectStorageProviderOperation( + ObjectOperationKey operationKey, + String canonicalDataKey, + ObjectContentIdentity expectedContentIdentity, + ObjectMediaType mediaType, + CallBudget budget, + CancellationView cancellation, + int maximumChunkBytes, + long maximumObjectBytes) { + + public ObjectStorageProviderOperation { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + requireCanonicalDataKey(canonicalDataKey); + Objects.requireNonNull(expectedContentIdentity, "expectedContentIdentity must be non-null"); + Objects.requireNonNull(mediaType, "mediaType must be non-null"); + Objects.requireNonNull(budget, "budget must be non-null"); + Objects.requireNonNull(cancellation, "cancellation must be non-null"); + if (maximumChunkBytes < 1 || maximumChunkBytes > 16 * 1024 * 1024) { + throw new IllegalArgumentException("maximumChunkBytes is outside the supported range"); + } + if (maximumObjectBytes < 1 || expectedContentIdentity.exactLength() > maximumObjectBytes) { + throw new IllegalArgumentException("content exceeds maximumObjectBytes"); + } + } + + public static String requireCanonicalDataKey(String key) { + if (key == null + || key.length() > 192 + || !key.matches( + "data/v1/[0-9abcdefghjkmnpqrstvwxyz]{12}/[0-9a-f]{2}/" + + "[0-9abcdefghjkmnpqrstvwxyz]{26}/[0-9a-f]{16}")) { + throw new IllegalArgumentException("data key is not canonical"); + } + return key; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidence.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidence.java new file mode 100644 index 0000000..dc53066 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidence.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Expiring, identity-bound evidence for one exact operation profile. */ +public record CapabilityEvidence( + CapabilityEvidenceStatus status, + CapabilityEvidenceSource source, + String evidenceDigest, + Instant observedAt, + Instant validUntil, + String providerAndDeploymentIdentity, + List limitations) { + + public CapabilityEvidence { + Objects.requireNonNull(status, "status must be non-null"); + Objects.requireNonNull(source, "source must be non-null"); + evidenceDigest = requireDigest(evidenceDigest); + Objects.requireNonNull(observedAt, "observedAt must be non-null"); + Objects.requireNonNull(validUntil, "validUntil must be non-null"); + if (!validUntil.isAfter(observedAt)) { + throw new IllegalArgumentException("capability evidence expiry must follow observation"); + } + providerAndDeploymentIdentity = + requireBounded("providerAndDeploymentIdentity", providerAndDeploymentIdentity, 256); + limitations = List.copyOf(Objects.requireNonNull(limitations, "limitations must be non-null")); + if (limitations.isEmpty() + || limitations.stream() + .anyMatch(value -> value == null || value.isBlank() || value.length() > 256)) { + throw new IllegalArgumentException("capability evidence limitations must be explicit"); + } + } + + public boolean supportsAt(Instant instant) { + Objects.requireNonNull(instant, "instant must be non-null"); + return status == CapabilityEvidenceStatus.SUPPORTED + && !instant.isBefore(observedAt) + && instant.isBefore(validUntil); + } + + public CapabilityEvidence withProviderAndDeploymentIdentity(String identity) { + return new CapabilityEvidence( + status, source, evidenceDigest, observedAt, validUntil, identity, limitations); + } + + private static String requireDigest(String value) { + if (value == null || !value.matches("sha256:[0-9a-f]{64}")) { + throw new IllegalArgumentException("capability evidence digest is invalid"); + } + return value; + } + + private static String requireBounded(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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceSource.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceSource.java new file mode 100644 index 0000000..5371d00 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceSource.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +/** Authority that produced one bounded capability observation. */ +public enum CapabilityEvidenceSource { + STATIC_ATTESTATION, + STARTUP_PROBE, + CI_QUALIFICATION +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceStatus.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceStatus.java new file mode 100644 index 0000000..0be1dcc --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceStatus.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +/** Truthful support result for one exact object-storage operation profile. */ +public enum CapabilityEvidenceStatus { + SUPPORTED, + UNSUPPORTED, + UNVERIFIABLE +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java new file mode 100644 index 0000000..354ba7e --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** Frozen object-storage capability-card identities; no module-wide readiness card exists. */ +public enum ObjectStorageCapabilityCard { + MANAGED_UPLOAD_SINGLE("object-storage-managed-upload-single"), + MANAGED_UPLOAD_MULTIPART("object-storage-managed-upload-multipart"), + MANAGED_DOWNLOAD("object-storage-managed-download"), + DIRECT_UPLOAD_SINGLE("object-storage-direct-upload-single"), + DIRECT_UPLOAD_MULTIPART("object-storage-direct-upload-multipart"), + DIRECT_DOWNLOAD("object-storage-direct-download"), + QUARANTINE_PUBLICATION("object-storage-quarantine-publication"), + RETENTION("object-storage-retention"), + RECONCILIATION("object-storage-reconciliation"); + + private final String cardId; + + ObjectStorageCapabilityCard(String cardId) { + this.cardId = cardId; + } + + public String cardId() { + return cardId; + } + + public static ObjectStorageCapabilityCard parse(String cardId) { + return Arrays.stream(values()) + .filter(candidate -> candidate.cardId.equals(cardId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("unknown object-storage capability card")); + } + + public static Set cardIds() { + return Arrays.stream(values()) + .map(ObjectStorageCapabilityCard::cardId) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityDescriptor.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityDescriptor.java new file mode 100644 index 0000000..a11b85b --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityDescriptor.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Exact provider/deployment capability description with independently evidenced named profiles. */ +public record ObjectStorageCapabilityDescriptor( + String providerType, + String providerVersion, + String bindingRevision, + String evidenceRevision, + Instant qualifiedAt, + Map operationProfiles, + Map limits) { + + public ObjectStorageCapabilityDescriptor { + providerType = requireToken("providerType", providerType, 128); + providerVersion = requireToken("providerVersion", providerVersion, 128); + bindingRevision = requireToken("bindingRevision", bindingRevision, 128); + evidenceRevision = requireToken("evidenceRevision", evidenceRevision, 128); + Objects.requireNonNull(qualifiedAt, "qualifiedAt must be non-null"); + operationProfiles = + Map.copyOf( + new LinkedHashMap<>( + Objects.requireNonNull(operationProfiles, "operationProfiles must be non-null"))); + if (operationProfiles.isEmpty() + || operationProfiles.entrySet().stream() + .anyMatch(entry -> !entry.getKey().equals(entry.getValue().name()))) { + throw new IllegalArgumentException("operation profile index is incomplete or inconsistent"); + } + limits = + Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(limits, "limits must be non-null"))); + if (limits.isEmpty() + || limits.entrySet().stream() + .anyMatch( + entry -> !entry.getKey().matches("[a-z][a-z0-9-]{0,63}") || entry.getValue() < 1)) { + throw new IllegalArgumentException("capability descriptor limits are invalid"); + } + } + + public ObjectStorageOperationProfile profile(String profileName) { + ObjectStorageOperationProfile profile = operationProfiles.get(profileName); + if (profile == null) { + throw new IllegalArgumentException("unknown object-storage operation profile"); + } + return profile; + } + + public ObjectStorageOperationProfile requireSupported(String profileName, Instant instant) { + ObjectStorageOperationProfile profile = profile(profileName); + if (!profile.evidence().supportsAt(instant)) { + throw new IllegalArgumentException( + "object-storage operation profile lacks current supported evidence"); + } + return profile; + } + + private static String requireToken(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; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java new file mode 100644 index 0000000..55e5092 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** One exact, bounded readiness claim validated against live provider/task registries. */ +public record ObjectStorageCapabilityEvidence( + ObjectStorageCapabilityCard card, + String providerType, + String providerVersion, + String destinationProfile, + ObjectStorageReadinessLevel claimedLevel, + String evidenceRevision, + Optional evidenceExpiresOn, + List requiredTasks, + List limitations) { + + public ObjectStorageCapabilityEvidence { + Objects.requireNonNull(card, "card must be non-null"); + Objects.requireNonNull(claimedLevel, "claimedLevel must be non-null"); + Objects.requireNonNull(evidenceExpiresOn, "evidenceExpiresOn must be non-null"); + requiredTasks = List.copyOf(Objects.requireNonNull(requiredTasks, "requiredTasks")); + limitations = List.copyOf(Objects.requireNonNull(limitations, "limitations")); + } + + public void validate(Set knownProviders, Set availableTasks) { + Objects.requireNonNull(knownProviders, "knownProviders must be non-null"); + Objects.requireNonNull(availableTasks, "availableTasks must be non-null"); + requireToken("provider type", providerType); + requireToken("provider version", providerVersion); + requireToken("destination profile", destinationProfile); + requireToken("evidence revision", evidenceRevision); + if (!knownProviders.contains(providerType)) { + throw invalid("unknown provider"); + } + if ("filesystem-local-dev".equals(providerType) + && (claimedLevel == ObjectStorageReadinessLevel.R2 + || claimedLevel == ObjectStorageReadinessLevel.R3)) { + throw invalid("filesystem-local-dev cannot claim above R1"); + } + if (requiredTasks.isEmpty() + || requiredTasks.stream().anyMatch(task -> !availableTasks.contains(task))) { + throw invalid("required readiness task is absent"); + } + if (limitations.isEmpty() + || limitations.stream().anyMatch(value -> value == null || value.isBlank())) { + throw invalid("provider limitations must be explicit"); + } + if ((claimedLevel == ObjectStorageReadinessLevel.R2 + || claimedLevel == ObjectStorageReadinessLevel.R3) + && evidenceExpiresOn.isEmpty()) { + throw invalid("R2/R3 evidence requires an expiry"); + } + } + + private static void requireToken(String label, String value) { + if (value == null + || value.isBlank() + || value.length() > 128 + || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:/-]*")) { + throw invalid(label + " is invalid"); + } + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException("object-storage readiness claim is invalid: " + message); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageOperationProfile.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageOperationProfile.java new file mode 100644 index 0000000..eb2acc4 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageOperationProfile.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import java.util.Objects; +import java.util.Set; + +/** + * Named operation combination; support is granted for the combination, not intersected booleans. + */ +public record ObjectStorageOperationProfile( + String name, Set capabilities, CapabilityEvidence evidence) { + + public ObjectStorageOperationProfile { + if (name == null || !name.matches("[a-z0-9][a-z0-9-]{0,63}")) { + throw new IllegalArgumentException("object-storage operation profile name is invalid"); + } + capabilities = + Set.copyOf(Objects.requireNonNull(capabilities, "capabilities must be non-null")); + if (capabilities.isEmpty()) { + throw new IllegalArgumentException("operation profile capabilities must be non-empty"); + } + Objects.requireNonNull(evidence, "evidence must be non-null"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessLevel.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessLevel.java new file mode 100644 index 0000000..8f6f807 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessLevel.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +/** Evidence maturity for one exact capability/provider/version/destination-profile tuple. */ +public enum ObjectStorageReadinessLevel { + R0, + R1, + R2, + R3 +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactory.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactory.java new file mode 100644 index 0000000..3fa5dfe --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactory.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.util.Objects; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.core.retry.backoff.EqualJitterBackoffStrategy; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.S3AsyncClientBuilder; + +/** Constructs only the selected, explicitly bounded Netty-backed S3 async client. */ +public final class S3AsyncClientFactory { + + public S3ClientLifecycle create(S3ClientPolicy policy) { + Objects.requireNonNull(policy, "policy must be non-null"); + SdkAsyncHttpClient httpClient = + NettyNioAsyncHttpClient.builder() + .connectionTimeout(policy.connectionTimeout()) + .tlsNegotiationTimeout(policy.tlsNegotiationTimeout()) + .connectionAcquisitionTimeout(policy.connectionAcquireTimeout()) + .readTimeout(policy.readTimeout()) + .writeTimeout(policy.writeTimeout()) + .maxConcurrency(policy.maximumConcurrency()) + .maxPendingConnectionAcquires(policy.maximumPendingAcquires()) + .useIdleConnectionReaper(true) + .build(); + try { + EqualJitterBackoffStrategy backoff = + EqualJitterBackoffStrategy.builder() + .baseDelay(policy.retryBaseDelay()) + .maxBackoffTime(policy.retryMaximumBackoff()) + .build(); + RetryPolicy retry = + RetryPolicy.builder() + .numRetries(policy.maximumAttempts() - 1) + .backoffStrategy(backoff) + .throttlingBackoffStrategy(backoff) + .build(); + ClientOverrideConfiguration override = + ClientOverrideConfiguration.builder() + .apiCallTimeout(policy.apiCallTimeout()) + .apiCallAttemptTimeout(policy.apiCallAttemptTimeout()) + .retryPolicy(retry) + .build(); + S3AsyncClientBuilder builder = + S3AsyncClient.builder() + .httpClient(httpClient) + .credentialsProvider(policy.credentialsProvider()) + .region(Region.of(policy.region())) + .overrideConfiguration(override) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) + .forcePathStyle(policy.pathStyleAccess()) + .multipartEnabled(false); + if (policy.endpointOverride() != null) { + builder.endpointOverride(policy.endpointOverride()); + } + return new S3ClientLifecycle(builder.build(), httpClient, policy.shutdownGrace()); + } catch (RuntimeException exception) { + httpClient.close(); + throw exception; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridge.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridge.java new file mode 100644 index 0000000..019d111 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridge.java @@ -0,0 +1,270 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.content.ObjectChunkSink; +import dev.caskeleton.application.objectstorage.content.ObjectChunkWriteException; +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import software.amazon.awssdk.core.async.AsyncRequestBody; + +/** + * Single-subscription request body that runs the blocking producer on an adapter worker and waits + * for downstream demand before retaining at most one bounded chunk. + */ +public final class S3AsyncRequestBodyBridge implements AsyncRequestBody { + + private final ObjectStorageProviderOperation operation; + private final ObjectContentProducer producer; + private final Executor worker; + private final CompletableFuture observedIdentity = + new CompletableFuture<>(); + private final AtomicBoolean subscribed = new AtomicBoolean(); + private final AtomicInteger maximumBufferedBytesObserved = new AtomicInteger(); + + public S3AsyncRequestBodyBridge( + ObjectStorageProviderOperation operation, ObjectContentProducer producer, Executor worker) { + this.operation = Objects.requireNonNull(operation, "operation must be non-null"); + this.producer = Objects.requireNonNull(producer, "producer must be non-null"); + this.worker = Objects.requireNonNull(worker, "worker must be non-null"); + } + + @Override + public Optional contentLength() { + return Optional.of(operation.expectedContentIdentity().exactLength()); + } + + @Override + public String contentType() { + return operation.mediaType().canonicalText(); + } + + @Override + public void subscribe(Subscriber subscriber) { + Objects.requireNonNull(subscriber, "subscriber must be non-null"); + if (!subscribed.compareAndSet(false, true)) { + subscriber.onSubscribe(RejectedSubscription.INSTANCE); + subscriber.onError( + new IllegalStateException("non-repeatable S3 request body cannot be subscribed twice")); + return; + } + BridgeSubscription subscription = new BridgeSubscription(subscriber); + subscriber.onSubscribe(subscription); + worker.execute(subscription::produce); + } + + public CompletableFuture observedIdentity() { + return observedIdentity; + } + + public int maximumBufferedBytesObserved() { + return maximumBufferedBytesObserved.get(); + } + + private final class BridgeSubscription implements Subscription { + + private final Subscriber subscriber; + private final Object monitor = new Object(); + private long demand; + private boolean cancelled; + private boolean terminated; + + private BridgeSubscription(Subscriber subscriber) { + this.subscriber = subscriber; + } + + @Override + public void request(long count) { + if (count <= 0) { + fail(new IllegalArgumentException("reactive demand must be positive")); + return; + } + synchronized (monitor) { + if (cancelled || terminated) { + return; + } + long next = demand + count; + demand = next < 0 ? Long.MAX_VALUE : next; + monitor.notifyAll(); + } + } + + @Override + public void cancel() { + synchronized (monitor) { + cancelled = true; + monitor.notifyAll(); + } + } + + private void produce() { + MessageDigest digest = sha256(); + long[] written = {0}; + CancellationView cancellation = () -> operation.cancellation().isCancelled() || isCancelled(); + ObjectContentProductionContext context = + ObjectContentProductionContext.open( + operation.budget(), cancellation, operation.maximumChunkBytes()); + ObjectChunkSink sink = + ObjectChunkSink.scoped( + context, + (bytes, offset, length) -> { + requireActive(); + long next; + try { + next = Math.addExact(written[0], length); + } catch (ArithmeticException exception) { + throw new ObjectChunkWriteException("S3 content length overflows", exception); + } + if (next > operation.expectedContentIdentity().exactLength() + || next > operation.maximumObjectBytes()) { + throw new ObjectChunkWriteException("S3 producer exceeded its exact bound"); + } + awaitDemand(); + byte[] copy = Arrays.copyOfRange(bytes, offset, offset + length); + maximumBufferedBytesObserved.accumulateAndGet(copy.length, Math::max); + try { + subscriber.onNext(ByteBuffer.wrap(copy).asReadOnlyBuffer()); + } catch (RuntimeException exception) { + throw new ObjectChunkWriteException("S3 subscriber rejected content", exception); + } + digest.update(copy); + written[0] = next; + }); + try { + requireActive(); + producer.produce(context, sink); + requireActive(); + ObjectContentIdentity observed = + new ObjectContentIdentity( + written[0], + ObjectDigest.of( + dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256, + java.util.Base64.getEncoder().encodeToString(digest.digest()))); + if (!observed.equals(operation.expectedContentIdentity())) { + throw providerFailure( + ObjectStorageProviderException.Failure.CONTENT_MISMATCH, + "S3 producer content does not match its expected identity", + null); + } + synchronized (monitor) { + terminated = true; + } + observedIdentity.complete(observed); + subscriber.onComplete(); + } catch (Exception exception) { + ObjectStorageProviderException mapped = + exception instanceof ObjectStorageProviderException providerException + ? providerException + : providerFailure( + isCancelled() + ? ObjectStorageProviderException.Failure.CANCELLED + : ObjectStorageProviderException.Failure.IO_FAILURE, + "bounded S3 content production failed", + exception); + fail(mapped); + } finally { + context.invalidate(); + } + } + + private void awaitDemand() throws ObjectChunkWriteException { + synchronized (monitor) { + while (demand == 0 && !cancelled && !operation.cancellation().isCancelled()) { + requireDeadline(); + try { + long remaining = operation.budget().remainingNanosAt(System.nanoTime()); + long waitMillis = Math.max(1, Math.min(100, remaining / 1_000_000)); + monitor.wait(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new ObjectChunkWriteException("S3 producer was interrupted", exception); + } + } + requireActive(); + if (demand != Long.MAX_VALUE) { + demand--; + } + } + } + + private void requireActive() { + if (isCancelled()) { + throw providerFailure( + ObjectStorageProviderException.Failure.CANCELLED, + "S3 content production is cancelled", + null); + } + requireDeadline(); + } + + private void requireDeadline() { + if (operation.budget().isExpiredAt(System.nanoTime())) { + throw providerFailure( + ObjectStorageProviderException.Failure.DEADLINE_EXCEEDED, + "S3 content production deadline elapsed", + null); + } + } + + private boolean isCancelled() { + synchronized (monitor) { + return cancelled || operation.cancellation().isCancelled(); + } + } + + private void fail(Throwable failure) { + boolean notify; + synchronized (monitor) { + notify = !terminated; + terminated = true; + monitor.notifyAll(); + } + if (notify) { + observedIdentity.completeExceptionally(failure); + if (!cancelled) { + subscriber.onError(failure); + } + } + } + } + + 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 providerFailure( + ObjectStorageProviderException.Failure failure, String message, Throwable cause) { + return cause == null + ? new ObjectStorageProviderException(failure, message) + : new ObjectStorageProviderException(failure, message, cause); + } + + private enum RejectedSubscription implements Subscription { + INSTANCE; + + @Override + public void request(long count) {} + + @Override + public void cancel() {} + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridge.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridge.java new file mode 100644 index 0000000..eb4a060 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridge.java @@ -0,0 +1,342 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import dev.caskeleton.application.objectstorage.content.ObjectChunkReadException; +import dev.caskeleton.application.objectstorage.content.ObjectChunkSource; +import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer; +import dev.caskeleton.application.objectstorage.content.ObjectContentReadContext; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.core.async.SdkPublisher; + +/** + * Bounded response transformer that keeps SDK callbacks non-blocking and invokes the synchronous + * application consumer on an adapter-owned worker. + */ +public final class S3AsyncResponseBodyBridge implements AsyncResponseTransformer { + + private static final Chunk END = new Chunk(new byte[0], true); + + private final ObjectStorageProvider.ReadOperation operation; + private final ObjectContentConsumer consumer; + private final Executor worker; + private final Consumer responseValidator; + private final AtomicBoolean prepared = new AtomicBoolean(); + private final AtomicInteger maximumBufferedBytesObserved = new AtomicInteger(); + private volatile CompletableFuture result; + private volatile R response; + private volatile BodySubscriber bodySubscriber; + + public S3AsyncResponseBodyBridge( + ObjectStorageProvider.ReadOperation operation, + ObjectContentConsumer consumer, + Executor worker, + Consumer responseValidator) { + this.operation = Objects.requireNonNull(operation, "operation must be non-null"); + this.consumer = Objects.requireNonNull(consumer, "consumer must be non-null"); + this.worker = Objects.requireNonNull(worker, "worker must be non-null"); + this.responseValidator = + Objects.requireNonNull(responseValidator, "responseValidator must be non-null"); + } + + @Override + public CompletableFuture prepare() { + if (!prepared.compareAndSet(false, true)) { + return CompletableFuture.failedFuture( + new IllegalStateException( + "non-repeatable S3 response consumer cannot be prepared twice")); + } + result = new CompletableFuture<>(); + return result; + } + + @Override + public void onResponse(R response) { + requirePrepared(); + try { + responseValidator.accept(response); + this.response = Objects.requireNonNull(response, "response must be non-null"); + } catch (RuntimeException exception) { + fail(exception); + } + } + + @Override + public void onStream(SdkPublisher publisher) { + requirePrepared(); + if (response == null) { + fail(new IllegalStateException("S3 response metadata is absent")); + return; + } + BodySubscriber subscriber = new BodySubscriber(); + bodySubscriber = subscriber; + worker.execute(() -> consume(subscriber)); + publisher.subscribe(subscriber); + } + + @Override + public void exceptionOccurred(Throwable throwable) { + fail( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.IO_FAILURE, + "S3 response stream failed", + throwable)); + } + + public int maximumBufferedBytesObserved() { + return maximumBufferedBytesObserved.get(); + } + + private void consume(BodySubscriber subscriber) { + ObjectContentReadContext context = + ObjectContentReadContext.open( + operation.budget(), + operation.cancellation(), + operation.maximumChunkBytes(), + operation.contentIdentity(), + operation.exactVersion(), + operation.range()); + MessageDigest digest = sha256(); + ChunkCursor cursor = new ChunkCursor(subscriber, digest); + ObjectChunkSource source = ObjectChunkSource.scoped(context, cursor::read); + try { + consumer.consume(context, source); + if (!cursor.eof || cursor.delivered != operation.range().length()) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.PRECONDITION_FAILED, + "S3 consumer did not read the exact response range"); + } + if (operation.range().offset() == 0 + && operation.range().length() == operation.contentIdentity().exactLength() + && !MessageDigest.isEqual( + digest.digest(), operation.contentIdentity().fullDigest().decodedValue())) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CONTENT_MISMATCH, + "S3 response logical digest does not match"); + } + result.complete(response); + } catch (Exception exception) { + subscriber.cancel(); + fail( + exception instanceof ObjectStorageProviderException + ? exception + : new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.IO_FAILURE, + "bounded S3 content consumption failed", + exception)); + } finally { + context.invalidate(); + } + } + + private void requirePrepared() { + if (result == null) { + throw new IllegalStateException("prepare must be called before S3 response callbacks"); + } + } + + private void fail(Throwable failure) { + CompletableFuture target = result; + if (target != null) { + target.completeExceptionally(failure); + } + BodySubscriber subscriber = bodySubscriber; + if (subscriber != null) { + subscriber.cancel(); + } + } + + private final class BodySubscriber implements Subscriber { + + private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(2); + private volatile Subscription subscription; + private volatile boolean cancelled; + + @Override + public void onSubscribe(Subscription subscription) { + if (this.subscription != null) { + subscription.cancel(); + return; + } + this.subscription = subscription; + if (cancelled) { + subscription.cancel(); + } else { + subscription.request(1); + } + } + + @Override + public void onNext(ByteBuffer item) { + if (cancelled || result.isDone()) { + return; + } + int remaining = item.remaining(); + if (remaining < 1 || remaining > operation.maximumChunkBytes()) { + cancel(); + fail( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 response chunk exceeds the bounded bridge size")); + return; + } + byte[] copy = new byte[remaining]; + item.get(copy); + maximumBufferedBytesObserved.accumulateAndGet(copy.length, Math::max); + if (!queue.offer(new Chunk(copy, false))) { + cancel(); + fail( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 publisher exceeded bounded demand")); + } + } + + @Override + public void onError(Throwable throwable) { + fail( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.IO_FAILURE, "S3 publisher failed", throwable)); + queue.offer(END); + } + + @Override + public void onComplete() { + if (!queue.offer(END)) { + cancel(); + fail( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 publisher completion exceeded bounded demand")); + } + } + + private Chunk take() throws ObjectChunkReadException { + while (true) { + requireActive(); + try { + Chunk chunk = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS); + if (chunk != null) { + return chunk; + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new ObjectChunkReadException("S3 response consumer was interrupted", exception); + } + } + } + + private void requestNext() { + Subscription current = subscription; + if (current != null && !cancelled) { + current.request(1); + } + } + + private void cancel() { + cancelled = true; + Subscription current = subscription; + if (current != null) { + current.cancel(); + } + } + + private void requireActive() throws ObjectChunkReadException { + if (cancelled || operation.cancellation().isCancelled()) { + throw new ObjectChunkReadException("S3 response consumption is cancelled"); + } + if (operation.budget().isExpiredAt(System.nanoTime())) { + throw new ObjectChunkReadException("S3 response consumption deadline elapsed"); + } + } + } + + private final class ChunkCursor { + + private final BodySubscriber subscriber; + private final MessageDigest digest; + private byte[] current; + private int offset; + private long delivered; + private boolean eof; + + private ChunkCursor(BodySubscriber subscriber, MessageDigest digest) { + this.subscriber = subscriber; + this.digest = digest; + } + + private int read(byte[] destination, int destinationOffset, int length) + throws ObjectChunkReadException { + if (eof) { + return ObjectChunkSource.EOF; + } + if (current == null || offset == current.length) { + Chunk chunk = subscriber.take(); + if (chunk.end()) { + eof = true; + if (delivered != operation.range().length()) { + throw new ObjectChunkReadException("S3 response body is truncated"); + } + return ObjectChunkSource.EOF; + } + current = chunk.bytes(); + offset = 0; + } + int count = Math.min(length, current.length - offset); + long next = delivered + count; + if (next > operation.range().length()) { + throw new ObjectChunkReadException("S3 response exceeds the exact requested range"); + } + System.arraycopy(current, offset, destination, destinationOffset, count); + digest.update(current, offset, count); + offset += count; + delivered = next; + if (offset == current.length) { + current = null; + offset = 0; + subscriber.requestNext(); + } + return count; + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private static final class Chunk { + + private final byte[] bytes; + private final boolean end; + + private Chunk(byte[] bytes, boolean end) { + this.bytes = Arrays.copyOf(bytes, bytes.length); + this.end = end; + } + + private byte[] bytes() { + return Arrays.copyOf(bytes, bytes.length); + } + + private boolean end() { + return end; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3CapabilityProbe.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3CapabilityProbe.java new file mode 100644 index 0000000..8921803 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3CapabilityProbe.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidence; +import java.util.Objects; +import java.util.Set; + +/** Safe startup probe seam restricted to one reserved namespace and non-provisioning actions. */ +@FunctionalInterface +public interface S3CapabilityProbe { + + CapabilityEvidence probe(S3ProviderBinding binding, ProbePlan plan); + + enum Action { + BUCKET_HEAD, + CONDITIONAL_CREATE, + EXACT_HEAD, + EXACT_GET, + CONDITIONAL_CONTROL_CAS, + DELETE_PROBE_OBJECT, + CREATE_BUCKET, + CHANGE_BUCKET_CONFIGURATION + } + + /** Closed probe policy; unsafe actions are rejected during construction. */ + record ProbePlan(String reservedPrefix, int maximumProbeBytes, Set actions) { + + public ProbePlan { + if (reservedPrefix == null || !reservedPrefix.matches("_capability-probe/v1/[0-9a-f]{32}/")) { + throw new IllegalArgumentException("S3 probe prefix is not reserved and canonical"); + } + if (maximumProbeBytes < 1 || maximumProbeBytes > 4096) { + throw new IllegalArgumentException("S3 probe size exceeds the safe bound"); + } + actions = Set.copyOf(Objects.requireNonNull(actions, "actions must be non-null")); + if (actions.isEmpty() + || actions.contains(Action.CREATE_BUCKET) + || actions.contains(Action.CHANGE_BUCKET_CONFIGURATION)) { + throw new IllegalArgumentException("S3 probe contains an unsafe action"); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ChecksumPolicy.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ChecksumPolicy.java new file mode 100644 index 0000000..f1b1305 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ChecksumPolicy.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm; +import java.util.Map; +import java.util.Objects; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; + +/** Keeps the logical full-content SHA-256 distinct while requesting native S3 checksum evidence. */ +public final class S3ChecksumPolicy { + + public static final String LOGICAL_SHA_256_METADATA = "ca-logical-sha256"; + + private S3ChecksumPolicy() {} + + public static ChecksumAlgorithm requireSha256(ObjectContentIdentity identity) { + Objects.requireNonNull(identity, "identity must be non-null"); + if (identity.fullDigest().algorithm() != ObjectDigestAlgorithm.SHA_256) { + throw new IllegalArgumentException("managed S3 profile requires logical SHA-256"); + } + return ChecksumAlgorithm.SHA256; + } + + public static Map logicalDigestMetadata(ObjectContentIdentity identity) { + requireSha256(identity); + return Map.of(LOGICAL_SHA_256_METADATA, identity.fullDigest().base64Value()); + } + + public static void requireMatchingEvidence( + ObjectContentIdentity expected, String providerChecksum, Map metadata) { + requireSha256(expected); + String logical = metadata == null ? null : metadata.get(LOGICAL_SHA_256_METADATA); + if (!expected.fullDigest().base64Value().equals(logical) + || (providerChecksum != null + && !expected.fullDigest().base64Value().equals(providerChecksum))) { + throw new dev.caskeleton.adapter.outbound.objectstorage.provider + .ObjectStorageProviderException( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException + .Failure.CONTENT_MISMATCH, + "S3 logical/provider checksum evidence does not match"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientLifecycle.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientLifecycle.java new file mode 100644 index 0000000..84396fa --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientLifecycle.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.services.s3.S3AsyncClient; + +/** Selected S3 client plus its explicitly owned Netty HTTP client in deterministic close order. */ +public final class S3ClientLifecycle implements AutoCloseable { + + private final S3AsyncClient client; + private final SdkAsyncHttpClient httpClient; + private final Duration shutdownGrace; + private final AtomicBoolean closed = new AtomicBoolean(); + + S3ClientLifecycle(S3AsyncClient client, SdkAsyncHttpClient httpClient, Duration shutdownGrace) { + this.client = Objects.requireNonNull(client, "client must be non-null"); + this.httpClient = Objects.requireNonNull(httpClient, "httpClient must be non-null"); + this.shutdownGrace = Objects.requireNonNull(shutdownGrace, "shutdownGrace must be non-null"); + } + + S3AsyncClient client() { + return client; + } + + SdkAsyncHttpClient httpClient() { + return httpClient; + } + + Duration shutdownGrace() { + return shutdownGrace; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + RuntimeException failure = null; + try { + client.close(); + } catch (RuntimeException exception) { + failure = exception; + } + try { + httpClient.close(); + } catch (RuntimeException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } + if (failure != null) { + throw failure; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientPolicy.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientPolicy.java new file mode 100644 index 0000000..329d53f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientPolicy.java @@ -0,0 +1,367 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.net.URI; +import java.time.Duration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; + +/** Fully bounded client/HTTP/retry policy compiled before constructing an S3 client. */ +public final class S3ClientPolicy { + + public static final long MINIMUM_NON_FINAL_PART_BYTES = 5L * 1024 * 1024; + public static final int MAXIMUM_PART_COUNT = 10_000; + + private final Builder source; + private final AwsCredentialsProvider credentialsProvider; + + private S3ClientPolicy(Builder source) { + this.source = source.copy(); + validate(); + credentialsProvider = + source.accessKeyId == null + ? DefaultCredentialsProvider.create() + : StaticCredentialsProvider.create( + AwsBasicCredentials.create(source.accessKeyId, source.secretAccessKey)); + } + + public static Builder builder() { + return new Builder(); + } + + public static int requireSdkCompatibleMpuObjectSize(long objectSize) { + if (objectSize < 0 || objectSize > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "full-object multipart checksum profile exceeds the pinned SDK integer boundary"); + } + return Math.toIntExact(objectSize); + } + + public static void requirePartCount(int partCount) { + if (partCount < 1 || partCount > MAXIMUM_PART_COUNT) { + throw new IllegalArgumentException("S3 multipart part count is outside the supported range"); + } + } + + public static void requirePartSize(long partBytes, boolean finalPart) { + if (partBytes < 0 || (!finalPart && partBytes < MINIMUM_NON_FINAL_PART_BYTES)) { + throw new IllegalArgumentException("S3 multipart part size is outside the supported range"); + } + } + + String region() { + return source.region; + } + + Duration apiCallTimeout() { + return source.apiCallTimeout; + } + + Duration apiCallAttemptTimeout() { + return source.apiCallAttemptTimeout; + } + + Duration connectionTimeout() { + return source.connectionTimeout; + } + + Duration tlsNegotiationTimeout() { + return source.tlsNegotiationTimeout; + } + + Duration connectionAcquireTimeout() { + return source.connectionAcquireTimeout; + } + + Duration readTimeout() { + return source.readTimeout; + } + + Duration writeTimeout() { + return source.writeTimeout; + } + + int maximumConcurrency() { + return source.maximumConcurrency; + } + + int maximumPendingAcquires() { + return source.maximumPendingAcquires; + } + + int maximumAttempts() { + return source.maximumAttempts; + } + + Duration retryBaseDelay() { + return source.retryBaseDelay; + } + + Duration retryMaximumBackoff() { + return source.retryMaximumBackoff; + } + + Duration shutdownGrace() { + return source.shutdownGrace; + } + + URI endpointOverride() { + return source.endpointOverride; + } + + boolean pathStyleAccess() { + return source.pathStyleAccess; + } + + AwsCredentialsProvider credentialsProvider() { + return credentialsProvider; + } + + private void validate() { + requireToken("region", source.region); + requirePositive("apiCallTimeout", source.apiCallTimeout); + requirePositive("apiCallAttemptTimeout", source.apiCallAttemptTimeout); + requirePositive("connectionTimeout", source.connectionTimeout); + requirePositive("tlsNegotiationTimeout", source.tlsNegotiationTimeout); + requirePositive("connectionAcquireTimeout", source.connectionAcquireTimeout); + requirePositive("readTimeout", source.readTimeout); + requirePositive("writeTimeout", source.writeTimeout); + requirePositive("retryBaseDelay", source.retryBaseDelay); + requirePositive("retryMaximumBackoff", source.retryMaximumBackoff); + requirePositive("shutdownGrace", source.shutdownGrace); + if (source.apiCallAttemptTimeout.compareTo(source.apiCallTimeout) >= 0) { + throw invalid("per-attempt timeout must be shorter than the parent call timeout"); + } + for (Duration transport : + java.util.List.of( + source.connectionTimeout, + source.tlsNegotiationTimeout, + source.connectionAcquireTimeout, + source.readTimeout, + source.writeTimeout)) { + if (transport.compareTo(source.apiCallAttemptTimeout) > 0) { + throw invalid("transport timeout exceeds the per-attempt timeout"); + } + } + if (source.maximumConcurrency < 1 + || source.maximumPendingAcquires < 1 + || source.maximumAttempts < 1) { + throw invalid("pool and retry bounds must be positive"); + } + if (source.retryBaseDelay.compareTo(source.retryMaximumBackoff) > 0) { + throw invalid("retry base delay exceeds maximum backoff"); + } + Duration retryWorstCase = + multiply(source.apiCallAttemptTimeout, source.maximumAttempts) + .plus(multiply(source.retryMaximumBackoff, source.maximumAttempts - 1L)); + if (retryWorstCase.compareTo(source.apiCallTimeout) > 0) { + throw invalid("retry attempts and backoff exceed the parent call budget"); + } + validateEndpoint(source.endpointOverride); + boolean accessPresent = source.accessKeyId != null && !source.accessKeyId.isBlank(); + boolean secretPresent = source.secretAccessKey != null && !source.secretAccessKey.isBlank(); + if (accessPresent != secretPresent) { + throw invalid("static credentials must be complete"); + } + if (!accessPresent && (source.accessKeyId != null || source.secretAccessKey != null)) { + throw invalid("static credentials must be non-blank"); + } + } + + private static Duration multiply(Duration duration, long multiplier) { + try { + return duration.multipliedBy(multiplier); + } catch (ArithmeticException exception) { + throw invalid("retry budget overflows"); + } + } + + private static void validateEndpoint(URI endpoint) { + if (endpoint == null) { + return; + } + String scheme = endpoint.getScheme(); + String host = endpoint.getHost(); + if (scheme == null + || host == null + || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + || endpoint.getUserInfo() != null + || endpoint.getQuery() != null + || endpoint.getFragment() != null) { + throw invalid("endpoint override is not canonical"); + } + if ("http".equalsIgnoreCase(scheme) + && (host.equals("amazonaws.com") || host.endsWith(".amazonaws.com"))) { + throw invalid("plaintext AWS endpoint is forbidden"); + } + } + + private static void requirePositive(String label, Duration value) { + if (value == null || value.isZero() || value.isNegative()) { + throw invalid(label + " must be positive"); + } + } + + private static void requireToken(String label, String value) { + if (value == null + || value.isBlank() + || value.length() > 64 + || !value.matches("[a-z0-9][a-z0-9-]*")) { + throw invalid(label + " is invalid"); + } + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException("S3 client policy is invalid: " + message); + } + + @Override + public String toString() { + return "S3ClientPolicy[region=" + + source.region + + ", endpointOverride=" + + source.endpointOverride + + ", credentials=" + + (source.accessKeyId == null ? "default-chain" : "[REDACTED]") + + "]"; + } + + /** Mutable input builder; {@link #build()} returns the validated immutable policy. */ + public static final class Builder { + + private String region; + private Duration apiCallTimeout; + private Duration apiCallAttemptTimeout; + private Duration connectionTimeout; + private Duration tlsNegotiationTimeout; + private Duration connectionAcquireTimeout; + private Duration readTimeout; + private Duration writeTimeout; + private int maximumConcurrency; + private int maximumPendingAcquires; + private int maximumAttempts; + private Duration retryBaseDelay; + private Duration retryMaximumBackoff; + private Duration shutdownGrace; + private URI endpointOverride; + private boolean pathStyleAccess; + private String accessKeyId; + private String secretAccessKey; + + private Builder() {} + + public Builder region(String value) { + region = value; + return this; + } + + public Builder apiCallTimeout(Duration value) { + apiCallTimeout = value; + return this; + } + + public Builder apiCallAttemptTimeout(Duration value) { + apiCallAttemptTimeout = value; + return this; + } + + public Builder connectionTimeout(Duration value) { + connectionTimeout = value; + return this; + } + + public Builder tlsNegotiationTimeout(Duration value) { + tlsNegotiationTimeout = value; + return this; + } + + public Builder connectionAcquireTimeout(Duration value) { + connectionAcquireTimeout = value; + return this; + } + + public Builder readTimeout(Duration value) { + readTimeout = value; + return this; + } + + public Builder writeTimeout(Duration value) { + writeTimeout = value; + return this; + } + + public Builder maximumConcurrency(int value) { + maximumConcurrency = value; + return this; + } + + public Builder maximumPendingAcquires(int value) { + maximumPendingAcquires = value; + return this; + } + + public Builder maximumAttempts(int value) { + maximumAttempts = value; + return this; + } + + public Builder retryBaseDelay(Duration value) { + retryBaseDelay = value; + return this; + } + + public Builder retryMaximumBackoff(Duration value) { + retryMaximumBackoff = value; + return this; + } + + public Builder shutdownGrace(Duration value) { + shutdownGrace = value; + return this; + } + + public Builder endpointOverride(URI value) { + endpointOverride = value; + return this; + } + + public Builder pathStyleAccess(boolean value) { + pathStyleAccess = value; + return this; + } + + public Builder staticCredentials(String accessKey, String secretKey) { + accessKeyId = accessKey; + secretAccessKey = secretKey; + return this; + } + + public S3ClientPolicy build() { + return new S3ClientPolicy(this); + } + + private Builder copy() { + Builder copy = new Builder(); + copy.region = region; + copy.apiCallTimeout = apiCallTimeout; + copy.apiCallAttemptTimeout = apiCallAttemptTimeout; + copy.connectionTimeout = connectionTimeout; + copy.tlsNegotiationTimeout = tlsNegotiationTimeout; + copy.connectionAcquireTimeout = connectionAcquireTimeout; + copy.readTimeout = readTimeout; + copy.writeTimeout = writeTimeout; + copy.maximumConcurrency = maximumConcurrency; + copy.maximumPendingAcquires = maximumPendingAcquires; + copy.maximumAttempts = maximumAttempts; + copy.retryBaseDelay = retryBaseDelay; + copy.retryMaximumBackoff = retryMaximumBackoff; + copy.shutdownGrace = shutdownGrace; + copy.endpointOverride = endpointOverride; + copy.pathStyleAccess = pathStyleAccess; + copy.accessKeyId = accessKeyId; + copy.secretAccessKey = secretAccessKey; + return copy; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java new file mode 100644 index 0000000..f7a57a8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java @@ -0,0 +1,279 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +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.ObjectControlRecordCodec; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlVersion; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; + +/** Exact-key S3 control store using native conditional PUT rather than check-then-act emulation. */ +public final class S3ConditionalObjectControlStore implements ObjectControlStore { + + private static final int MAXIMUM_CONTROL_BYTES = 64 * 1024; + + private final S3AsyncClient client; + private final S3ProviderBinding binding; + private final ObjectControlRecordCodec codec; + private final S3ProviderErrorMapper errors = new S3ProviderErrorMapper(); + + public S3ConditionalObjectControlStore( + S3AsyncClient client, S3ProviderBinding binding, ObjectControlRecordCodec codec) { + this.client = Objects.requireNonNull(client, "client must be non-null"); + this.binding = Objects.requireNonNull(binding, "binding must be non-null"); + this.codec = Objects.requireNonNull(codec, "codec must be non-null"); + } + + @Override + public Optional read(String key) { + requireControlKey(key); + GetObjectRequest.Builder request = GetObjectRequest.builder().bucket(binding.bucket()).key(key); + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + try { + ControlPayload payload = + await(client.getObject(request.build(), new BoundedControlTransformer()), false); + ObjectControlRecord record = codec.decode(payload.bytes()); + return Optional.of(new StoredControlRecord(ObjectControlVersion.of(payload.eTag()), record)); + } catch (ObjectStorageProviderException exception) { + if (exception.failure() == ObjectStorageProviderException.Failure.NOT_FOUND) { + return Optional.empty(); + } + throw exception; + } + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + return put(key, record, "*", null); + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + Objects.requireNonNull(mutation, "mutation must be non-null"); + return put(key, mutation.replacement(), null, mutation.expectedVersion().value()); + } + + private StoredControlRecord put( + String key, ObjectControlRecord record, String ifNoneMatch, String ifMatch) { + requireControlKey(key); + Objects.requireNonNull(record, "record must be non-null"); + byte[] encoded = codec.encode(record); + if (encoded.length > MAXIMUM_CONTROL_BYTES) { + throw new IllegalArgumentException("control record exceeds the S3 control bound"); + } + PutObjectRequest.Builder request = + PutObjectRequest.builder() + .bucket(binding.bucket()) + .key(key) + .contentLength((long) encoded.length) + .contentType("application/vnd.caskeleton.object-control+json") + .serverSideEncryption(ServerSideEncryption.AES256); + if (ifNoneMatch != null) { + request.ifNoneMatch(ifNoneMatch); + } + if (ifMatch != null) { + request.ifMatch(ifMatch); + } + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + try { + PutObjectResponse response = + await( + client.putObject( + request.build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(encoded).asReadOnlyBuffer())), + true); + return stored(response.eTag(), record); + } catch (ObjectStorageProviderException exception) { + if (exception.failure() == ObjectStorageProviderException.Failure.CONFLICT + || exception.failure() == ObjectStorageProviderException.Failure.PRECONDITION_FAILED) { + throw new ObjectControlConflictException("S3 control conditional mutation conflicted"); + } + if (exception.failure() == ObjectStorageProviderException.Failure.INDETERMINATE) { + return reconcileLostResponse(key, record); + } + throw exception; + } + } + + private StoredControlRecord reconcileLostResponse(String key, ObjectControlRecord expected) { + Optional current = read(key); + if (current.isPresent() && current.orElseThrow().record().equals(expected)) { + return current.orElseThrow(); + } + if (current.isPresent()) { + throw new ObjectControlConflictException( + "S3 control response loss resolved to different exact record evidence"); + } + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.INDETERMINATE, + "S3 control mutation response was lost and no authoritative applied evidence exists"); + } + + private static StoredControlRecord stored(String eTag, ObjectControlRecord record) { + if (eTag == null || eTag.isBlank() || eTag.length() > 256) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 control mutation returned no bounded ETag"); + } + return new StoredControlRecord(ObjectControlVersion.of(eTag), record); + } + + private T await(CompletableFuture future, boolean mutation) { + try { + return future.join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof ObjectStorageProviderException providerException) { + throw providerException; + } + RuntimeException runtime = + cause instanceof RuntimeException runtimeException ? runtimeException : exception; + S3ProviderErrorMapper.Mapping mapping = errors.map(runtime, mutation); + throw new ObjectStorageProviderException(mapping.failure(), mapping.safeMessage(), runtime); + } + } + + private static void requireControlKey(String key) { + if (key == null + || key.length() > 512 + || !key.matches("control/v1/[a-z-]+/[a-zA-Z0-9._/-]+") + || key.contains("//") + || key.contains("..")) { + throw new IllegalArgumentException("S3 control key is not canonical"); + } + } + + private static final class ControlPayload { + + private final String eTag; + private final byte[] bytes; + + private ControlPayload(String eTag, byte[] bytes) { + if (eTag == null || eTag.isBlank()) { + throw new IllegalArgumentException("S3 control response ETag is absent"); + } + this.eTag = eTag; + this.bytes = bytes.clone(); + } + + private String eTag() { + return eTag; + } + + private byte[] bytes() { + return bytes.clone(); + } + } + + private static final class BoundedControlTransformer + implements AsyncResponseTransformer { + + private final AtomicBoolean prepared = new AtomicBoolean(); + private CompletableFuture result; + private GetObjectResponse response; + + @Override + public CompletableFuture prepare() { + if (!prepared.compareAndSet(false, true)) { + return CompletableFuture.failedFuture( + new IllegalStateException("S3 control response cannot be replayed")); + } + result = new CompletableFuture<>(); + return result; + } + + @Override + public void onResponse(GetObjectResponse response) { + this.response = response; + if (response.contentLength() == null + || response.contentLength() < 1 + || response.contentLength() > MAXIMUM_CONTROL_BYTES + || response.eTag() == null + || response.eTag().isBlank()) { + result.completeExceptionally( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 control response metadata exceeds its bound")); + } + } + + @Override + public void onStream(SdkPublisher publisher) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + publisher.subscribe( + new Subscriber<>() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + } + + @Override + public void onNext(ByteBuffer item) { + int next = bytes.size() + item.remaining(); + if (next > MAXIMUM_CONTROL_BYTES) { + subscription.cancel(); + result.completeExceptionally( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 control response body exceeds its bound")); + return; + } + byte[] chunk = new byte[item.remaining()]; + item.get(chunk); + bytes.writeBytes(chunk); + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + result.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + if (response == null + || response.contentLength() == null + || response.contentLength() != bytes.size()) { + result.completeExceptionally( + new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, + "S3 control response body is truncated")); + } else { + result.complete(new ControlPayload(response.eTag(), bytes.toByteArray())); + } + } + }); + } + + @Override + public void exceptionOccurred(Throwable throwable) { + result.completeExceptionally(throwable); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalRequestMapper.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalRequestMapper.java new file mode 100644 index 0000000..a418045 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalRequestMapper.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.util.Objects; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; + +/** Centralizes expected-owner, exact-version, ETag, and range request conditions. */ +public final class S3ConditionalRequestMapper { + + private S3ConditionalRequestMapper() {} + + public static HeadObjectRequest head(S3ProviderBinding binding, String key) { + HeadObjectRequest.Builder builder = + HeadObjectRequest.builder() + .bucket(binding.bucket()) + .key(key) + .checksumMode(software.amazon.awssdk.services.s3.model.ChecksumMode.ENABLED); + expectedOwner(binding, builder); + return builder.build(); + } + + public static GetObjectRequest get( + S3ProviderBinding binding, + ObjectStorageProviderReadEvidence evidence, + dev.caskeleton.application.objectstorage.model.ObjectReadRange range, + long fullLength) { + Objects.requireNonNull(evidence, "evidence must be non-null"); + GetObjectRequest.Builder builder = + GetObjectRequest.builder() + .bucket(binding.bucket()) + .key(evidence.canonicalDataKey()) + .ifMatch(evidence.privateEtag()) + .checksumMode(software.amazon.awssdk.services.s3.model.ChecksumMode.ENABLED); + if (evidence.providerVersionId() != null) { + builder.versionId(evidence.providerVersionId()); + } + if (range.offset() != 0 || range.length() != fullLength) { + builder.range("bytes=" + range.offset() + "-" + (range.endExclusive() - 1)); + } + expectedOwner(binding, builder); + return builder.build(); + } + + private static void expectedOwner(S3ProviderBinding binding, HeadObjectRequest.Builder builder) { + if (binding.expectedOwner() != null) { + builder.expectedBucketOwner(binding.expectedOwner()); + } + } + + private static void expectedOwner(S3ProviderBinding binding, GetObjectRequest.Builder builder) { + if (binding.expectedOwner() != null) { + builder.expectedBucketOwner(binding.expectedOwner()); + } + } + + /** Provider-private exact read preconditions derived from a validated HEAD. */ + public record ObjectStorageProviderReadEvidence( + String canonicalDataKey, String providerVersionId, String privateEtag) { + + public ObjectStorageProviderReadEvidence { + Objects.requireNonNull(canonicalDataKey, "canonicalDataKey must be non-null"); + Objects.requireNonNull(privateEtag, "privateEtag must be non-null"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectCompletionVerifier.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectCompletionVerifier.java new file mode 100644 index 0000000..5d8d352 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectCompletionVerifier.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectGrantProvider; +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectTransferSessionRecord; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import java.util.Objects; +import java.util.concurrent.CompletionException; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.ChecksumMode; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; + +/** Exact HEAD-based verifier; no client-reported success flag participates in completion. */ +public final class S3DirectCompletionVerifier { + + private final S3AsyncClient client; + private final S3ProviderBinding binding; + private final S3ProviderErrorMapper errors = new S3ProviderErrorMapper(); + + public S3DirectCompletionVerifier(S3AsyncClient client, S3ProviderBinding binding) { + this.client = Objects.requireNonNull(client, "client must be non-null"); + this.binding = Objects.requireNonNull(binding, "binding must be non-null"); + } + + public DirectGrantProvider.VerifiedUpload verify(DirectTransferSessionRecord session) { + HeadObjectResponse response = head(session.canonicalDataKey()); + S3ObjectEvidenceMapper.HeadEvidence evidence = + S3ObjectEvidenceMapper.fromHead( + session.canonicalDataKey(), response, binding.maximumObjectBytes()); + if (!evidence.storedData().contentIdentity().equals(session.expectedContent())) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CONTENT_MISMATCH, + "direct upload HEAD evidence does not match the exact expected content"); + } + return new DirectGrantProvider.VerifiedUpload( + evidence.storedData().contentIdentity(), evidence.storedData().exactVersion()); + } + + S3ObjectEvidenceMapper.HeadEvidence inspectExact( + String canonicalDataKey, + dev.caskeleton.application.objectstorage.model.ObjectContentIdentity expected, + dev.caskeleton.application.objectstorage.identity.ObjectVersionToken exactVersion) { + S3ObjectEvidenceMapper.HeadEvidence evidence = + S3ObjectEvidenceMapper.fromHead( + canonicalDataKey, head(canonicalDataKey), binding.maximumObjectBytes()); + if (!evidence.storedData().contentIdentity().equals(expected) + || !evidence.storedData().exactVersion().equals(exactVersion)) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.PRECONDITION_FAILED, + "direct download publication evidence is no longer exact"); + } + return evidence; + } + + private HeadObjectResponse head(String canonicalDataKey) { + HeadObjectRequest.Builder request = + HeadObjectRequest.builder() + .bucket(binding.bucket()) + .key(canonicalDataKey) + .checksumMode(ChecksumMode.ENABLED); + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + try { + return client.headObject(request.build()).join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + RuntimeException runtime = + cause instanceof RuntimeException runtimeException ? runtimeException : exception; + S3ProviderErrorMapper.Mapping mapping = errors.map(runtime, false); + throw new ObjectStorageProviderException(mapping.failure(), mapping.safeMessage(), runtime); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java new file mode 100644 index 0000000..9db6d17 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java @@ -0,0 +1,324 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +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.direct.DirectGrantProvider; +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectMultipartProvider; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.ChecksumMode; +import software.amazon.awssdk.services.s3.model.ChecksumType; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.ListMultipartUploadsRequest; +import software.amazon.awssdk.services.s3.model.ListPartsRequest; +import software.amazon.awssdk.services.s3.model.ListPartsResponse; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedUploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.model.UploadPartPresignRequest; + +/** Direct multipart S3 primitive; provider upload IDs and part evidence remain adapter-private. */ +public final class S3DirectMultipartProvider implements DirectMultipartProvider { + + private final S3Presigner presigner; + private final S3AsyncClient client; + private final S3ProviderBinding binding; + private final S3ProviderErrorMapper errors = new S3ProviderErrorMapper(); + + public S3DirectMultipartProvider( + S3Presigner presigner, S3AsyncClient client, S3ProviderBinding binding) { + this.presigner = Objects.requireNonNull(presigner, "presigner must be non-null"); + this.client = Objects.requireNonNull(client, "client must be non-null"); + this.binding = Objects.requireNonNull(binding, "binding must be non-null"); + } + + @Override + public InitiateResult initiate(ObjectDirectMultipartSessionRecord session) { + CreateMultipartUploadRequest.Builder request = + CreateMultipartUploadRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .contentType(session.mediaType().canonicalText()) + .metadata(S3ChecksumPolicy.logicalDigestMetadata(session.expectedContent())) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumType(ChecksumType.FULL_OBJECT) + .serverSideEncryption(ServerSideEncryption.AES256); + expectedOwner(request); + String uploadId = await(client.createMultipartUpload(request.build()), true).uploadId(); + return new InitiateResult(uploadId); + } + + @Override + public InitiateResolution resolveInitiate(ObjectDirectMultipartSessionRecord session) { + String keyMarker = null; + String uploadMarker = null; + List candidates = new ArrayList<>(); + for (int page = 0; page < 10; page++) { + ListMultipartUploadsRequest.Builder request = + ListMultipartUploadsRequest.builder() + .bucket(binding.bucket()) + .prefix(session.canonicalDataKey()) + .maxUploads(100) + .keyMarker(keyMarker) + .uploadIdMarker(uploadMarker); + expectedOwner(request); + var response = await(client.listMultipartUploads(request.build()), false); + response.uploads().stream() + .filter(upload -> session.canonicalDataKey().equals(upload.key())) + .map(software.amazon.awssdk.services.s3.model.MultipartUpload::uploadId) + .forEach(candidates::add); + if (!Boolean.TRUE.equals(response.isTruncated())) { + return candidates.size() == 1 + ? InitiateResolution.applied(candidates.getFirst()) + : candidates.isEmpty() + ? new InitiateResolution(Resolution.NOT_APPLIED, null) + : InitiateResolution.indeterminate(); + } + keyMarker = response.nextKeyMarker(); + uploadMarker = response.nextUploadIdMarker(); + if (keyMarker == null || uploadMarker == null) { + return InitiateResolution.indeterminate(); + } + } + return InitiateResolution.indeterminate(); + } + + @Override + public DirectGrantProvider.DirectGrantMaterial signPart( + ObjectDirectMultipartSessionRecord session, ObjectDirectMultipartGrantRecord grant) { + UploadPartRequest.Builder part = + UploadPartRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerSessionEvidence()) + .partNumber(grant.partNumber().value()) + .contentLength(grant.exactPartLength()) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumSHA256(grant.expectedPartDigest().base64Value()); + expectedOwner(part); + PresignedUploadPartRequest signed = + presigner.presignUploadPart( + UploadPartPresignRequest.builder() + .signatureDuration(Duration.between(grant.signingTime(), grant.expiresAt())) + .uploadPartRequest(part.build()) + .build()); + return material( + java.net.URI.create(signed.url().toString()), signed.signedHeaders(), signed.expiration()); + } + + @Override + public VerifiedPart verifyPart( + ObjectDirectMultipartSessionRecord session, MultipartPartAcknowledgement acknowledgement) { + if (!acknowledgement.clientCompletionClaim().matches("\"?[0-9a-fA-F-]{1,128}\"?")) { + throw new IllegalArgumentException("direct multipart completion claim is not allowlisted"); + } + ListPartsRequest.Builder request = + ListPartsRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerSessionEvidence()) + .maxParts(session.maximumParts()); + expectedOwner(request); + ListPartsResponse response = await(client.listParts(request.build()), false); + if (Boolean.TRUE.equals(response.isTruncated())) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.INDETERMINATE, + "direct multipart provider part ledger is truncated"); + } + var matching = + response.parts().stream() + .filter(part -> part.partNumber().equals(acknowledgement.partNumber().value())) + .toList(); + if (matching.size() != 1) { + throw new IllegalArgumentException("direct multipart provider part is absent or ambiguous"); + } + var part = matching.getFirst(); + if (!Objects.equals(part.eTag(), acknowledgement.clientCompletionClaim()) + || !Objects.equals(part.checksumSHA256(), acknowledgement.observedDigest().base64Value()) + || !Objects.equals(part.size(), acknowledgement.observedLength())) { + throw new IllegalArgumentException("direct multipart provider part evidence conflicts"); + } + return new VerifiedPart( + acknowledgement.partNumber(), + acknowledgement.observedLength(), + acknowledgement.observedDigest(), + part.eTag(), + part.checksumSHA256()); + } + + @Override + public boolean controlledIngressDrained(ObjectDirectMultipartSessionRecord session) { + return false; + } + + @Override + public DirectGrantProvider.VerifiedUpload complete( + ObjectDirectMultipartSessionRecord session, List parts) { + List completed = new ArrayList<>(parts.size()); + for (ObjectMultipartPartRecord part : parts) { + String[] evidence = part.providerPartEvidence().split(":", 2); + if (evidence.length != 2) { + throw new IllegalStateException("direct multipart part evidence is corrupt"); + } + completed.add( + CompletedPart.builder() + .partNumber(part.partNumber().value()) + .eTag(evidence[0]) + .checksumSHA256(evidence[1]) + .build()); + } + CompleteMultipartUploadRequest.Builder request = + CompleteMultipartUploadRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerSessionEvidence()) + .ifNoneMatch("*") + .checksumType(ChecksumType.FULL_OBJECT) + .checksumSHA256(session.expectedContent().fullDigest().base64Value()) + .mpuObjectSize( + S3ClientPolicy.requireSdkCompatibleMpuObjectSize( + session.expectedContent().exactLength())) + .multipartUpload(CompletedMultipartUpload.builder().parts(completed).build()); + expectedOwner(request); + await(client.completeMultipartUpload(request.build()), true); + return verifyCompleted(session); + } + + @Override + public DirectGrantProvider.VerifiedUpload resolveComplete( + ObjectDirectMultipartSessionRecord session, List parts) { + Objects.requireNonNull(parts, "parts must be non-null"); + return verifyCompleted(session); + } + + private DirectGrantProvider.VerifiedUpload verifyCompleted( + ObjectDirectMultipartSessionRecord session) { + HeadObjectRequest.Builder head = + HeadObjectRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .checksumMode(ChecksumMode.ENABLED); + if (binding.expectedOwner() != null) { + head.expectedBucketOwner(binding.expectedOwner()); + } + S3ObjectEvidenceMapper.HeadEvidence evidence = + S3ObjectEvidenceMapper.fromHead( + session.canonicalDataKey(), + await(client.headObject(head.build()), false), + binding.maximumObjectBytes()); + if (!evidence.storedData().contentIdentity().equals(session.expectedContent())) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CONTENT_MISMATCH, + "direct multipart full-object verification conflicts"); + } + return new DirectGrantProvider.VerifiedUpload( + evidence.storedData().contentIdentity(), evidence.storedData().exactVersion()); + } + + @Override + public void abort(ObjectDirectMultipartSessionRecord session) { + AbortMultipartUploadRequest.Builder request = + AbortMultipartUploadRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerSessionEvidence()); + expectedOwner(request); + await(client.abortMultipartUpload(request.build()), true); + } + + @Override + public Resolution resolveAbort(ObjectDirectMultipartSessionRecord session) { + ListPartsRequest.Builder parts = + ListPartsRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerSessionEvidence()) + .maxParts(1); + expectedOwner(parts); + try { + await(client.listParts(parts.build()), false); + return Resolution.NOT_APPLIED; + } catch (ObjectStorageProviderException exception) { + return exception.failure() == ObjectStorageProviderException.Failure.NOT_FOUND + ? Resolution.APPLIED + : Resolution.INDETERMINATE; + } + } + + private void expectedOwner(CreateMultipartUploadRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(ListMultipartUploadsRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(UploadPartRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(ListPartsRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(CompleteMultipartUploadRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(AbortMultipartUploadRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private static DirectGrantProvider.DirectGrantMaterial material( + java.net.URI uri, Map> headers, java.time.Instant expiration) { + Map flattened = new LinkedHashMap<>(); + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getValue() == null || entry.getValue().size() != 1) { + throw new IllegalStateException("S3 presigner returned a non-canonical part header"); + } + flattened.put(entry.getKey(), entry.getValue().getFirst()); + } + return new DirectGrantProvider.DirectGrantMaterial(uri, flattened, expiration); + } + + private T await(CompletableFuture future, boolean mutation) { + try { + return future.join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + RuntimeException runtime = + cause instanceof RuntimeException runtimeException ? runtimeException : exception; + S3ProviderErrorMapper.Mapping mapping = errors.map(runtime, mutation); + throw new ObjectStorageProviderException(mapping.failure(), mapping.safeMessage(), runtime); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProvider.java new file mode 100644 index 0000000..acad974 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProvider.java @@ -0,0 +1,144 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectGrantProvider; +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectTransferSessionRecord; +import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +/** Exact single-object PUT/GET presigning with server-side HEAD completion verification. */ +public final class S3DirectTransferProvider implements DirectGrantProvider { + + private final S3Presigner presigner; + private final S3ProviderBinding binding; + private final S3DirectCompletionVerifier verifier; + private final Function publishedResolver; + + public S3DirectTransferProvider( + S3Presigner presigner, S3AsyncClient client, S3ProviderBinding binding) { + this( + presigner, + client, + binding, + request -> { + throw new IllegalStateException("published direct-download resolution is not configured"); + }); + } + + public S3DirectTransferProvider( + S3Presigner presigner, + S3AsyncClient client, + S3ProviderBinding binding, + Function publishedResolver) { + this.presigner = Objects.requireNonNull(presigner, "presigner must be non-null"); + this.binding = Objects.requireNonNull(binding, "binding must be non-null"); + this.verifier = new S3DirectCompletionVerifier(client, binding); + this.publishedResolver = + Objects.requireNonNull(publishedResolver, "publishedResolver must be non-null"); + } + + @Override + public DirectGrantMaterial signUpload(DirectTransferSessionRecord session) { + requireGrantWindow(session); + PutObjectRequest.Builder put = + PutObjectRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .ifNoneMatch("*") + .contentLength(session.expectedContent().exactLength()) + .contentType(session.mediaType().canonicalText()) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumSHA256(session.expectedContent().fullDigest().base64Value()) + .metadata(S3ChecksumPolicy.logicalDigestMetadata(session.expectedContent())) + .serverSideEncryption(ServerSideEncryption.AES256); + if (binding.expectedOwner() != null) { + put.expectedBucketOwner(binding.expectedOwner()); + } + PresignedPutObjectRequest signed = + presigner.presignPutObject( + PutObjectPresignRequest.builder() + .signatureDuration(Duration.between(session.signingTime(), session.expiresAt())) + .putObjectRequest(put.build()) + .build()); + return material( + java.net.URI.create(signed.url().toString()), signed.signedHeaders(), signed.expiration()); + } + + @Override + public DirectGrantMaterial signDownload( + DirectTransferSessionRecord session, PublishedObject published) { + requireGrantWindow(session); + if (!published.active() + || !published.canonicalDataKey().equals(session.canonicalDataKey()) + || !published.contentIdentity().equals(session.expectedContent()) + || !published.exactVersion().equals(session.exactVersion())) { + throw new IllegalArgumentException("direct download publication differs from the session"); + } + S3ObjectEvidenceMapper.HeadEvidence evidence = + verifier.inspectExact( + published.canonicalDataKey(), published.contentIdentity(), published.exactVersion()); + GetObjectRequest.Builder get = + GetObjectRequest.builder() + .bucket(binding.bucket()) + .key(published.canonicalDataKey()) + .ifMatch(evidence.privateEtag()); + if (evidence.providerVersionId() != null) { + get.versionId(evidence.providerVersionId()); + } + if (binding.expectedOwner() != null) { + get.expectedBucketOwner(binding.expectedOwner()); + } + PresignedGetObjectRequest signed = + presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.between(session.signingTime(), session.expiresAt())) + .getObjectRequest(get.build()) + .build()); + return material( + java.net.URI.create(signed.url().toString()), signed.signedHeaders(), signed.expiration()); + } + + @Override + public VerifiedUpload verifyUpload(DirectTransferSessionRecord session) { + return verifier.verify(session); + } + + @Override + public PublishedObject resolvePublished(DirectDownloadGrantRequest request) { + return publishedResolver.apply(request); + } + + private static DirectGrantMaterial material( + java.net.URI uri, Map> signedHeaders, java.time.Instant expiration) { + Map flattened = new LinkedHashMap<>(); + for (Map.Entry> entry : signedHeaders.entrySet()) { + if (entry.getValue() == null || entry.getValue().size() != 1) { + throw new IllegalStateException("S3 presigner returned a non-canonical signed header"); + } + flattened.put(entry.getKey(), entry.getValue().getFirst()); + } + return new DirectGrantMaterial(uri, flattened, expiration); + } + + private static void requireGrantWindow(DirectTransferSessionRecord session) { + if (session.signingTime() == null + || session.expiresAt() == null + || !session.expiresAt().isAfter(session.signingTime())) { + throw new IllegalArgumentException("direct grant session has no signing window"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProvider.java new file mode 100644 index 0000000..acd82d1 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProvider.java @@ -0,0 +1,281 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.multipart.MultipartCompletionEvidence; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation; +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.ChecksumType; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse; +import software.amazon.awssdk.services.s3.model.ListPartsRequest; +import software.amazon.awssdk.services.s3.model.ListPartsResponse; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.model.UploadPartResponse; + +/** Low-level adapter-owned multipart calls; SDK automatic multipart remains disabled. */ +public final class S3ManagedMultipartProvider { + + private final S3AsyncClient client; + private final S3ProviderBinding binding; + private final Executor contentWorker; + private final S3ProviderErrorMapper errors = new S3ProviderErrorMapper(); + + public S3ManagedMultipartProvider( + S3AsyncClient client, S3ProviderBinding binding, Executor contentWorker) { + this.client = Objects.requireNonNull(client, "client must be non-null"); + this.binding = Objects.requireNonNull(binding, "binding must be non-null"); + this.contentWorker = Objects.requireNonNull(contentWorker, "contentWorker must be non-null"); + } + + public Session initiate(ObjectStorageProviderOperation fullObject) { + requireWithinBinding(fullObject); + CreateMultipartUploadRequest.Builder request = + CreateMultipartUploadRequest.builder() + .bucket(binding.bucket()) + .key(fullObject.canonicalDataKey()) + .contentType(fullObject.mediaType().canonicalText()) + .metadata(S3ChecksumPolicy.logicalDigestMetadata(fullObject.expectedContentIdentity())) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumType(ChecksumType.FULL_OBJECT) + .serverSideEncryption(ServerSideEncryption.AES256); + expectedOwner(request); + CreateMultipartUploadResponse response = + await(client.createMultipartUpload(request.build()), true); + if (response.uploadId() == null || response.uploadId().isBlank()) { + throw corrupt("S3 multipart initiate returned no upload ID"); + } + return new Session( + fullObject.canonicalDataKey(), response.uploadId(), fullObject.expectedContentIdentity()); + } + + public Part uploadPart( + Session session, + MultipartPartNumber partNumber, + ObjectStorageProviderOperation partOperation, + ObjectContentProducer producer, + boolean finalPart) { + Objects.requireNonNull(session, "session must be non-null"); + Objects.requireNonNull(partNumber, "partNumber must be non-null"); + requireWithinBinding(partOperation); + if (!session.canonicalDataKey().equals(partOperation.canonicalDataKey())) { + throw new IllegalArgumentException("multipart part key differs from its session"); + } + S3ClientPolicy.requirePartSize( + partOperation.expectedContentIdentity().exactLength(), finalPart); + S3AsyncRequestBodyBridge body = + new S3AsyncRequestBodyBridge(partOperation, producer, contentWorker); + UploadPartRequest.Builder request = + UploadPartRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerUploadId()) + .partNumber(partNumber.value()) + .contentLength(partOperation.expectedContentIdentity().exactLength()) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumSHA256(partOperation.expectedContentIdentity().fullDigest().base64Value()); + expectedOwner(request); + UploadPartResponse response = await(client.uploadPart(request.build(), body), true); + await(body.observedIdentity(), true); + if (response.eTag() == null + || response.eTag().isBlank() + || !Objects.equals( + response.checksumSHA256(), + partOperation.expectedContentIdentity().fullDigest().base64Value())) { + throw corrupt("S3 multipart part evidence is incomplete"); + } + return new Part( + partNumber, + partOperation.expectedContentIdentity(), + response.eTag(), + response.checksumSHA256()); + } + + public List listParts(Session session) { + ListPartsRequest.Builder request = + ListPartsRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerUploadId()) + .maxParts(S3ClientPolicy.MAXIMUM_PART_COUNT); + expectedOwner(request); + ListPartsResponse response = await(client.listParts(request.build()), false); + if (response.isTruncated()) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.INDETERMINATE, + "S3 multipart part listing exceeded the bounded page"); + } + return response.parts().stream() + .map( + part -> + new Part( + MultipartPartNumber.of(part.partNumber()), + null, + part.eTag(), + part.checksumSHA256())) + .toList(); + } + + public MultipartCompletionEvidence complete( + Session session, List parts, ObjectContentIdentity expectedFullContent) { + Objects.requireNonNull(session, "session must be non-null"); + Objects.requireNonNull(expectedFullContent, "expectedFullContent must be non-null"); + if (!session.expectedFullContent().equals(expectedFullContent)) { + throw new IllegalArgumentException("multipart completion identity differs from the session"); + } + List ordered = new ArrayList<>(parts); + ordered.sort(Comparator.comparingInt(part -> part.partNumber().value())); + S3ClientPolicy.requirePartCount(ordered.size()); + long total = 0; + List completed = new ArrayList<>(ordered.size()); + for (int index = 0; index < ordered.size(); index++) { + Part part = ordered.get(index); + if (part.partNumber().value() != index + 1 || part.contentIdentity() == null) { + throw new IllegalArgumentException("multipart completion ledger is not contiguous"); + } + total = Math.addExact(total, part.contentIdentity().exactLength()); + completed.add( + CompletedPart.builder() + .partNumber(part.partNumber().value()) + .eTag(part.privateEtag()) + .checksumSHA256(part.providerChecksum()) + .build()); + } + if (total != expectedFullContent.exactLength()) { + throw new IllegalArgumentException("multipart part sizes do not equal the full object"); + } + CompleteMultipartUploadRequest.Builder request = + CompleteMultipartUploadRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerUploadId()) + .multipartUpload(CompletedMultipartUpload.builder().parts(completed).build()) + .ifNoneMatch("*") + .checksumType(ChecksumType.FULL_OBJECT) + .checksumSHA256(expectedFullContent.fullDigest().base64Value()) + .mpuObjectSize( + S3ClientPolicy.requireSdkCompatibleMpuObjectSize( + expectedFullContent.exactLength())); + expectedOwner(request); + CompleteMultipartUploadResponse response = + await(client.completeMultipartUpload(request.build()), true); + if (response.eTag() == null + || response.eTag().isBlank() + || !Objects.equals( + response.checksumSHA256(), expectedFullContent.fullDigest().base64Value())) { + throw corrupt("S3 multipart completion evidence is incomplete"); + } + return new MultipartCompletionEvidence( + expectedFullContent, response.versionId(), response.eTag(), response.checksumSHA256()); + } + + public void abort(Session session) { + AbortMultipartUploadRequest.Builder request = + AbortMultipartUploadRequest.builder() + .bucket(binding.bucket()) + .key(session.canonicalDataKey()) + .uploadId(session.providerUploadId()); + expectedOwner(request); + await(client.abortMultipartUpload(request.build()), true); + } + + private void expectedOwner(CreateMultipartUploadRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(UploadPartRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(ListPartsRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(CompleteMultipartUploadRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void expectedOwner(AbortMultipartUploadRequest.Builder request) { + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + } + + private void requireWithinBinding(ObjectStorageProviderOperation operation) { + if (operation.maximumObjectBytes() > binding.maximumObjectBytes() + || operation.maximumChunkBytes() > binding.chunkBytes()) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.UNSUPPORTED, + "S3 multipart operation exceeds the compiled provider bounds"); + } + } + + private T await(CompletableFuture future, boolean mutation) { + try { + return future.join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + RuntimeException runtime = + cause instanceof RuntimeException runtimeException ? runtimeException : exception; + S3ProviderErrorMapper.Mapping mapping = errors.map(runtime, mutation); + throw new ObjectStorageProviderException(mapping.failure(), mapping.safeMessage(), runtime); + } + } + + private static ObjectStorageProviderException corrupt(String message) { + return new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, message); + } + + /** Private provider session identity and frozen full-content expectation. */ + public record Session( + String canonicalDataKey, String providerUploadId, ObjectContentIdentity expectedFullContent) { + + public Session { + Objects.requireNonNull(canonicalDataKey, "canonicalDataKey must be non-null"); + if (providerUploadId == null || providerUploadId.isBlank()) { + throw new IllegalArgumentException("providerUploadId must be non-blank"); + } + Objects.requireNonNull(expectedFullContent, "expectedFullContent must be non-null"); + } + } + + /** One private provider part receipt; never returned through an application port. */ + public record Part( + MultipartPartNumber partNumber, + ObjectContentIdentity contentIdentity, + String privateEtag, + String providerChecksum) { + + public Part { + Objects.requireNonNull(partNumber, "partNumber must be non-null"); + if (privateEtag == null || privateEtag.isBlank()) { + throw new IllegalArgumentException("privateEtag must be non-blank"); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProvider.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProvider.java new file mode 100644 index 0000000..dc987d6 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProvider.java @@ -0,0 +1,213 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +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.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; + +/** Bounded immutable single-object S3 data plane behind the provider-neutral contract. */ +public final class S3ManagedObjectProvider implements ObjectStorageProvider { + + private final S3AsyncClient client; + private final S3ProviderBinding binding; + private final Executor contentWorker; + private final S3ProviderErrorMapper errors = new S3ProviderErrorMapper(); + + public S3ManagedObjectProvider( + S3AsyncClient client, S3ProviderBinding binding, Executor contentWorker) { + this.client = Objects.requireNonNull(client, "client must be non-null"); + this.binding = Objects.requireNonNull(binding, "binding must be non-null"); + this.contentWorker = Objects.requireNonNull(contentWorker, "contentWorker must be non-null"); + } + + @Override + public ObjectStorageProviderDescriptor descriptor() { + return binding.providerDescriptor(); + } + + @Override + public StoredData create( + ObjectStorageProviderOperation operation, ObjectContentProducer producer) { + requireWithinBinding(operation); + S3AsyncRequestBodyBridge body = + new S3AsyncRequestBodyBridge(operation, producer, contentWorker); + PutObjectRequest.Builder request = + PutObjectRequest.builder() + .bucket(binding.bucket()) + .key(operation.canonicalDataKey()) + .contentLength(operation.expectedContentIdentity().exactLength()) + .contentType(operation.mediaType().canonicalText()) + .ifNoneMatch("*") + .checksumAlgorithm(S3ChecksumPolicy.requireSha256(operation.expectedContentIdentity())) + .checksumSHA256(operation.expectedContentIdentity().fullDigest().base64Value()) + .metadata(S3ChecksumPolicy.logicalDigestMetadata(operation.expectedContentIdentity())) + .serverSideEncryption(ServerSideEncryption.AES256); + if (binding.expectedOwner() != null) { + request.expectedBucketOwner(binding.expectedOwner()); + } + PutObjectResponse response = await(client.putObject(request.build(), body), true); + await(body.observedIdentity(), true); + if (response.serverSideEncryption() != ServerSideEncryption.AES256 + || (response.checksumSHA256() != null + && !response + .checksumSHA256() + .equals(operation.expectedContentIdentity().fullDigest().base64Value()))) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CONTENT_MISMATCH, + "S3 PUT evidence does not match the managed profile"); + } + return new StoredData( + operation.canonicalDataKey(), + operation.expectedContentIdentity(), + S3ObjectEvidenceMapper.versionForKey(operation.canonicalDataKey())); + } + + @Override + public Optional inspect(String canonicalDataKey) { + try { + return Optional.of(inspectEvidence(canonicalDataKey).storedData()); + } catch (ObjectStorageProviderException exception) { + if (exception.failure() == ObjectStorageProviderException.Failure.NOT_FOUND) { + return Optional.empty(); + } + throw exception; + } + } + + @Override + public void transfer(ReadOperation operation, ObjectContentConsumer consumer) { + requireWithinBinding(operation); + S3ObjectEvidenceMapper.HeadEvidence head = inspectEvidence(operation.canonicalDataKey()); + if (!head.storedData() + .equals( + new StoredData( + operation.canonicalDataKey(), + operation.contentIdentity(), + operation.exactVersion()))) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.PRECONDITION_FAILED, + "S3 exact read precondition failed"); + } + S3ConditionalRequestMapper.ObjectStorageProviderReadEvidence readEvidence = + new S3ConditionalRequestMapper.ObjectStorageProviderReadEvidence( + operation.canonicalDataKey(), head.providerVersionId(), head.privateEtag()); + GetObjectRequest request = + S3ConditionalRequestMapper.get( + binding, readEvidence, operation.range(), operation.contentIdentity().exactLength()); + S3AsyncResponseBodyBridge bridge = + new S3AsyncResponseBodyBridge<>( + operation, + consumer, + contentWorker, + response -> validateGetResponse(response, head, operation)); + await(client.getObject(request, bridge), false); + } + + @Override + public boolean retire(RetireOperation operation) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.UNSUPPORTED, + "version-aware S3 retirement is not available in the managed-single profile"); + } + + @Override + public Resolution resolveCreate(ObjectStorageProviderOperation operation) { + try { + Optional stored = inspect(operation.canonicalDataKey()); + if (stored.isEmpty()) { + return Resolution.NOT_APPLIED; + } + return stored.get().contentIdentity().equals(operation.expectedContentIdentity()) + ? Resolution.APPLIED + : Resolution.PRECONDITION_FAILED; + } catch (ObjectStorageProviderException exception) { + return Resolution.INDETERMINATE; + } + } + + private S3ObjectEvidenceMapper.HeadEvidence inspectEvidence(String canonicalDataKey) { + HeadObjectResponse response = + await(client.headObject(S3ConditionalRequestMapper.head(binding, canonicalDataKey)), false); + return S3ObjectEvidenceMapper.fromHead( + canonicalDataKey, response, binding.maximumObjectBytes()); + } + + private void validateGetResponse( + GetObjectResponse response, + S3ObjectEvidenceMapper.HeadEvidence head, + ReadOperation operation) { + if (!Objects.equals(response.contentLength(), operation.range().length()) + || !Objects.equals(response.eTag(), head.privateEtag()) + || response.serverSideEncryption() != ServerSideEncryption.AES256 + || (head.providerVersionId() != null + && !Objects.equals(response.versionId(), head.providerVersionId()))) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.PRECONDITION_FAILED, + "S3 GET response does not match exact HEAD evidence"); + } + boolean range = + operation.range().offset() != 0 + || operation.range().length() != operation.contentIdentity().exactLength(); + if (range) { + String expected = + "bytes " + + operation.range().offset() + + "-" + + (operation.range().endExclusive() - 1) + + "/" + + operation.contentIdentity().exactLength(); + if (!expected.equals(response.contentRange())) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.PRECONDITION_FAILED, + "S3 GET content range is not exact"); + } + } + } + + private void requireWithinBinding(ObjectStorageProviderOperation operation) { + if (operation.maximumObjectBytes() > binding.maximumObjectBytes() + || operation.maximumChunkBytes() > binding.chunkBytes()) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.UNSUPPORTED, + "S3 operation exceeds the compiled provider bounds"); + } + } + + private void requireWithinBinding(ReadOperation operation) { + if (operation.maximumDeliveredBytes() > binding.maximumObjectBytes() + || operation.maximumChunkBytes() > binding.chunkBytes()) { + throw new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.UNSUPPORTED, + "S3 read exceeds the compiled provider bounds"); + } + } + + private T await(CompletableFuture future, boolean mutation) { + try { + return future.join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof ObjectStorageProviderException providerException) { + throw providerException; + } + RuntimeException runtime = + cause instanceof RuntimeException runtimeException ? runtimeException : exception; + S3ProviderErrorMapper.Mapping mapping = errors.map(runtime, mutation); + throw new ObjectStorageProviderException(mapping.failure(), mapping.safeMessage(), runtime); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectEvidenceMapper.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectEvidenceMapper.java new file mode 100644 index 0000000..669b7a0 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectEvidenceMapper.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +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.identity.ObjectId; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm; +import java.util.Objects; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; + +/** Validates S3 HEAD evidence while keeping provider version IDs and ETags adapter-private. */ +public final class S3ObjectEvidenceMapper { + + private S3ObjectEvidenceMapper() {} + + public static HeadEvidence fromHead( + String canonicalDataKey, HeadObjectResponse response, long maximumObjectBytes) { + ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey); + Objects.requireNonNull(response, "response must be non-null"); + Long contentLength = response.contentLength(); + if (contentLength == null || contentLength < 0 || contentLength > maximumObjectBytes) { + throw corrupt("S3 HEAD content length is outside the binding"); + } + String logicalDigest = + response.metadata() == null + ? null + : response.metadata().get(S3ChecksumPolicy.LOGICAL_SHA_256_METADATA); + ObjectContentIdentity identity; + try { + identity = + new ObjectContentIdentity( + contentLength, ObjectDigest.of(ObjectDigestAlgorithm.SHA_256, logicalDigest)); + } catch (IllegalArgumentException exception) { + throw corrupt("S3 HEAD logical digest metadata is absent or corrupt", exception); + } + S3ChecksumPolicy.requireMatchingEvidence( + identity, response.checksumSHA256(), response.metadata()); + if (response.serverSideEncryption() != ServerSideEncryption.AES256) { + throw corrupt("S3 HEAD encryption evidence does not match the managed profile"); + } + if (response.eTag() == null || response.eTag().isBlank()) { + throw corrupt("S3 HEAD ETag evidence is absent"); + } + ObjectStorageProvider.StoredData stored = + new ObjectStorageProvider.StoredData( + canonicalDataKey, identity, versionForKey(canonicalDataKey)); + return new HeadEvidence(stored, response.versionId(), response.eTag()); + } + + public static ObjectVersionToken versionForKey(String canonicalDataKey) { + ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey); + String[] segments = canonicalDataKey.split("/", -1); + return ObjectHandleCodec.version( + ObjectRouteToken.parse(segments[2]), ObjectId.parse(segments[4])); + } + + /** Validated public facts plus provider-private conditional/read evidence. */ + public record HeadEvidence( + ObjectStorageProvider.StoredData storedData, String providerVersionId, String privateEtag) { + + public HeadEvidence { + Objects.requireNonNull(storedData, "storedData must be non-null"); + Objects.requireNonNull(privateEtag, "privateEtag must be non-null"); + } + } + + private static ObjectStorageProviderException corrupt(String message) { + return new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, message); + } + + private static ObjectStorageProviderException corrupt(String message, Throwable cause) { + return new ObjectStorageProviderException( + ObjectStorageProviderException.Failure.CORRUPT_DATA, message, cause); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java new file mode 100644 index 0000000..4772bcd --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import java.util.Objects; +import java.util.Optional; + +/** Exact-lookup-only resolution for uncertain S3 data and control mutations. */ +public final class S3ObjectOperationResolver { + + private final ObjectStorageProvider dataProvider; + private final ObjectControlStore controlStore; + + public S3ObjectOperationResolver( + ObjectStorageProvider dataProvider, ObjectControlStore controlStore) { + this.dataProvider = Objects.requireNonNull(dataProvider, "dataProvider must be non-null"); + this.controlStore = Objects.requireNonNull(controlStore, "controlStore must be non-null"); + } + + public ObjectStorageProvider.Resolution resolveDataPut( + String canonicalDataKey, ObjectContentIdentity expectedIdentity) { + Objects.requireNonNull(expectedIdentity, "expectedIdentity must be non-null"); + try { + Optional stored = dataProvider.inspect(canonicalDataKey); + if (stored.isEmpty()) { + return ObjectStorageProvider.Resolution.NOT_APPLIED; + } + return stored.orElseThrow().contentIdentity().equals(expectedIdentity) + ? ObjectStorageProvider.Resolution.APPLIED + : ObjectStorageProvider.Resolution.PRECONDITION_FAILED; + } catch (RuntimeException exception) { + return ObjectStorageProvider.Resolution.INDETERMINATE; + } + } + + public ObjectStorageProvider.Resolution resolveControlRecord( + String exactControlKey, ObjectControlRecord expectedRecord) { + Objects.requireNonNull(expectedRecord, "expectedRecord must be non-null"); + try { + Optional stored = controlStore.read(exactControlKey); + if (stored.isEmpty()) { + return ObjectStorageProvider.Resolution.NOT_APPLIED; + } + return stored.orElseThrow().record().equals(expectedRecord) + ? ObjectStorageProvider.Resolution.APPLIED + : ObjectStorageProvider.Resolution.PRECONDITION_FAILED; + } catch (RuntimeException exception) { + return ObjectStorageProvider.Resolution.INDETERMINATE; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java new file mode 100644 index 0000000..09eccbe --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +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.direct.DirectPublishedObjectResolver; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor; +import java.time.Clock; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** + * Explicit S3-family contribution. Description and compilation are side-effect free; client and + * credential construction occurs only after exact selection. + */ +public final class S3ObjectStorageProviderContribution + implements ObjectStorageProviderContribution { + + private final Clock clock; + private final S3ProviderQualifier qualifier; + private final Function clientFactory; + private final Function presignerFactory; + private final S3ProviderBinding.SecretResolver secrets; + + public S3ObjectStorageProviderContribution() { + this( + Clock.systemUTC(), + new S3ProviderQualifier( + Clock.systemUTC(), + (binding, plan) -> { + throw new IllegalStateException("startup S3 probing is not configured"); + }), + policy -> new S3AsyncClientFactory().create(policy), + policy -> new S3PresignerFactory().create(policy), + System::getenv); + } + + S3ObjectStorageProviderContribution( + Clock clock, + S3ProviderQualifier qualifier, + Function clientFactory, + S3ProviderBinding.SecretResolver secrets) { + this( + clock, + qualifier, + clientFactory, + policy -> new S3PresignerFactory().create(policy), + secrets); + } + + S3ObjectStorageProviderContribution( + Clock clock, + S3ProviderQualifier qualifier, + Function clientFactory, + Function presignerFactory, + S3ProviderBinding.SecretResolver secrets) { + this.clock = clock; + this.qualifier = qualifier; + this.clientFactory = clientFactory; + this.presignerFactory = presignerFactory; + this.secrets = secrets; + } + + @Override + public String providerType() { + return S3ProviderType.AWS_S3_GENERAL_PURPOSE.token(); + } + + @Override + public Set providerTypes() { + return Set.of( + S3ProviderType.AWS_S3_GENERAL_PURPOSE.token(), + S3ProviderType.MINIO_COMMUNITY_2024_01_16.token()); + } + + @Override + public ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings) { + return S3ProviderBinding.compile("descriptor", settings, clock).providerDescriptor(); + } + + @Override + public Object compileProviderBinding(String providerId, ObjectStorageProviderSettings settings) { + return S3ProviderBinding.compile(providerId, settings, clock); + } + + @Override + public SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider) { + S3ProviderBinding binding = provider.requireProviderBinding(S3ProviderBinding.class); + for (String profile : binding.capabilityDescriptor().operationProfiles().keySet()) { + if (binding.capabilityDescriptor().profile(profile).evidence().supportsAt(clock.instant())) { + qualifier.qualify(binding, profile); + } + } + S3ClientPolicy clientPolicy = binding.clientPolicy(secrets); + S3ClientLifecycle lifecycle = clientFactory.apply(clientPolicy); + ExecutorService contentWorkers = + Executors.newFixedThreadPool( + Math.max(1, Math.min(8, clientPolicy.maximumConcurrency())), + runnable -> { + Thread thread = new Thread(runnable, "object-storage-s3-content"); + thread.setDaemon(true); + return thread; + }); + S3Presigner presigner = null; + try { + boolean directUploadEnabled = + binding + .providerDescriptor() + .support() + .get( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider + .Capability.DIRECT_UPLOAD) + == dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider + .Support.SUPPORTED; + boolean directMultipartEnabled = + binding + .providerDescriptor() + .support() + .get( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider + .Capability.DIRECT_MULTIPART) + == dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider + .Support.SUPPORTED; + presigner = + directUploadEnabled || directMultipartEnabled + ? presignerFactory.apply(clientPolicy) + : null; + S3DirectTransferProvider directProvider = + !directUploadEnabled + ? null + : new S3DirectTransferProvider( + presigner, + lifecycle.client(), + binding, + new DirectPublishedObjectResolver( + new S3ConditionalObjectControlStore( + lifecycle.client(), + binding, + new CanonicalJsonObjectControlRecordCodec())) + ::resolve); + S3DirectMultipartProvider directMultipartProvider = + !directMultipartEnabled + ? null + : new S3DirectMultipartProvider(presigner, lifecycle.client(), binding); + S3Presigner ownedPresigner = presigner; + return new SelectedObjectStorageProviderFactory( + new S3ManagedObjectProvider(lifecycle.client(), binding, contentWorkers), + new S3ConditionalObjectControlStore( + lifecycle.client(), binding, new CanonicalJsonObjectControlRecordCodec()), + new S3ManagedMultipartProvider(lifecycle.client(), binding, contentWorkers), + directProvider, + directMultipartProvider, + () -> close(contentWorkers, ownedPresigner, lifecycle)); + } catch (RuntimeException exception) { + contentWorkers.shutdownNow(); + if (presigner != null) { + presigner.close(); + } + lifecycle.close(); + throw exception; + } + } + + private static void close( + ExecutorService workers, S3Presigner presigner, S3ClientLifecycle lifecycle) { + workers.shutdown(); + try { + if (!workers.awaitTermination(lifecycle.shutdownGrace().toNanos(), TimeUnit.NANOSECONDS)) { + workers.shutdownNow(); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + workers.shutdownNow(); + } finally { + try { + if (presigner != null) { + presigner.close(); + } + } finally { + lifecycle.close(); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PresignerFactory.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PresignerFactory.java new file mode 100644 index 0000000..f999696 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PresignerFactory.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.util.Objects; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** Constructs an explicitly selected S3 presigner from the same frozen binding as the client. */ +public final class S3PresignerFactory { + + public S3Presigner create(S3ClientPolicy policy) { + Objects.requireNonNull(policy, "policy must be non-null"); + S3Presigner.Builder builder = + S3Presigner.builder() + .credentialsProvider(policy.credentialsProvider()) + .region(Region.of(policy.region())) + .serviceConfiguration( + S3Configuration.builder().pathStyleAccessEnabled(policy.pathStyleAccess()).build()); + if (policy.endpointOverride() != null) { + builder.endpointOverride(policy.endpointOverride()); + } + return builder.build(); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBinding.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBinding.java new file mode 100644 index 0000000..482bf89 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBinding.java @@ -0,0 +1,361 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageProviderSettings; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidence; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageCapabilityDescriptor; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageOperationProfile; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Exact immutable S3-family binding compiled without credentials, clients, DNS, or probes. */ +public record S3ProviderBinding( + String providerId, + S3ProviderType providerType, + S3ProviderVersion providerVersion, + String bucket, + String region, + String expectedOwner, + String deploymentIdentity, + URI endpointOverride, + boolean pathStyleAccess, + long maximumObjectBytes, + int chunkBytes, + S3ProviderSettings settings, + ObjectStorageCapabilityDescriptor capabilityDescriptor, + ObjectStorageProviderDescriptor providerDescriptor, + Instant compiledAt) { + + public S3ProviderBinding { + requireCanonicalId("providerId", providerId, 64); + Objects.requireNonNull(providerType, "providerType must be non-null"); + Objects.requireNonNull(providerVersion, "providerVersion must be non-null"); + requireBucket(bucket); + requireToken("region", region, 64); + requireToken("deploymentIdentity", deploymentIdentity, 256); + Objects.requireNonNull(settings, "settings must be non-null"); + Objects.requireNonNull(capabilityDescriptor, "capabilityDescriptor must be non-null"); + Objects.requireNonNull(providerDescriptor, "providerDescriptor must be non-null"); + Objects.requireNonNull(compiledAt, "compiledAt must be non-null"); + } + + public static S3ProviderBinding compile( + String providerId, ObjectStorageProviderSettings source, Clock clock) { + Objects.requireNonNull(source, "provider settings must be non-null"); + Objects.requireNonNull(clock, "clock must be non-null"); + S3ProviderType type = S3ProviderType.parse(source.type()); + S3ProviderSettings settings = + Objects.requireNonNull(source.s3(), "exact S3 settings must be present"); + if (!Objects.equals(source.version(), settings.providerVersion())) { + throw invalid("provider versions disagree"); + } + S3ProviderVersion version = S3ProviderVersion.require(type, settings.providerVersion()); + requireBucket(settings.bucket()); + requireToken("region", settings.region(), 64); + requireToken("bindingRevision", settings.bindingRevision(), 128); + requireToken("evidenceRevision", settings.evidenceRevision(), 128); + validateEndpoint(type, settings.endpointOverride()); + validateAddressing(type, settings.addressing()); + validateProvisioning(settings); + validateIdentity(type, settings); + validateCredentials(type, settings.credentials()); + Objects.requireNonNull(source.maximumObjectBytes(), "maximumObjectBytes must be non-null"); + Objects.requireNonNull(source.chunkBytes(), "chunkBytes must be non-null"); + long maximumBytes = source.maximumObjectBytes().toBytes(); + long chunkBytes = source.chunkBytes().toBytes(); + if (maximumBytes < 1 + || chunkBytes < 1 + || chunkBytes > maximumBytes + || chunkBytes > 16L * 1024 * 1024) { + throw invalid("object and chunk bounds are invalid"); + } + validateClientBounds(settings); + Instant now = clock.instant(); + String deployment = settings.deploymentIdentity(); + String evidenceIdentity = type.token() + ":" + version.token() + ":" + deployment; + Map profiles = + compileProfiles(type, settings.capabilityProfiles(), evidenceIdentity); + ObjectStorageCapabilityDescriptor capabilityDescriptor = + new ObjectStorageCapabilityDescriptor( + type.token(), + version.token(), + settings.bindingRevision(), + settings.evidenceRevision(), + now, + profiles, + Map.of( + "maximum-object-bytes", maximumBytes, + "maximum-chunk-bytes", chunkBytes, + "maximum-part-count", (long) S3ClientPolicy.MAXIMUM_PART_COUNT)); + EnumMap support = + supportAt(profiles, now); + ObjectStorageProviderDescriptor providerDescriptor = + new ObjectStorageProviderDescriptor( + type.token(), + version.token(), + maximumBytes, + Math.toIntExact(chunkBytes), + support, + Optional.of(capabilityDescriptor)); + return new S3ProviderBinding( + providerId, + type, + version, + settings.bucket(), + settings.region(), + settings.expectedOwner(), + deployment, + settings.endpointOverride(), + "path-style".equals(settings.addressing()), + maximumBytes, + Math.toIntExact(chunkBytes), + settings, + capabilityDescriptor, + providerDescriptor, + now); + } + + public ObjectStorageOperationProfile requireSupportedProfile(String profileName) { + return capabilityDescriptor.requireSupported(profileName, compiledAt); + } + + public String providerAndDeploymentIdentity() { + return providerType.token() + ":" + providerVersion.token() + ":" + deploymentIdentity; + } + + public S3ClientPolicy clientPolicy(SecretResolver secrets) { + Objects.requireNonNull(secrets, "secrets must be non-null"); + S3ProviderSettings.Timeouts timeouts = settings.timeouts(); + S3ProviderSettings.Pool pool = settings.pool(); + S3ProviderSettings.Retry retry = settings.retry(); + S3ClientPolicy.Builder builder = + S3ClientPolicy.builder() + .region(region) + .apiCallTimeout(timeouts.apiCall()) + .apiCallAttemptTimeout(timeouts.apiAttempt()) + .connectionTimeout(timeouts.connect()) + .tlsNegotiationTimeout(timeouts.tlsNegotiation()) + .connectionAcquireTimeout(timeouts.acquire()) + .readTimeout(timeouts.read()) + .writeTimeout(timeouts.write()) + .maximumConcurrency(pool.maximumConcurrency()) + .maximumPendingAcquires(pool.maximumPendingAcquires()) + .maximumAttempts(retry.maximumAttempts()) + .retryBaseDelay(retry.baseDelay()) + .retryMaximumBackoff(retry.maximumBackoff()) + .shutdownGrace(settings.shutdownGrace()) + .endpointOverride(endpointOverride) + .pathStyleAccess(pathStyleAccess); + S3ProviderSettings.Credentials credentials = settings.credentials(); + if ("environment".equals(credentials.mode())) { + String accessKey = secrets.resolve(credentials.accessKeyEnvironment()); + String secretKey = secrets.resolve(credentials.secretKeyEnvironment()); + if (accessKey == null || accessKey.isBlank() || secretKey == null || secretKey.isBlank()) { + throw invalid("credential environment references did not resolve completely"); + } + builder.staticCredentials(accessKey, secretKey); + } + return builder.build(); + } + + private static Map compileProfiles( + S3ProviderType providerType, + Map source, + String expectedIdentity) { + if (source == null || source.isEmpty()) { + throw invalid("at least one named operation profile is required"); + } + Map profiles = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + String name = entry.getKey(); + S3ProviderCapabilityConfig config = + Objects.requireNonNull(entry.getValue(), "capability profile must be non-null"); + CapabilityEvidence evidence = config.toEvidence(); + if (!expectedIdentity.equals(evidence.providerAndDeploymentIdentity())) { + throw invalid("capability evidence identity does not match the provider binding"); + } + if (providerType == S3ProviderType.MINIO_COMMUNITY_2024_01_16 + && evidence.status() + == dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidenceStatus + .SUPPORTED + && (config.capabilities().contains(ObjectStorageProvider.Capability.IMMUTABLE_CREATE) + || config + .capabilities() + .contains(ObjectStorageProvider.Capability.MULTI_NODE_LINEARIZABLE_CAS) + || config + .capabilities() + .contains(ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION) + || config.capabilities().contains(ObjectStorageProvider.Capability.DIRECT_UPLOAD) + || config + .capabilities() + .contains(ObjectStorageProvider.Capability.DIRECT_MULTIPART))) { + throw invalid( + "the exact MinIO release cannot claim native conditional managed mutation support"); + } + profiles.put(name, new ObjectStorageOperationProfile(name, config.capabilities(), evidence)); + } + return Map.copyOf(profiles); + } + + private static EnumMap supportAt( + Map profiles, Instant instant) { + EnumMap support = + new EnumMap<>(ObjectStorageProvider.Capability.class); + for (ObjectStorageProvider.Capability capability : ObjectStorageProvider.Capability.values()) { + boolean supported = + profiles.values().stream() + .anyMatch( + profile -> + profile.capabilities().contains(capability) + && profile.evidence().supportsAt(instant)); + support.put( + capability, + supported + ? ObjectStorageProvider.Support.SUPPORTED + : ObjectStorageProvider.Support.UNSUPPORTED); + } + return support; + } + + private static void validateIdentity(S3ProviderType type, S3ProviderSettings settings) { + requireToken("deploymentIdentity", settings.deploymentIdentity(), 256); + if (type == S3ProviderType.AWS_S3_GENERAL_PURPOSE) { + if (settings.expectedOwner() == null || !settings.expectedOwner().matches("[0-9]{12}")) { + throw invalid("AWS expected owner must be an exact 12-digit account ID"); + } + } else if (settings.expectedOwner() != null && !settings.expectedOwner().isBlank()) { + throw invalid("MinIO binding cannot claim an AWS expected owner"); + } + } + + private static void validateEndpoint(S3ProviderType type, URI endpoint) { + if (type == S3ProviderType.AWS_S3_GENERAL_PURPOSE && endpoint != null) { + throw invalid("AWS endpoint override is not part of the qualified profile"); + } + if (type == S3ProviderType.MINIO_COMMUNITY_2024_01_16 && endpoint == null) { + throw invalid("MinIO endpoint is required"); + } + if (endpoint == null) { + return; + } + if (!"https".equalsIgnoreCase(endpoint.getScheme()) + || endpoint.getHost() == null + || endpoint.getUserInfo() != null + || endpoint.getQuery() != null + || endpoint.getFragment() != null) { + throw invalid("endpoint must be canonical HTTPS without userinfo/query/fragment"); + } + } + + private static void validateAddressing(S3ProviderType type, String addressing) { + if (!"virtual-hosted".equals(addressing) && !"path-style".equals(addressing)) { + throw invalid("addressing must be exact"); + } + if (type == S3ProviderType.AWS_S3_GENERAL_PURPOSE && !"virtual-hosted".equals(addressing)) { + throw invalid("AWS general-purpose profile requires virtual-hosted addressing"); + } + } + + private static void validateProvisioning(S3ProviderSettings settings) { + if (settings.autoCreateBucket() || settings.publicAcl()) { + throw invalid("runtime provisioning and public ACLs are forbidden"); + } + } + + private static void validateCredentials( + S3ProviderType type, S3ProviderSettings.Credentials credentials) { + Objects.requireNonNull(credentials, "credentials must be non-null"); + if ("default-chain".equals(credentials.mode())) { + if (credentials.accessKeyEnvironment() != null + || credentials.secretKeyEnvironment() != null) { + throw invalid("default-chain credentials cannot include static references"); + } + if (type != S3ProviderType.AWS_S3_GENERAL_PURPOSE) { + throw invalid("MinIO requires explicit environment credential references"); + } + return; + } + if (!"environment".equals(credentials.mode()) + || type == S3ProviderType.AWS_S3_GENERAL_PURPOSE + || !isEnvironmentKey(credentials.accessKeyEnvironment()) + || !isEnvironmentKey(credentials.secretKeyEnvironment())) { + throw invalid("credential mode or environment references are invalid"); + } + } + + private static void validateClientBounds(S3ProviderSettings settings) { + Objects.requireNonNull(settings.timeouts(), "timeouts must be non-null"); + Objects.requireNonNull(settings.pool(), "pool must be non-null"); + Objects.requireNonNull(settings.retry(), "retry must be non-null"); + Objects.requireNonNull(settings.shutdownGrace(), "shutdownGrace must be non-null"); + S3ClientPolicy.Builder builder = + S3ClientPolicy.builder() + .region(settings.region()) + .apiCallTimeout(settings.timeouts().apiCall()) + .apiCallAttemptTimeout(settings.timeouts().apiAttempt()) + .connectionTimeout(settings.timeouts().connect()) + .tlsNegotiationTimeout(settings.timeouts().tlsNegotiation()) + .connectionAcquireTimeout(settings.timeouts().acquire()) + .readTimeout(settings.timeouts().read()) + .writeTimeout(settings.timeouts().write()) + .maximumConcurrency(settings.pool().maximumConcurrency()) + .maximumPendingAcquires(settings.pool().maximumPendingAcquires()) + .maximumAttempts(settings.retry().maximumAttempts()) + .retryBaseDelay(settings.retry().baseDelay()) + .retryMaximumBackoff(settings.retry().maximumBackoff()) + .shutdownGrace(settings.shutdownGrace()) + .endpointOverride(settings.endpointOverride()) + .pathStyleAccess("path-style".equals(settings.addressing())) + .staticCredentials("compile-only-access", "compile-only-secret"); + builder.build(); + } + + private static void requireBucket(String value) { + if (value == null + || value.length() < 3 + || value.length() > 63 + || !value.matches("[a-z0-9][a-z0-9.-]*[a-z0-9]") + || value.contains("..")) { + throw invalid("bucket is invalid"); + } + } + + private static void requireCanonicalId(String label, String value, int maximumLength) { + requireToken(label, value, maximumLength); + if (!value.equals(value.toLowerCase(Locale.ROOT)) || !value.matches("[a-z0-9][a-z0-9_-]*")) { + throw invalid(label + " must be canonical"); + } + } + + private static void requireToken(String label, String value, int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength + || value.chars().anyMatch(character -> character < 0x20 || character > 0x7e)) { + throw invalid(label + " is invalid"); + } + } + + private static boolean isEnvironmentKey(String value) { + return value != null && value.matches("[A-Z][A-Z0-9_]{0,126}"); + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException("S3 provider binding is invalid: " + message); + } + + /** Secret source seam resolved only after exact provider selection. */ + @FunctionalInterface + public interface SecretResolver { + + String resolve(String environmentKey); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCapabilityConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCapabilityConfig.java new file mode 100644 index 0000000..d68446f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCapabilityConfig.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidence; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidenceSource; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidenceStatus; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Constructor-bound evidence declaration for one exact named S3 operation profile. */ +public record S3ProviderCapabilityConfig( + CapabilityEvidenceStatus status, + CapabilityEvidenceSource source, + String evidenceDigest, + Instant observedAt, + Instant validUntil, + String providerAndDeploymentIdentity, + Set capabilities, + List limitations) { + + public S3ProviderCapabilityConfig { + Objects.requireNonNull(status, "status must be non-null"); + Objects.requireNonNull(source, "source must be non-null"); + capabilities = + Set.copyOf(Objects.requireNonNull(capabilities, "capabilities must be non-null")); + limitations = List.copyOf(Objects.requireNonNull(limitations, "limitations must be non-null")); + } + + CapabilityEvidence toEvidence() { + return new CapabilityEvidence( + status, + source, + evidenceDigest, + observedAt, + validUntil, + providerAndDeploymentIdentity, + limitations); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapper.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapper.java new file mode 100644 index 0000000..54964c9 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapper.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException.Failure; +import java.net.SocketTimeoutException; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.TimeoutException; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; + +/** + * Normalizes provider failures while preserving mutation uncertainty and redacting request data. + */ +public final class S3ProviderErrorMapper { + + public Mapping map(RuntimeException failure, boolean mutation) { + Objects.requireNonNull(failure, "failure must be non-null"); + if (failure instanceof AwsServiceException service) { + return mapService(service, mutation); + } + if (mutation) { + return new Mapping(Failure.INDETERMINATE, false, "S3 mutation outcome is indeterminate"); + } + Throwable cause = failure.getCause(); + if (cause instanceof SocketTimeoutException + || cause instanceof TimeoutException + || (failure instanceof SdkClientException + && failure.getClass().getSimpleName().toLowerCase(Locale.ROOT).contains("timeout"))) { + return new Mapping(Failure.DEADLINE_EXCEEDED, true, "S3 request deadline elapsed"); + } + return new Mapping(Failure.IO_FAILURE, true, "S3 request failed"); + } + + private static Mapping mapService(AwsServiceException exception, boolean mutation) { + String errorCode = + exception.awsErrorDetails() == null ? "" : exception.awsErrorDetails().errorCode(); + int status = exception.statusCode(); + Failure normalized = + switch (errorCode == null ? "" : errorCode) { + case "InvalidBucketOwner", "IncorrectExpectedBucketOwner" -> Failure.OWNER_MISMATCH; + case "PermanentRedirect", "AuthorizationHeaderMalformed" -> Failure.REGION_MISMATCH; + case "NoSuchKey", "NoSuchBucket", "NoSuchUpload" -> Failure.NOT_FOUND; + case "ConditionalRequestConflict", "OperationAborted" -> Failure.CONFLICT; + case "PreconditionFailed" -> Failure.PRECONDITION_FAILED; + case "SlowDown", "Throttling", "ThrottlingException" -> Failure.THROTTLED; + case "BadDigest", "ChecksumMismatch", "InvalidDigest" -> Failure.CONTENT_MISMATCH; + case "ObjectLockConfigurationNotFoundError", + "InvalidRequestRetention", + "AccessDeniedByObjectLock" -> + Failure.RETENTION_BLOCKED; + case "AccessDenied", "AllAccessDisabled" -> Failure.PERMISSION_DENIED; + default -> mapStatus(status, mutation); + }; + boolean authoritative = normalized != Failure.INDETERMINATE; + return new Mapping(normalized, authoritative, "S3 request failed: " + normalized.name()); + } + + private static Failure mapStatus(int status, boolean mutation) { + return switch (status) { + case 301 -> Failure.REGION_MISMATCH; + case 403 -> Failure.PERMISSION_DENIED; + case 404 -> Failure.NOT_FOUND; + case 409 -> Failure.CONFLICT; + case 412 -> Failure.PRECONDITION_FAILED; + case 429, 500, 502, 503, 504 -> Failure.THROTTLED; + default -> mutation ? Failure.INDETERMINATE : Failure.UNKNOWN; + }; + } + + /** Provider-neutral classification plus whether the observation is authoritative. */ + public record Mapping(Failure failure, boolean authoritative, String safeMessage) { + + public Mapping { + Objects.requireNonNull(failure, "failure must be non-null"); + Objects.requireNonNull(safeMessage, "safeMessage must be non-null"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifier.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifier.java new file mode 100644 index 0000000..b2ee18c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifier.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidence; +import java.time.Clock; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Validates static evidence or runs one explicitly safe startup probe for a selected provider. */ +public final class S3ProviderQualifier { + + private static final Set SAFE_ACTIONS = + Set.of( + S3CapabilityProbe.Action.BUCKET_HEAD, + S3CapabilityProbe.Action.CONDITIONAL_CREATE, + S3CapabilityProbe.Action.EXACT_HEAD, + S3CapabilityProbe.Action.EXACT_GET, + S3CapabilityProbe.Action.CONDITIONAL_CONTROL_CAS, + S3CapabilityProbe.Action.DELETE_PROBE_OBJECT); + + private final Clock clock; + private final S3CapabilityProbe probe; + + public S3ProviderQualifier(Clock clock, S3CapabilityProbe probe) { + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.probe = Objects.requireNonNull(probe, "probe must be non-null"); + } + + public S3QualificationEvidence qualify(S3ProviderBinding binding, String operationProfile) { + CapabilityEvidence evidence = binding.requireSupportedProfile(operationProfile).evidence(); + validate(binding, evidence); + return new S3QualificationEvidence(binding.providerId(), operationProfile, evidence); + } + + public S3QualificationEvidence qualifyWithProbe( + S3ProviderBinding binding, String operationProfile) { + binding.capabilityDescriptor().profile(operationProfile); + String nonce = UUID.randomUUID().toString().replace("-", ""); + S3CapabilityProbe.ProbePlan plan = + new S3CapabilityProbe.ProbePlan("_capability-probe/v1/" + nonce + "/", 4096, SAFE_ACTIONS); + CapabilityEvidence evidence = + Objects.requireNonNull(probe.probe(binding, plan), "probe evidence must be non-null"); + validate(binding, evidence); + if (!evidence.supportsAt(clock.instant())) { + throw new IllegalArgumentException("S3 startup probe did not produce current support"); + } + return new S3QualificationEvidence(binding.providerId(), operationProfile, evidence); + } + + private void validate(S3ProviderBinding binding, CapabilityEvidence evidence) { + if (!binding.providerAndDeploymentIdentity().equals(evidence.providerAndDeploymentIdentity())) { + throw new IllegalArgumentException("S3 qualification evidence identity mismatch"); + } + if (!evidence.supportsAt(clock.instant())) { + throw new IllegalArgumentException("S3 qualification evidence is expired or unsupported"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderSettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderSettings.java new file mode 100644 index 0000000..00de3d3 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderSettings.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.net.URI; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Provider-specific constructor-bound settings without literal secret values. */ +public record S3ProviderSettings( + String providerVersion, + String bucket, + String region, + String expectedOwner, + String deploymentIdentity, + URI endpointOverride, + String addressing, + boolean autoCreateBucket, + boolean publicAcl, + Credentials credentials, + Timeouts timeouts, + Pool pool, + Retry retry, + Duration shutdownGrace, + String bindingRevision, + String evidenceRevision, + Map capabilityProfiles) { + + public S3ProviderSettings { + capabilityProfiles = + capabilityProfiles == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(capabilityProfiles)); + } + + /** Credential source names only; resolved values never enter bound settings. */ + public record Credentials( + String mode, String accessKeyEnvironment, String secretKeyEnvironment) {} + + /** Every network and SDK call boundary is finite and independently named. */ + public record Timeouts( + Duration apiCall, + Duration apiAttempt, + Duration connect, + Duration tlsNegotiation, + Duration acquire, + Duration read, + Duration write) {} + + /** Explicit Netty admission bounds. */ + public record Pool(int maximumConcurrency, int maximumPendingAcquires) {} + + /** Explicit retry amplification bounds. */ + public record Retry(int maximumAttempts, Duration baseDelay, Duration maximumBackoff) {} +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderType.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderType.java new file mode 100644 index 0000000..6ee78a7 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderType.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import java.util.Arrays; + +/** Closed exact S3-family provider allowlist. */ +public enum S3ProviderType { + AWS_S3_GENERAL_PURPOSE("aws-s3-general-purpose"), + MINIO_COMMUNITY_2024_01_16("s3-compatible-minio-community-release-2024-01-16t16-07-38z"); + + private final String token; + + S3ProviderType(String token) { + this.token = token; + } + + public String token() { + return token; + } + + public static S3ProviderType parse(String token) { + return Arrays.stream(values()) + .filter(candidate -> candidate.token.equals(token)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("unsupported exact S3 provider type")); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderVersion.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderVersion.java new file mode 100644 index 0000000..6c75433 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderVersion.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +/** Closed provider-release and pinned-client combinations qualified by this implementation. */ +public enum S3ProviderVersion { + AWS_SDK_2_30_0(S3ProviderType.AWS_S3_GENERAL_PURPOSE, "aws-sdk-2.30.0"), + MINIO_2024_01_16_SDK_2_30_0( + S3ProviderType.MINIO_COMMUNITY_2024_01_16, "release-2024-01-16t16-07-38z-sdk-2.30.0"); + + private final S3ProviderType providerType; + private final String token; + + S3ProviderVersion(S3ProviderType providerType, String token) { + this.providerType = providerType; + this.token = token; + } + + public String token() { + return token; + } + + public static S3ProviderVersion require(S3ProviderType providerType, String token) { + for (S3ProviderVersion version : values()) { + if (version.providerType == providerType && version.token.equals(token)) { + return version; + } + } + throw new IllegalArgumentException("unsupported exact S3 provider version"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3QualificationEvidence.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3QualificationEvidence.java new file mode 100644 index 0000000..6fdba8a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3QualificationEvidence.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidence; +import java.util.Objects; + +/** Qualified result tied to one provider binding and exact operation profile. */ +public record S3QualificationEvidence( + String providerId, String operationProfile, CapabilityEvidence evidence) { + + public S3QualificationEvidence { + Objects.requireNonNull(providerId, "providerId must be non-null"); + Objects.requireNonNull(operationProfile, "operationProfile must be non-null"); + Objects.requireNonNull(evidence, "evidence must be non-null"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java b/src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java new file mode 100644 index 0000000..40858f5 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.objectstorage.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AwsS3DirectTransferQualificationTest { + + @Test + void protectedDirectLaneRequiresSeparateExplicitMutationAuthority() { + Map environment = System.getenv(); + + assertThat(environment.get("OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED")) + .as("protected AWS qualification authority") + .isEqualTo("true"); + assertThat(environment.get("OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED")) + .as("separate direct-transfer mutation authority") + .isEqualTo("true"); + assertThat(environment.get("OBJECT_STORAGE_AWS_BUCKET")).isNotBlank(); + assertThat(environment.get("OBJECT_STORAGE_AWS_REGION")).isNotBlank(); + assertThat(environment.get("OBJECT_STORAGE_AWS_EXPECTED_OWNER")).matches("[0-9]{12}"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java b/src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java new file mode 100644 index 0000000..ed5ca64 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.objectstorage.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AwsS3ManagedCommonSubsetQualificationTest { + + @Test + void protectedLaneRequiresExplicitAuthorityAndExactSandboxInputs() { + Map environment = System.getenv(); + + assertThat(environment.get("OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED")) + .as("protected AWS qualification authority") + .isEqualTo("true"); + assertThat(environment.get("OBJECT_STORAGE_AWS_BUCKET")) + .as("exact sandbox bucket") + .isNotBlank(); + assertThat(environment.get("OBJECT_STORAGE_AWS_REGION")) + .as("exact sandbox region") + .isNotBlank(); + assertThat(environment.get("OBJECT_STORAGE_AWS_EXPECTED_OWNER")) + .as("exact expected owner") + .matches("[0-9]{12}"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java b/src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java new file mode 100644 index 0000000..843ff6c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.objectstorage.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class MinioDirectTransferContractTest { + + @Test + void exactReleaseAdvertisesNoDirectCreateOnlyProfile() throws Exception { + try (var input = + MinioDirectTransferContractTest.class + .getClassLoader() + .getResourceAsStream("object-storage/minio-provider-evidence.json")) { + assertThat(input).as("MinIO evidence resource").isNotNull(); + String evidence = new String(input.readAllBytes(), StandardCharsets.UTF_8); + + assertThat(evidence) + .contains("\"status\": \"UNSUPPORTED\"") + .contains("\"direct-single-create-only\"") + .contains("\"direct-multipart-create-only\""); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java b/src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java new file mode 100644 index 0000000..6927141 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java @@ -0,0 +1,288 @@ +package dev.caskeleton.adapter.outbound.objectstorage.qualification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.ChecksumMode; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; + +class MinioManagedObjectContractTest { + + private static final String MINIO_IMAGE = + "minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8"; + private static final String REGION = "us-east-1"; + private static final String BUCKET = "object-storage-contract"; + private static final java.security.SecureRandom SECURE_RANDOM = new java.security.SecureRandom(); + private static GenericContainer minio; + private static S3AsyncClient client; + + @BeforeAll + static void startExactMinio() { + String accessKey = "a" + UUID.randomUUID().toString().replace("-", ""); + String secretKey = UUID.randomUUID().toString().replace("-", "") + UUID.randomUUID(); + byte[] keyBytes = new byte[32]; + SECURE_RANDOM.nextBytes(keyBytes); + String kmsKey = "contract-key:" + Base64.getEncoder().encodeToString(keyBytes); + minio = + new GenericContainer<>(DockerImageName.parse(MINIO_IMAGE)) + .withExposedPorts(9000) + .withEnv("MINIO_ROOT_USER", accessKey) + .withEnv("MINIO_ROOT_PASSWORD", secretKey) + .withEnv("MINIO_KMS_SECRET_KEY", kmsKey) + .withCommand("server", "/data"); + minio.start(); + client = + S3AsyncClient.builder() + .endpointOverride( + URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000))) + .region(Region.of(REGION)) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .httpClient( + NettyNioAsyncHttpClient.builder() + .connectionTimeout(Duration.ofSeconds(2)) + .readTimeout(Duration.ofSeconds(5)) + .writeTimeout(Duration.ofSeconds(5)) + .maxConcurrency(8) + .maxPendingConnectionAcquires(16) + .build()) + .forcePathStyle(true) + .multipartEnabled(false) + .build(); + client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build()).join(); + } + + @AfterAll + static void stopExactMinio() { + if (client != null) { + client.close(); + } + if (minio != null) { + minio.stop(); + } + } + + @Test + void checksumHeadRangeWorkButConditionalCreateIsIgnoredWhileStaleCasIsRejected() + throws Exception { + byte[] content = "qualified-minio-content".getBytes(StandardCharsets.UTF_8); + String checksum = sha256(content); + String key = "qualification/managed-single"; + var first = + client + .putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .ifNoneMatch("*") + .contentLength((long) content.length) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumSHA256(checksum) + .serverSideEncryption(ServerSideEncryption.AES256) + .metadata(java.util.Map.of("ca-logical-sha256", checksum)) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(content))) + .join(); + + assertThat(first.eTag()).isNotBlank(); + byte[] replacement = content.clone(); + replacement[0] ^= 1; + String replacementChecksum = sha256(replacement); + var overwrittenDespiteCreateOnlyCondition = + client + .putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .ifNoneMatch("*") + .contentLength((long) replacement.length) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumSHA256(replacementChecksum) + .serverSideEncryption(ServerSideEncryption.AES256) + .metadata(java.util.Map.of("ca-logical-sha256", replacementChecksum)) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(replacement))) + .join(); + assertThat(overwrittenDespiteCreateOnlyCondition.eTag()).isNotBlank(); + + var head = + client + .headObject( + HeadObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .checksumMode(ChecksumMode.ENABLED) + .build()) + .join(); + assertThat(head.contentLength()).isEqualTo(replacement.length); + assertThat(head.metadata()).containsEntry("ca-logical-sha256", replacementChecksum); + assertThat(head.serverSideEncryption()).isEqualTo(ServerSideEncryption.AES256); + + var range = + client + .getObject( + GetObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .ifMatch(head.eTag()) + .range("bytes=2-7") + .build(), + AsyncResponseTransformer.toBytes()) + .join(); + assertThat(range.asByteArray()) + .containsExactly(java.util.Arrays.copyOfRange(replacement, 2, 8)); + + byte[] control = "{\"revision\":1}".getBytes(StandardCharsets.UTF_8); + var controlCreate = + client + .putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key("qualification/control/exact") + .ifNoneMatch("*") + .contentLength((long) control.length) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(control))) + .join(); + byte[] controlTwo = "{\"revision\":2}".getBytes(StandardCharsets.UTF_8); + assertThat(controlCreate.eTag()).isNotBlank(); + var overwrittenControlCreate = + client + .putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key("qualification/control/exact") + .ifNoneMatch("*") + .contentLength((long) controlTwo.length) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(controlTwo))) + .join(); + assertThat(overwrittenControlCreate.eTag()).isNotBlank(); + assertPreconditionFailed( + () -> + client + .putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key("qualification/control/exact") + .ifMatch("\"definitely-stale-etag\"") + .contentLength((long) controlTwo.length) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(controlTwo))) + .join()); + } + + @Test + void lowLevelMultipartWorksButCompletionCreateOnlyConditionIsIgnored() throws Exception { + byte[] original = "pre-existing-target".getBytes(StandardCharsets.UTF_8); + byte[] content = "final-part-can-be-small".getBytes(StandardCharsets.UTF_8); + String checksum = sha256(content); + String key = "qualification/managed-multipart"; + client + .putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .contentLength((long) original.length) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(original))) + .join(); + var initiated = + client + .createMultipartUpload( + CreateMultipartUploadRequest.builder() + .bucket(BUCKET) + .key(key) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .serverSideEncryption(ServerSideEncryption.AES256) + .build()) + .join(); + var part = + client + .uploadPart( + UploadPartRequest.builder() + .bucket(BUCKET) + .key(key) + .uploadId(initiated.uploadId()) + .partNumber(1) + .contentLength((long) content.length) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .checksumSHA256(checksum) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(content))) + .join(); + var completed = + client + .completeMultipartUpload( + CompleteMultipartUploadRequest.builder() + .bucket(BUCKET) + .key(key) + .uploadId(initiated.uploadId()) + .multipartUpload( + CompletedMultipartUpload.builder() + .parts( + List.of( + CompletedPart.builder() + .partNumber(1) + .eTag(part.eTag()) + .checksumSHA256(part.checksumSHA256()) + .build())) + .build()) + .ifNoneMatch("*") + .build()) + .join(); + assertThat(completed.eTag()).isNotBlank(); + var overwritten = + client + .getObject( + GetObjectRequest.builder().bucket(BUCKET).key(key).build(), + AsyncResponseTransformer.toBytes()) + .join(); + assertThat(overwritten.asByteArray()).containsExactly(content); + } + + private static String sha256(byte[] value) throws Exception { + return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(value)); + } + + private static void assertPreconditionFailed( + org.assertj.core.api.ThrowableAssert.ThrowingCallable action) { + assertThatThrownBy(action) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(S3Exception.class) + .extracting(Throwable::getCause) + .satisfies(cause -> assertThat(((S3Exception) cause).statusCode()).isEqualTo(412)); + } +} diff --git a/src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java b/src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java new file mode 100644 index 0000000..7a58f87 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.objectstorage.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * Freezes the negative direct-transfer fault contract for the exact MinIO release. + * + *

No bearer or multipart mutation is attempted because qualification proved that create-only PUT + * and create-only multipart completion are ignored by this provider identity. + */ +class MinioDirectTransferFaultTest { + + @Test + void unsupportedDirectProfilesCannotEnterTheFaultMutationMatrix() throws Exception { + try (var input = + MinioDirectTransferFaultTest.class + .getClassLoader() + .getResourceAsStream("object-storage/minio-provider-evidence.json")) { + assertThat(input).as("MinIO evidence resource").isNotNull(); + String evidence = new String(input.readAllBytes(), StandardCharsets.UTF_8); + + assertThat(evidence) + .contains("\"status\": \"UNSUPPORTED\"") + .contains("\"direct-single-create-only\"") + .contains("\"direct-multipart-create-only\"") + .contains("CompleteMultipartUpload accepted If-None-Match"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java b/src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java new file mode 100644 index 0000000..1edea47 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.outbound.objectstorage.qualification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.ToxiproxyContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +class MinioManagedObjectFaultTest { + + private static final DockerImageName MINIO_IMAGE = + DockerImageName.parse( + "minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8"); + private static final DockerImageName TOXIPROXY_IMAGE = + DockerImageName.parse( + "ghcr.io/shopify/toxiproxy@sha256:" + + "9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e") + .asCompatibleSubstituteFor("shopify/toxiproxy"); + + @Test + void connectionCutProducesABoundedFailureAndRecoveryWithoutMutationReplay() { + String accessKey = "a" + UUID.randomUUID().toString().replace("-", ""); + String secretKey = UUID.randomUUID().toString().replace("-", "") + UUID.randomUUID(); + try (Network network = Network.newNetwork(); + GenericContainer minio = + new GenericContainer<>(MINIO_IMAGE) + .withNetwork(network) + .withNetworkAliases("qualified-minio") + .withExposedPorts(9000) + .withEnv("MINIO_ROOT_USER", accessKey) + .withEnv("MINIO_ROOT_PASSWORD", secretKey) + .withCommand("server", "/data"); + ToxiproxyContainer toxiproxy = + new ToxiproxyContainer(TOXIPROXY_IMAGE).withNetwork(network)) { + minio.start(); + toxiproxy.start(); + ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy(minio, 9000); + try (S3AsyncClient client = + S3AsyncClient.builder() + .endpointOverride( + URI.create( + "http://" + proxy.getContainerIpAddress() + ":" + proxy.getProxyPort())) + .region(Region.US_EAST_1) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .httpClient( + NettyNioAsyncHttpClient.builder() + .connectionTimeout(Duration.ofMillis(500)) + .readTimeout(Duration.ofSeconds(1)) + .writeTimeout(Duration.ofSeconds(1)) + .maxConcurrency(2) + .maxPendingConnectionAcquires(2) + .build()) + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .apiCallAttemptTimeout(Duration.ofSeconds(1)) + .apiCallTimeout(Duration.ofSeconds(2)) + .build()) + .forcePathStyle(true) + .multipartEnabled(false) + .build()) { + String bucket = "object-storage-fault"; + client.createBucket(CreateBucketRequest.builder().bucket(bucket).build()).join(); + byte[] body = {1, 2, 3}; + client + .putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key("fault/exact") + .ifNoneMatch("*") + .contentLength((long) body.length) + .build(), + AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(body))) + .join(); + + proxy.setConnectionCut(true); + long started = System.nanoTime(); + assertThatThrownBy( + () -> + client + .headObject( + HeadObjectRequest.builder().bucket(bucket).key("fault/exact").build()) + .join()) + .isInstanceOf(CompletionException.class); + assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(5)); + + proxy.setConnectionCut(false); + assertThat( + client + .headObject( + HeadObjectRequest.builder().bucket(bucket).key("fault/exact").build()) + .join() + .contentLength()) + .isEqualTo(body.length); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java new file mode 100644 index 0000000..38f61d1 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.storage.StoredObject; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +/** + * Characterizes the pre-migration blob API. These assertions preserve evidence of the unsafe legacy + * boundary; they are not requirements for the replacement capability. + */ +class LegacyObjectStorageBehaviorTest { + + @TempDir Path tempDir; + + @Test + void callerKeyIsOverwriteableAndGetMaterializesTheWholeByteArray() throws Exception { + ObjectStoragePort storage = new FilesystemObjectStorageAdapter(tempDir.toString()); + + storage.put("posters/legacy/image", bytes("first"), "image/png"); + storage.put("posters/legacy/image", bytes("second"), "image/png"); + + assertThat(storage.get("posters/legacy/image")).contains(bytes("second")); + Method get = ObjectStoragePort.class.getMethod("get", String.class); + assertThat(get.getGenericReturnType().getTypeName()).isEqualTo("java.util.Optional"); + } + + @Test + void receiptsExposeBackendSpecificFileAndS3Locators() { + ObjectStoragePort filesystem = new FilesystemObjectStorageAdapter(tempDir.toString()); + StoredObject file = filesystem.put("legacy/file", new byte[] {1}, "application/octet-stream"); + + S3Client client = mock(S3Client.class); + ObjectStoragePort s3 = new S3ObjectStorageAdapter(client, "legacy-bucket"); + StoredObject object = s3.put("legacy/object", new byte[] {2}, "application/octet-stream"); + + assertThat(file.location().getScheme()).isEqualTo("file"); + assertThat(object.location().toString()).isEqualTo("s3://legacy-bucket/legacy/object"); + org.mockito.Mockito.verify(client) + .putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void missingGetUsesOptionalRatherThanAStreamOrDescriptor() { + ObjectStoragePort storage = new FilesystemObjectStorageAdapter(tempDir.toString()); + + Optional result = storage.get("missing"); + + assertThat(result).isEmpty(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java new file mode 100644 index 0000000..2d2da62 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; + +/** Records the legacy configuration side effects that the canonical capability must remove. */ +class LegacyObjectStorageConfigTest { + + @TempDir Path tempDir; + + @Test + void missingBackendSelectsFilesystemAndCreatesItsDirectoryDuringContextStartup() { + Path storageRoot = tempDir.resolve("created-before-first-put"); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageConfig.class) + .withPropertyValues("ca-skeleton.objectstorage.base-path=" + storageRoot) + .run( + context -> { + assertThat(context).hasSingleBean(ObjectStoragePort.class); + assertThat(context.getBean(ObjectStoragePort.class)) + .isInstanceOf(FilesystemObjectStorageAdapter.class); + assertThat(Files.isDirectory(storageRoot)).isTrue(); + }); + } + + @Test + void autoCreateBucketTrueProvisionsTheBucketWhileTheS3BeanIsCreated() { + S3Client s3 = mock(S3Client.class); + when(s3.headBucket(any(HeadBucketRequest.class))) + .thenThrow(NoSuchBucketException.builder().message("missing").build()); + ObjectStorageSettings settings = new ObjectStorageSettings(); + settings.setBucket("legacy-auto-created"); + settings.setAutoCreateBucket(true); + + ObjectStoragePort result = new ObjectStorageConfig().s3ObjectStoragePort(s3, settings); + + assertThat(result).isInstanceOf(S3ObjectStorageAdapter.class); + verify(s3).headBucket(any(HeadBucketRequest.class)); + verify(s3).createBucket(any(CreateBucketRequest.class)); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java new file mode 100644 index 0000000..605fc6f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.outbound.objectstorage.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +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.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 java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.List; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import org.junit.jupiter.api.Test; + +class ObjectNamespaceCodecTest { + + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final ObjectId OBJECT = ObjectId.parse("0123456789abcdefghjkmnpqrs"); + + @Test + void dataAndControlKeysHaveFrozenCanonicalGrammars() { + ObjectOperationKey operation = + new ObjectOperationKey( + dev.caskeleton.application.objectstorage.identity.ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("2026a"), + ObjectOperationId.of("upload-42")); + ObjectReference reference = ObjectReferenceCodec.published(ROUTE, OBJECT); + DirectTransferSessionId session = ObjectHandleCodec.multipart(ROUTE, OBJECT); + + assertThat(ObjectDataKeyCodec.encode(ROUTE, OBJECT, ObjectDataKeyCodec.Generation.of(7))) + .isEqualTo("data/v1/0123456789ab/6a/0123456789abcdefghjkmnpqrs/0000000000000007"); + assertThat(ObjectControlKeyCodec.operation(ROUTE, operation)) + .isEqualTo("control/v1/operations/0123456789ab/2026a/bd/upload-42"); + assertThat(ObjectControlKeyCodec.reference(ROUTE, reference)) + .isEqualTo( + "control/v1/references/0123456789ab/6a/" + + "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + assertThat( + ObjectControlKeyCodec.manifest( + ROUTE, OBJECT, ObjectControlKeyCodec.ManifestRevision.of(9))) + .isEqualTo( + "control/v1/manifests/0123456789ab/6a/" + + "0123456789abcdefghjkmnpqrs/0000000000000009"); + assertThat(ObjectControlKeyCodec.multipart(ROUTE, session)) + .startsWith("control/v1/multipart/0123456789ab/6a/osm1."); + assertThat( + ObjectControlKeyCodec.reaperCursor( + ROUTE, ObjectControlKeyCodec.ReaperJobId.of("retired"))) + .isEqualTo("control/v1/reaper-cursors/0123456789ab/retired"); + assertThat(ObjectControlKeyCodec.operationEpoch(ROUTE, ObjectOperationEpoch.of("2026a"))) + .isEqualTo("control/v1/operation-epochs/0123456789ab/2026a"); + } + + @Test + void referenceAndHandleFamiliesRemainSeparated() { + ObjectReference reference = ObjectReferenceCodec.published(ROUTE, OBJECT); + ObjectStageHandle stage = ObjectHandleCodec.stage(ROUTE, OBJECT); + DirectTransferSessionId upload = ObjectHandleCodec.directUpload(ROUTE, OBJECT); + DirectTransferSessionId multipart = ObjectHandleCodec.multipart(ROUTE, OBJECT); + + assertThat(reference.canonicalText()) + .isEqualTo("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + assertThat(stage.canonicalText()) + .isEqualTo("osh1.0123456789ab.0123456789abcdefghjkmnpqrs.f3226a9833"); + assertThat(upload.canonicalText()) + .isEqualTo("osu1.0123456789ab.0123456789abcdefghjkmnpqrs.55459818cc"); + assertThat(multipart.canonicalText()) + .isEqualTo("osm1.0123456789ab.0123456789abcdefghjkmnpqrs.13a5a3b163"); + assertThatIllegalArgumentException() + .isThrownBy(() -> ObjectReference.parse(stage.canonicalText())); + } + + @Test + void dataKeyApiHasNoRawNameStringParameter() { + List parameterTypes = + java.util.Arrays.stream(ObjectDataKeyCodec.class.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers())) + .map(Method::getGenericParameterTypes) + .flatMap(java.util.Arrays::stream) + .toList(); + + assertThat(parameterTypes).doesNotContain(String.class); + } + + @Property + void routeParserRejectsArbitraryNonCanonicalText(@ForAll String candidate) { + if (!candidate.matches("[0-9abcdefghjkmnpqrstvwxyz]{12}")) { + assertThatIllegalArgumentException().isThrownBy(() -> ObjectRouteToken.parse(candidate)); + } + } + + @Test + void namespaceRejectsAliasesAndTraversalInputs() { + for (String invalid : + List.of( + "../escape", + ".", + "..", + "a/b", + "a%2fb", + "é", + "e\u0301", + "UPPER", + "control\u0000record")) { + assertThatIllegalArgumentException() + .as(invalid) + .isThrownBy(() -> ObjectControlKeyCodec.ReaperJobId.of(invalid)); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodecTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodecTest.java new file mode 100644 index 0000000..4061183 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodecTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.outbound.objectstorage.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectRequestFingerprintCodec.ContentMode; +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectRequestFingerprintCodec.OperationKind; +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectRequestFingerprintCodec.RequestIntent; +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.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +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.time.Duration; +import java.util.EnumSet; +import org.junit.jupiter.api.Test; + +class ObjectRequestFingerprintCodecTest { + + @Test + void canonicalIntentHasAFrozenGoldenVector() { + RequestIntent intent = exactIntent(); + + assertThat( + new String( + ObjectRequestFingerprintCodec.canonicalBytes(intent), StandardCharsets.UTF_8)) + .isEqualTo( + "object-request-fingerprint-v1\n" + + "destination=6:poster\n" + + "operation=7:PUBLISH\n" + + "media-type=10:image/jpeg\n" + + "size=3:123\n" + + "content-mode=16:EXACT_R2_CONTENT\n" + + "content-digest=52:SHA_256:" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" + + "publication=18:INTEGRITY_VERIFIED\n" + + "retention=4:NONE\n" + + "encryption=16:PROVIDER_MANAGED\n" + + "direct-parameter=-\n" + + "prior-reference=-\n" + + "prior-version=-\n"); + assertThat(ObjectRequestFingerprintCodec.sha256Hex(intent)) + .isEqualTo("94dd2bc8b41bd4b5f3e2577a08cf29c27764dea63a81be65485473c7e11e4929"); + } + + @Test + void sameIntentIsStableAndEverySemanticChangeChangesTheFingerprint() { + RequestIntent baseline = exactIntent(); + + assertThat(ObjectRequestFingerprintCodec.sha256Hex(exactIntent())) + .isEqualTo(ObjectRequestFingerprintCodec.sha256Hex(baseline)); + assertThat( + ObjectRequestFingerprintCodec.sha256Hex( + new RequestIntent( + 1, + ObjectDestinationId.of("poster"), + OperationKind.PUBLISH, + ObjectMediaType.of("image/png"), + 123L, + ContentMode.EXACT_R2_CONTENT, + baseline.contentIdentity(), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + null, + null, + null))) + .isNotEqualTo(ObjectRequestFingerprintCodec.sha256Hex(baseline)); + } + + @Test + void absenceEmptyOverflowAndR1MarkerAreExplicit() { + RequestIntent r1 = + new RequestIntent( + 1, + ObjectDestinationId.of("poster"), + OperationKind.PUBLISH, + ObjectMediaType.of("image/jpeg"), + Long.MAX_VALUE, + ContentMode.R1_UNVERIFIED_CONTENT, + null, + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + "", + null, + null); + + String canonical = + new String(ObjectRequestFingerprintCodec.canonicalBytes(r1), StandardCharsets.UTF_8); + assertThat(canonical) + .contains("size=19:9223372036854775807") + .contains("content-mode=21:R1_UNVERIFIED_CONTENT") + .contains("content-digest=-") + .contains("direct-parameter=0:"); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + new RequestIntent( + 2, + r1.destination(), + r1.operationKind(), + r1.mediaType(), + r1.expectedSize(), + r1.contentMode(), + null, + r1.publicationRequirement(), + r1.retentionRequirement(), + r1.encryptionRequirement(), + null, + null, + null)); + } + + @Test + void policySnapshotCodecIsCanonicalAndContainsNoCredentialSurface() { + ObjectPolicySnapshot snapshot = + new ObjectPolicySnapshot( + ObjectBindingRevision.of("binding-v1"), + ObjectPolicyRevision.of("policy-v3"), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of( + ObjectCapabilityRequirement.IMMUTABLE_CREATE, + ObjectCapabilityRequirement.SHA_256_VERIFICATION), + 10_485_760L, + 65_536, + Duration.ofDays(7)); + + String canonical = + new String(ObjectPolicySnapshotCodec.encode(snapshot), StandardCharsets.UTF_8); + assertThat(canonical) + .isEqualTo( + "object-policy-snapshot-v1\n" + + "binding=10:binding-v1\n" + + "policy=9:policy-v3\n" + + "publication=18:INTEGRITY_VERIFIED\n" + + "retention=4:NONE\n" + + "encryption=16:PROVIDER_MANAGED\n" + + "capabilities=37:IMMUTABLE_CREATE,SHA_256_VERIFICATION\n" + + "max-object-bytes=8:10485760\n" + + "chunk-bytes=5:65536\n" + + "minimum-replay-seconds=6:604800\n") + .doesNotContain("secret", "credential", "access-key", "endpoint"); + } + + private static RequestIntent exactIntent() { + return new RequestIntent( + 1, + ObjectDestinationId.of("poster"), + OperationKind.PUBLISH, + ObjectMediaType.of("image/jpeg"), + 123L, + ContentMode.EXACT_R2_CONTENT, + new ObjectContentIdentity( + 123L, + ObjectDigest.of( + dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + null, + null, + null); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompilerTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompilerTest.java new file mode 100644 index 0000000..8474c92 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompilerTest.java @@ -0,0 +1,167 @@ +package dev.caskeleton.adapter.outbound.objectstorage.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import dev.caskeleton.adapter.outbound.objectstorage.filesystem.FilesystemLocalDevProviderContribution; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.util.unit.DataSize; + +class ObjectStorageBindingCompilerTest { + + @TempDir Path root; + + @Test + void canonicalSettingsAreConstructorBoundImmutableRecords() { + assertThat(ObjectStorageCapabilitySettings.class.isRecord()).isTrue(); + assertThat(ObjectStorageCapabilitySettings.Legacy.class.isRecord()).isTrue(); + assertThat(ObjectStorageProviderSettings.class.isRecord()).isTrue(); + assertThat(ObjectStorageDestinationSettings.class.isRecord()).isTrue(); + } + + @Test + void disabledCapabilityCompilesToNoProviderOrDestination() { + ObjectStorageCapabilitySettings settings = + new ObjectStorageCapabilitySettings(false, null, null, null, null); + + var compiled = compiler(Set.of("local")).compile(settings); + + assertThat(compiled.providers()).isEmpty(); + assertThat(compiled.destinations()).isEmpty(); + } + + @Test + void validLocalProfileCompilesOneExactRouteAndFrozenPolicy() { + ObjectStorageCapabilitySettings settings = validSettings(root); + + var compiled = compiler(Set.of("local")).compile(settings); + + assertThat(compiled.defaultDestination().value()).isEqualTo("poster"); + assertThat(compiled.providers()).containsOnlyKeys("local"); + assertThat(compiled.destinations()).containsOnlyKeys("poster"); + CompiledObjectStorageDestination destination = compiled.destinations().get("poster"); + assertThat(destination.route().canonicalText()).isEqualTo("0123456789ab"); + assertThat(destination.policySnapshot().maximumObjectBytes()).isEqualTo(1024); + assertThat(destination.policySnapshot().chunkBytes()).isEqualTo(64); + } + + @Test + void enabledWithoutCompleteExplicitBindingFailsClosed() { + ObjectStorageCapabilitySettings settings = + new ObjectStorageCapabilitySettings(true, null, null, null, null); + + assertThatIllegalArgumentException() + .isThrownBy(() -> compiler(Set.of("local")).compile(settings)); + } + + @Test + void unknownProviderDuplicateRouteScanWithoutSeamAndProductionLocalFailClosed() { + ObjectStorageCapabilitySettings valid = validSettings(root); + ObjectStorageCapabilitySettings unknown = + withDestinations(valid, Map.of("poster", destination("missing", "0123456789ab", null))); + assertThatIllegalArgumentException() + .isThrownBy(() -> compiler(Set.of("local")).compile(unknown)); + + Map duplicateDestinations = new LinkedHashMap<>(); + duplicateDestinations.put("poster", destination("local", "0123456789ab", null)); + duplicateDestinations.put("archive", destination("local", "0123456789ab", null)); + ObjectStorageCapabilitySettings duplicate = withDestinations(valid, duplicateDestinations); + assertThatIllegalArgumentException() + .isThrownBy(() -> compiler(Set.of("local")).compile(duplicate)); + + ObjectStorageCapabilitySettings scan = + withDestinations( + valid, + Map.of( + "poster", + destination("local", "0123456789ab", ObjectPublicationRequirement.SCAN_CLEAN))); + assertThatIllegalArgumentException().isThrownBy(() -> compiler(Set.of("local")).compile(scan)); + + assertThatIllegalArgumentException() + .isThrownBy(() -> compiler(Set.of("prod")).compile(validSettings(root))); + } + + @Test + void invalidBoundsAndCanonicalLegacyNamespaceOverlapFailClosed() { + ObjectStorageCapabilitySettings valid = validSettings(root); + ObjectStorageProviderSettings invalidProvider = + new ObjectStorageProviderSettings( + "filesystem-local-dev", + "jdk-21", + root.resolve("object-storage-v1").toString(), + DataSize.ofBytes(1024), + DataSize.ofBytes(2048), + null); + ObjectStorageCapabilitySettings invalid = + new ObjectStorageCapabilitySettings( + true, "poster", Map.of("local", invalidProvider), valid.destinations(), valid.legacy()); + assertThatIllegalArgumentException() + .isThrownBy(() -> compiler(Set.of("local")).compile(invalid)); + + ObjectStorageCapabilitySettings overlap = + new ObjectStorageCapabilitySettings( + true, + "poster", + valid.providers(), + valid.destinations(), + new ObjectStorageCapabilitySettings.Legacy( + true, "filesystem", root.toString(), null, null, null, null, null, true, false)); + assertThatIllegalArgumentException() + .isThrownBy(() -> compiler(Set.of("local")).compile(overlap)); + } + + static ObjectStorageCapabilitySettings validSettings(Path root) { + ObjectStorageProviderSettings provider = + new ObjectStorageProviderSettings( + "filesystem-local-dev", + "jdk-21", + root.resolve("object-storage-v1").toString(), + DataSize.ofBytes(1024), + DataSize.ofBytes(64), + null); + return new ObjectStorageCapabilitySettings( + true, + "poster", + Map.of("local", provider), + Map.of("poster", destination("local", "0123456789ab", null)), + null); + } + + private static ObjectStorageCapabilitySettings withDestinations( + ObjectStorageCapabilitySettings source, + Map destinations) { + return new ObjectStorageCapabilitySettings( + source.enabled(), + source.defaultDestination(), + source.providers(), + destinations, + source.legacy()); + } + + private static ObjectStorageDestinationSettings destination( + String providerRef, String route, ObjectPublicationRequirement publicationRequirement) { + return new ObjectStorageDestinationSettings( + providerRef, + route, + "binding-v1", + "policy-v1", + publicationRequirement, + null, + null, + null, + null, + null); + } + + private static ObjectStorageBindingCompiler compiler(Set profiles) { + return new ObjectStorageBindingCompiler( + List.of(new FilesystemLocalDevProviderContribution()), profiles, false); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java new file mode 100644 index 0000000..b00dbe7 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java @@ -0,0 +1,477 @@ +package dev.caskeleton.adapter.outbound.objectstorage.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.model.ObjectCapabilityRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +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.port.ManagedObjectPublicationPort; +import dev.caskeleton.application.objectstorage.port.ObjectPurgeMaintenancePort; +import dev.caskeleton.application.objectstorage.port.ObjectScanMaintenancePort; +import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest; +import dev.caskeleton.application.outbound.CallBudget; +import java.nio.file.Path; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +@SuppressWarnings("removal") +class ObjectStorageCapabilityConfigTest { + + @TempDir Path root; + + @Test + void absentOrDisabledCreatesNoSemanticPortProviderOrDirectory() { + Path providerRoot = root.resolve("absent"); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues(providerProperties(providerRoot, false)) + .run( + context -> { + assertThat(context).doesNotHaveBean(ManagedObjectPublicationPort.class); + assertThat(context).doesNotHaveBean(ObjectStorageProvider.class); + assertThat(providerRoot).doesNotExist(); + }); + } + + @Test + void explicitLocalBindingCreatesOnlyNormalSemanticPorts() { + Path providerRoot = root.resolve("selected"); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues(providerProperties(providerRoot, true)) + .run( + context -> { + assertThat(context).hasSingleBean(RoutingObjectMutationAdapter.class); + assertThat(context).hasSingleBean(RoutingObjectReadAdapter.class); + assertThat(context).hasSingleBean(ManagedObjectPublicationPort.class); + assertThat(context).doesNotHaveBean(ObjectPurgeMaintenancePort.class); + assertThat(context).doesNotHaveBean(ObjectScanMaintenancePort.class); + assertThat(providerRoot).isDirectory(); + }); + } + + @Test + void unknownDestinationFailsBeforeProducerInvocation() { + Path providerRoot = root.resolve("unknown"); + AtomicBoolean invoked = new AtomicBoolean(); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues(providerProperties(providerRoot, true)) + .run( + context -> { + ManagedObjectPublicationPort port = + context.getBean(ManagedObjectPublicationPort.class); + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> + port.publish( + publishRequest("missing"), + (productionContext, sink) -> invoked.set(true)))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(invoked).isFalse(); + }); + } + + @Test + void contributionIsConstructedOnceOnlyWhenSelectedAndClosedOnce() { + AtomicInteger constructed = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + CountingContribution contribution = new CountingContribution(constructed, closed, false); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withBean(ObjectStorageProviderContribution.class, () -> contribution) + .withPropertyValues(countingProviderProperties(root.resolve("counting"), true)) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(constructed).hasValue(1); + }); + + assertThat(closed).hasValue(1); + } + + @Test + void unselectedContributionIsDescribedButNeverConstructedOrClosed() { + AtomicInteger constructed = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + CountingContribution contribution = new CountingContribution(constructed, closed, false); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withBean(ObjectStorageProviderContribution.class, () -> contribution) + .withPropertyValues( + combined( + providerProperties(root.resolve("selected-local"), true), + countingProviderDefinition(root.resolve("unselected-counting")))) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(constructed).hasValue(0); + }); + + assertThat(closed).hasValue(0); + assertThat(root.resolve("unselected-counting")).doesNotExist(); + } + + @Test + void disabledContributionIsNeverDescribedConstructedOrClosed() { + AtomicInteger described = new AtomicInteger(); + AtomicInteger constructed = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + CountingContribution contribution = + new CountingContribution(described, constructed, closed, false); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withBean(ObjectStorageProviderContribution.class, () -> contribution) + .withPropertyValues(countingProviderProperties(root.resolve("disabled-counting"), false)) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(described).hasValue(0); + assertThat(constructed).hasValue(0); + }); + + assertThat(closed).hasValue(0); + assertThat(root.resolve("disabled-counting")).doesNotExist(); + } + + @Test + void invalidBindingFailsBeforeSelectedContributionConstruction() { + AtomicInteger constructed = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + CountingContribution contribution = new CountingContribution(constructed, closed, false); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withBean(ObjectStorageProviderContribution.class, () -> contribution) + .withPropertyValues( + combined( + countingProviderProperties(root.resolve("invalid"), true), + new String[] {"app.object-storage.destinations.poster.route-token=invalid"})) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(constructed).hasValue(0); + }); + + assertThat(closed).hasValue(0); + } + + @Test + void selectedContributionConstructionFailureCleansItsPartialAllocation() { + AtomicInteger constructed = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + CountingContribution contribution = new CountingContribution(constructed, closed, true); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withBean(ObjectStorageProviderContribution.class, () -> contribution) + .withPropertyValues(countingProviderProperties(root.resolve("failing"), true)) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(constructed).hasValue(1); + }); + + assertThat(closed).hasValue(1); + } + + @Test + void canonicalAndLegacyAliasTogetherFailWithoutEchoingValues() { + new ApplicationContextRunner() + .withUserConfiguration( + ObjectStorageCapabilityConfig.class, + dev.caskeleton.adapter.outbound.objectstorage.ObjectStorageConfig.class) + .withPropertyValues( + "app.object-storage.enabled=false", + "ca-skeleton.objectstorage.base-path=/do-not-echo-private-path") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure().toString()) + .doesNotContain("do-not-echo-private-path"); + }); + } + + @Test + void canonicalLegacyOnlyIsExplicitAndPreservesExistingData() throws Exception { + Path legacyRoot = root.resolve("legacy-only"); + Path existing = legacyRoot.resolve("posters/existing.bin"); + java.nio.file.Files.createDirectories(existing.getParent()); + java.nio.file.Files.write(existing, new byte[] {7, 8, 9}); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues( + "app.object-storage.enabled=false", + "app.object-storage.legacy.enabled=true", + "app.object-storage.legacy.backend=filesystem", + "app.object-storage.legacy.base-path=" + legacyRoot) + .run( + context -> { + assertThat(context) + .hasSingleBean(dev.caskeleton.application.storage.ObjectStoragePort.class); + assertThat(context).doesNotHaveBean(ManagedObjectPublicationPort.class); + assertThat( + context + .getBean(dev.caskeleton.application.storage.ObjectStoragePort.class) + .get("posters/existing.bin")) + .contains(new byte[] {7, 8, 9}); + }); + + assertThat(existing).exists(); + } + + @Test + void namespaceSeparatedCanonicalCapabilityAndLegacyCanRunTogether() { + Path capabilityRoot = root.resolve("dual-v1"); + Path legacyRoot = root.resolve("dual-legacy"); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues( + combined( + providerProperties(capabilityRoot, true), + new String[] { + "app.object-storage.legacy.enabled=true", + "app.object-storage.legacy.backend=filesystem", + "app.object-storage.legacy.base-path=" + legacyRoot + })) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ManagedObjectPublicationPort.class); + assertThat(context) + .hasSingleBean(dev.caskeleton.application.storage.ObjectStoragePort.class); + assertThat(capabilityRoot).isDirectory(); + assertThat(legacyRoot).isDirectory(); + }); + } + + private static String[] providerProperties(Path root, boolean enabled) { + return new String[] { + "app.object-storage.enabled=" + enabled, + "app.object-storage.default-destination=poster", + "app.object-storage.providers.local.type=filesystem-local-dev", + "app.object-storage.providers.local.version=jdk-21", + "app.object-storage.providers.local.root=" + root, + "app.object-storage.providers.local.maximum-object-bytes=1KB", + "app.object-storage.providers.local.chunk-bytes=64B", + "app.object-storage.destinations.poster.provider-ref=local", + "app.object-storage.destinations.poster.route-token=0123456789ab", + "app.object-storage.destinations.poster.binding-revision=binding-v1", + "app.object-storage.destinations.poster.policy-revision=policy-v1" + }; + } + + private static String[] countingProviderProperties(Path root, boolean enabled) { + return new String[] { + "app.object-storage.enabled=" + enabled, + "app.object-storage.default-destination=poster", + "app.object-storage.providers.counting.type=counting", + "app.object-storage.providers.counting.version=v1", + "app.object-storage.providers.counting.root=" + root, + "app.object-storage.providers.counting.maximum-object-bytes=1KB", + "app.object-storage.providers.counting.chunk-bytes=64B", + "app.object-storage.destinations.poster.provider-ref=counting", + "app.object-storage.destinations.poster.route-token=0123456789ab", + "app.object-storage.destinations.poster.binding-revision=binding-v1", + "app.object-storage.destinations.poster.policy-revision=policy-v1" + }; + } + + private static String[] countingProviderDefinition(Path root) { + return new String[] { + "app.object-storage.providers.counting.type=counting", + "app.object-storage.providers.counting.version=v1", + "app.object-storage.providers.counting.root=" + root, + "app.object-storage.providers.counting.maximum-object-bytes=1KB", + "app.object-storage.providers.counting.chunk-bytes=64B" + }; + } + + private static String[] combined(String[] first, String[] second) { + String[] combined = java.util.Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, combined, first.length, second.length); + return combined; + } + + private static ObjectPublishRequest publishRequest(String destination) { + byte[] digest = new byte[32]; + return new ObjectPublishRequest( + new ObjectOperationKey( + ObjectDestinationId.of(destination), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("operation-a")), + ObjectMediaType.of("application/octet-stream"), + new ObjectContentIdentity( + 0, + ObjectDigest.of( + dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256, + java.util.Base64.getEncoder().encodeToString(digest))), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of(ObjectCapabilityRequirement.IMMUTABLE_CREATE), + CallBudget.fromNow(Duration.ofMinutes(1)), + CancellationView.never()); + } + + private static final class CountingContribution implements ObjectStorageProviderContribution { + + private final AtomicInteger constructed; + private final AtomicInteger closed; + private final AtomicInteger described; + private final boolean fail; + + private CountingContribution(AtomicInteger constructed, AtomicInteger closed, boolean fail) { + this(new AtomicInteger(), constructed, closed, fail); + } + + private CountingContribution( + AtomicInteger described, AtomicInteger constructed, AtomicInteger closed, boolean fail) { + this.described = described; + this.constructed = constructed; + this.closed = closed; + this.fail = fail; + } + + @Override + public String providerType() { + return "counting"; + } + + @Override + public ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings) { + described.incrementAndGet(); + EnumMapSupport support = new EnumMapSupport(); + return new ObjectStorageProviderDescriptor( + "counting", + settings.version(), + settings.maximumObjectBytes().toBytes(), + Math.toIntExact(settings.chunkBytes().toBytes()), + support.normalCapabilitySupport()); + } + + @Override + public SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider) { + constructed.incrementAndGet(); + if (fail) { + closed.incrementAndGet(); + throw new IllegalStateException("selected construction failed"); + } + return new SelectedObjectStorageProviderFactory( + new NoopProvider(provider.descriptor()), new NoopControlStore(), closed::incrementAndGet); + } + } + + private record EnumMapSupport() { + + Map normalCapabilitySupport() { + java.util.EnumMap result = + new java.util.EnumMap<>(ObjectStorageProvider.Capability.class); + for (ObjectStorageProvider.Capability capability : + ObjectStorageProvider.Capability.values()) { + result.put(capability, ObjectStorageProvider.Support.UNSUPPORTED); + } + result.put( + ObjectStorageProvider.Capability.IMMUTABLE_CREATE, + ObjectStorageProvider.Support.SUPPORTED); + result.put( + ObjectStorageProvider.Capability.EXACT_INSPECT, ObjectStorageProvider.Support.SUPPORTED); + result.put( + ObjectStorageProvider.Capability.FULL_READ, ObjectStorageProvider.Support.SUPPORTED); + result.put( + ObjectStorageProvider.Capability.RANGE_READ, ObjectStorageProvider.Support.SUPPORTED); + result.put( + ObjectStorageProvider.Capability.CONDITIONAL_RETIREMENT, + ObjectStorageProvider.Support.SUPPORTED); + result.put( + ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION, + ObjectStorageProvider.Support.SUPPORTED); + return result; + } + } + + private record NoopProvider(ObjectStorageProviderDescriptor descriptor) + implements ObjectStorageProvider { + + @Override + public StoredData create( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation + operation, + dev.caskeleton.application.objectstorage.content.ObjectContentProducer producer) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional inspect(String canonicalDataKey) { + return Optional.empty(); + } + + @Override + public void transfer( + ReadOperation operation, + dev.caskeleton.application.objectstorage.content.ObjectContentConsumer consumer) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retire(RetireOperation operation) { + return false; + } + + @Override + public Resolution resolveCreate( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation + operation) { + return Resolution.NOT_APPLIED; + } + } + + private static final class NoopControlStore implements ObjectControlStore { + + @Override + public Optional read(String key) { + return Optional.empty(); + } + + @Override + public StoredControlRecord create( + String key, + dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlRecord record) { + throw new UnsupportedOperationException(); + } + + @Override + public StoredControlRecord compareAndSet( + String key, + dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation mutation) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfigTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfigTest.java new file mode 100644 index 0000000..674f163 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfigTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.objectstorage.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyAdoptionApprovalReplayStore; +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.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +@SuppressWarnings("removal") +class ObjectStorageLegacyMigrationConfigTest { + + @Test + void normalDisabledContextExposesNoRawLocatorPort() { + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageLegacyMigrationConfig.class) + .withPropertyValues("app.object-storage.legacy-adoption.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(LegacyObjectAdoptionPort.class)); + } + + @Test + void explicitReportOnlyMaintenanceCompositionCreatesOnePort() { + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageLegacyMigrationConfig.class) + .withBean(LegacyObjectInspector.class, () -> mock(LegacyObjectInspector.class)) + .withBean( + ManagedObjectPublicationPort.class, () -> mock(ManagedObjectPublicationPort.class)) + .withBean( + LegacyAdoptionApprovalReplayStore.class, + () -> mock(LegacyAdoptionApprovalReplayStore.class)) + .withBean(Clock.class, Clock::systemUTC) + .withPropertyValues( + "app.object-storage.legacy-adoption.enabled=true", + "app.object-storage.legacy-adoption.mode=REPORT_ONLY", + "app.object-storage.legacy-adoption.report-path=build/adoption-report.json", + "app.object-storage.legacy-adoption.batch-size=10") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(LegacyObjectAdoptionPort.class); + }); + } + + @Test + void applyWithoutReviewedManifestFailsClosed() { + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageLegacyMigrationConfig.class) + .withBean(LegacyObjectInspector.class, () -> mock(LegacyObjectInspector.class)) + .withBean( + ManagedObjectPublicationPort.class, () -> mock(ManagedObjectPublicationPort.class)) + .withBean( + LegacyAdoptionApprovalReplayStore.class, + () -> mock(LegacyAdoptionApprovalReplayStore.class)) + .withBean(Clock.class, Clock::systemUTC) + .withPropertyValues( + "app.object-storage.legacy-adoption.enabled=true", + "app.object-storage.legacy-adoption.mode=APPLY", + "app.object-storage.legacy-adoption.report-path=build/adoption-report.json") + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java new file mode 100644 index 0000000..b455e76 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.objectstorage.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.objectstorage.port.ObjectScanMaintenancePort; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class ObjectStorageScanMaintenanceConfigTest { + + @TempDir Path root; + + @Test + void normalCompositionDoesNotExposePrivilegedScanPort() { + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues(properties(false)) + .run(context -> assertThat(context).doesNotHaveBean(ObjectScanMaintenancePort.class)); + } + + @Test + void explicitScannerWorkflowExposesOnlyItsNarrowPort() { + new ApplicationContextRunner() + .withUserConfiguration( + ObjectStorageCapabilityConfig.class, ObjectStorageScanMaintenanceConfig.class) + .withPropertyValues(properties(true)) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ObjectScanMaintenancePort.class); + assertThat(context.getBean(ObjectScanMaintenancePort.class)) + .isInstanceOf(RoutingObjectScanMaintenanceAdapter.class); + }); + } + + private String[] properties(boolean scanEnabled) { + return new String[] { + "app.object-storage.enabled=true", + "app.object-storage.scan-maintenance.enabled=" + scanEnabled, + "app.object-storage.default-destination=poster", + "app.object-storage.providers.local.type=filesystem-local-dev", + "app.object-storage.providers.local.version=jdk-21", + "app.object-storage.providers.local.root=" + root.resolve(scanEnabled ? "scan" : "normal"), + "app.object-storage.providers.local.maximum-object-bytes=1KB", + "app.object-storage.providers.local.chunk-bytes=64B", + "app.object-storage.destinations.poster.provider-ref=local", + "app.object-storage.destinations.poster.route-token=0123456789ab", + "app.object-storage.destinations.poster.binding-revision=binding-v1", + "app.object-storage.destinations.poster.policy-revision=policy-v1", + "app.object-storage.destinations.poster.publication-requirement=" + + (scanEnabled ? "SCAN_CLEAN" : "INTEGRITY_VERIFIED") + }; + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectStorageAdapterTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectStorageAdapterTest.java new file mode 100644 index 0000000..5db09bc --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectStorageAdapterTest.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.objectstorage.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.model.ObjectCapabilityRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectDigestVerification; +import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement; +import dev.caskeleton.application.objectstorage.port.ManagedObjectPublicationPort; +import dev.caskeleton.application.objectstorage.port.ObjectInspectionPort; +import dev.caskeleton.application.objectstorage.port.ObjectTransferPort; +import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest; +import dev.caskeleton.application.objectstorage.request.ObjectReadRequest; +import dev.caskeleton.application.outbound.CallBudget; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class RoutingObjectStorageAdapterTest { + + @TempDir Path root; + + @Test + void publishesReadsAndReplaysWithoutInvokingTheProducerAgain() { + byte[] content = new byte[200]; + for (int index = 0; index < content.length; index++) { + content[index] = (byte) (index * 17); + } + ObjectPublishRequest request = publishRequest(content); + AtomicBoolean replayProducerInvoked = new AtomicBoolean(); + + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues(providerProperties(root.resolve("runtime"))) + .run( + context -> { + assertThat(context).hasNotFailed(); + ManagedObjectPublicationPort publication = + context.getBean(ManagedObjectPublicationPort.class); + var first = + publication.publish( + request, + (productionContext, sink) -> { + for (int offset = 0; offset < content.length; offset += 64) { + int length = Math.min(64, content.length - offset); + sink.write(content, offset, length); + } + }); + var replay = + publication.publish( + request, (productionContext, sink) -> replayProducerInvoked.set(true)); + + assertThat(first.outcome()).isEqualTo(ObjectMutationOutcome.APPLIED); + assertThat(replay.outcome()).isEqualTo(ObjectMutationOutcome.REPLAYED); + assertThat(replay.reference()).isEqualTo(first.reference()); + assertThat(replayProducerInvoked).isFalse(); + + ObjectInspectionPort inspection = context.getBean(ObjectInspectionPort.class); + assertThat(inspection.inspect(first.reference())) + .get() + .extracting(descriptor -> descriptor.contentIdentity()) + .isEqualTo( + new ObjectContentIdentity(content.length, ObjectDigest.sha256(content))); + + ByteArrayOutputStream transferred = new ByteArrayOutputStream(); + ObjectTransferPort transfer = context.getBean(ObjectTransferPort.class); + transfer.transfer( + new ObjectReadRequest( + first.reference(), + Optional.of(first.exactVersion()), + ObjectReadRange.of(0, content.length), + ObjectDigestVerification.FULL_CONTENT, + content.length, + CallBudget.fromNow(Duration.ofMinutes(1)), + CancellationView.never()), + (readContext, source) -> { + byte[] buffer = new byte[64]; + for (int count = source.read(buffer, 0, buffer.length); + count != -1; + count = source.read(buffer, 0, buffer.length)) { + transferred.write(buffer, 0, count); + } + }); + + assertThat(transferred.toByteArray()).containsExactly(content); + }); + } + + @Test + void inspectionReturnsEmptyForAnAbsentReferenceOnAKnownRetainedRoute() { + new ApplicationContextRunner() + .withUserConfiguration(ObjectStorageCapabilityConfig.class) + .withPropertyValues(providerProperties(root.resolve("absent-reference"))) + .run( + context -> + assertThat( + context + .getBean(ObjectInspectionPort.class) + .inspect( + ObjectReference.parse( + "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"))) + .isEmpty()); + } + + private static ObjectPublishRequest publishRequest(byte[] content) { + return new ObjectPublishRequest( + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("operation-a")), + ObjectMediaType.of("application/octet-stream"), + new ObjectContentIdentity(content.length, ObjectDigest.sha256(content)), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of(ObjectCapabilityRequirement.IMMUTABLE_CREATE), + CallBudget.fromNow(Duration.ofMinutes(1)), + CancellationView.never()); + } + + private static String[] providerProperties(Path root) { + return new String[] { + "app.object-storage.enabled=true", + "app.object-storage.default-destination=poster", + "app.object-storage.providers.local.type=filesystem-local-dev", + "app.object-storage.providers.local.version=jdk-21", + "app.object-storage.providers.local.root=" + root, + "app.object-storage.providers.local.maximum-object-bytes=1KB", + "app.object-storage.providers.local.chunk-bytes=64B", + "app.object-storage.destinations.poster.provider-ref=local", + "app.object-storage.destinations.poster.route-token=0123456789ab", + "app.object-storage.destinations.poster.binding-revision=binding-v1", + "app.object-storage.destinations.poster.policy-revision=policy-v1" + }; + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/contract/ObjectStorageProviderContract.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/contract/ObjectStorageProviderContract.java new file mode 100644 index 0000000..ace0020 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/contract/ObjectStorageProviderContract.java @@ -0,0 +1,247 @@ +package dev.caskeleton.adapter.outbound.objectstorage.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec; +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.CancellationView; +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +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.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.io.ByteArrayOutputStream; +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** Non-skipping bounded provider contract. Optional capabilities assert descriptors explicitly. */ +public abstract class ObjectStorageProviderContract { + + protected static final int CHUNK_BYTES = 64; + protected static final int MAXIMUM_BYTES = 1024; + + protected abstract ObjectStorageProvider provider(); + + @Test + void storesBoundarySizesWithoutAWholeObjectProviderApi() { + int[] sizes = {0, 1, CHUNK_BYTES - 1, CHUNK_BYTES, CHUNK_BYTES + 1, MAXIMUM_BYTES}; + for (int index = 0; index < sizes.length; index++) { + byte[] content = content(sizes[index]); + ObjectStorageProvider.StoredData stored = + provider() + .create(operation(index + 1, content, CancellationView.never()), producer(content)); + + assertThat(stored.contentIdentity()).isEqualTo(identity(content)); + assertThat(provider().inspect(stored.canonicalDataKey())).contains(stored); + } + } + + @Test + void immutableCreateConflictsAndResolutionDoesNotReplayProducer() { + byte[] content = content(10); + ObjectStorageProviderOperation operation = operation(20, content, CancellationView.never()); + AtomicBoolean invoked = new AtomicBoolean(); + ObjectStorageProvider.StoredData stored = + provider() + .create( + operation, + (context, sink) -> { + invoked.set(true); + sink.write(content, 0, content.length); + }); + + invoked.set(false); + assertThatThrownBy(() -> provider().create(operation, producer(content))) + .isInstanceOf(ObjectStorageProviderException.class); + assertThat(provider().resolveCreate(operation)) + .isEqualTo(ObjectStorageProvider.Resolution.APPLIED); + assertThat(invoked).isFalse(); + assertThat(provider().inspect(stored.canonicalDataKey())).contains(stored); + } + + @Test + void fullAndContiguousRangeReadsAreBoundedAndInvalidateTheSource() { + byte[] content = content(200); + ObjectStorageProvider.StoredData stored = + provider().create(operation(30, content, CancellationView.never()), producer(content)); + AtomicReference escaped = + new AtomicReference<>(); + ByteArrayOutputStream full = new ByteArrayOutputStream(); + provider() + .transfer( + read(stored, ObjectReadRange.of(0, content.length)), + (context, source) -> { + escaped.set(source); + byte[] buffer = new byte[CHUNK_BYTES]; + for (int count = source.read(buffer, 0, buffer.length); + count != -1; + count = source.read(buffer, 0, buffer.length)) { + full.write(buffer, 0, count); + } + }); + + ByteArrayOutputStream range = new ByteArrayOutputStream(); + provider() + .transfer( + read(stored, ObjectReadRange.of(17, 83)), + (context, source) -> { + byte[] buffer = new byte[19]; + for (int count = source.read(buffer, 0, buffer.length); + count != -1; + count = source.read(buffer, 0, buffer.length)) { + range.write(buffer, 0, count); + } + }); + + assertThat(full.toByteArray()).containsExactly(content); + assertThat(range.toByteArray()).containsExactly(Arrays.copyOfRange(content, 17, 100)); + assertThatThrownBy(() -> escaped.get().read(new byte[1], 0, 1)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void shortFailingAndCancelledProductionLeavesNoPublishedData() { + byte[] content = content(20); + ObjectStorageProviderOperation shortOperation = + operation(40, content, CancellationView.never()); + assertThatThrownBy( + () -> + provider() + .create( + shortOperation, + (context, sink) -> sink.write(content, 0, content.length - 1))) + .isInstanceOf(ObjectStorageProviderException.class); + assertThat(provider().inspect(shortOperation.canonicalDataKey())).isEmpty(); + + ObjectStorageProviderOperation failing = operation(41, content, CancellationView.never()); + assertThatThrownBy( + () -> + provider() + .create( + failing, + (context, sink) -> { + throw new dev.caskeleton.application.objectstorage.content + .ObjectContentProductionException("producer failed"); + })) + .isInstanceOf(ObjectStorageProviderException.class); + assertThat(provider().inspect(failing.canonicalDataKey())).isEmpty(); + + ObjectStorageProviderOperation cancelled = operation(42, content, () -> true); + assertThatThrownBy(() -> provider().create(cancelled, producer(content))) + .isInstanceOf(ObjectStorageProviderException.class); + assertThat(provider().inspect(cancelled.canonicalDataKey())).isEmpty(); + } + + @Test + void checksumMismatchFailsAndExactVersionRetirementIsConditional() { + byte[] content = content(32); + ObjectStorageProviderOperation wrongDigest = + new ObjectStorageProviderOperation( + operation(50, content, CancellationView.never()).operationKey(), + operation(50, content, CancellationView.never()).canonicalDataKey(), + new ObjectContentIdentity(content.length, ObjectDigest.sha256(new byte[] {9})), + ObjectMediaType.of("application/octet-stream"), + CallBudget.fromNow(Duration.ofMinutes(1)), + CancellationView.never(), + CHUNK_BYTES, + MAXIMUM_BYTES); + assertThatThrownBy(() -> provider().create(wrongDigest, producer(content))) + .isInstanceOf(ObjectStorageProviderException.class); + + ObjectStorageProvider.StoredData stored = + provider().create(operation(51, content, CancellationView.never()), producer(content)); + assertThat( + provider() + .retire( + new ObjectStorageProvider.RetireOperation( + stored.canonicalDataKey(), + dev.caskeleton.application.objectstorage.identity.ObjectVersionToken.parse( + "osv1.0123456789ab.11111111111111111111111111.36da73f19a")))) + .isFalse(); + assertThat( + provider() + .retire( + new ObjectStorageProvider.RetireOperation( + stored.canonicalDataKey(), stored.exactVersion()))) + .isTrue(); + assertThat(provider().inspect(stored.canonicalDataKey())).isEmpty(); + } + + @Test + void unsupportedOptionalCapabilitiesAreDeclaredRatherThanSkipped() { + assertThat(provider().descriptor().support()) + .containsEntry( + ObjectStorageProvider.Capability.MULTI_NODE_LINEARIZABLE_CAS, + ObjectStorageProvider.Support.UNSUPPORTED) + .containsEntry( + ObjectStorageProvider.Capability.POWER_LOSS_DURABILITY, + ObjectStorageProvider.Support.UNSUPPORTED); + } + + public static ObjectStorageProviderOperation operation( + int generation, byte[] content, CancellationView cancellation) { + ObjectRouteToken route = ObjectRouteToken.parse("0123456789ab"); + String objectToken = + String.format(java.util.Locale.ROOT, "%025d", generation).replace('8', 'a') + "b"; + ObjectId objectId = ObjectId.parse(objectToken); + return new ObjectStorageProviderOperation( + new ObjectOperationKey( + ObjectDestinationId.of("contract"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("operation-" + generation)), + ObjectDataKeyCodec.encode(route, objectId, ObjectDataKeyCodec.Generation.of(generation)), + identity(content), + ObjectMediaType.of("application/octet-stream"), + CallBudget.fromNow(Duration.ofMinutes(1)), + cancellation, + CHUNK_BYTES, + MAXIMUM_BYTES); + } + + public static ObjectContentProducer producer(byte[] content) { + return (context, sink) -> { + for (int offset = 0; offset < content.length; offset += CHUNK_BYTES) { + int length = Math.min(CHUNK_BYTES, content.length - offset); + sink.write(content, offset, length); + } + }; + } + + protected static ObjectStorageProvider.ReadOperation read( + ObjectStorageProvider.StoredData stored, ObjectReadRange range) { + return new ObjectStorageProvider.ReadOperation( + stored.canonicalDataKey(), + stored.exactVersion(), + stored.contentIdentity(), + range, + CallBudget.fromNow(Duration.ofMinutes(1)), + CancellationView.never(), + CHUNK_BYTES, + MAXIMUM_BYTES); + } + + protected static ObjectContentIdentity identity(byte[] content) { + return new ObjectContentIdentity(content.length, ObjectDigest.sha256(content)); + } + + private static byte[] content(int size) { + byte[] value = new byte[size]; + for (int index = 0; index < size; index++) { + value[index] = (byte) (index * 31); + } + return value; + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java new file mode 100644 index 0000000..f8eee5c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java @@ -0,0 +1,339 @@ +package dev.caskeleton.adapter.outbound.objectstorage.control; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec; +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectPolicySnapshotCodec; +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.ObjectPolicyRevision; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken; +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.ObjectCapabilityRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +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.time.Duration; +import java.time.Instant; +import java.util.EnumSet; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ObjectControlRecordCodecTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final ObjectId OBJECT = ObjectId.parse("0123456789abcdefghjkmnpqrs"); + private static final ObjectReference REFERENCE = + ObjectReference.parse("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + private static final ObjectVersionToken VERSION = + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); + private static final ObjectDigest DIGEST = + ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8)); + private static final CanonicalJsonObjectControlRecordCodec CODEC = + new CanonicalJsonObjectControlRecordCodec(); + + @Test + void everyClosedRecordFamilyRoundTripsByteForByte() { + for (ObjectControlRecord record : records()) { + byte[] encoded = CODEC.encode(record); + ObjectControlRecord decoded = CODEC.decode(encoded); + + assertThat(decoded).isEqualTo(record); + assertThat(CODEC.encode(decoded)).containsExactly(encoded); + assertThat(new String(encoded, StandardCharsets.UTF_8)) + .startsWith("{\"digest\":\"") + .contains("\",\"payload\":\"") + .doesNotContain( + "secret", + "credential", + "https://", + "file://", + "public-read", + "/home/", + "original-filename"); + } + } + + @Test + void operationGoldenEnvelopeIsFrozen() { + String encoded = new String(CODEC.encode(records().getFirst()), StandardCharsets.UTF_8); + + assertThat(CODEC.sha256Hex(encoded.getBytes(StandardCharsets.UTF_8))) + .isEqualTo("82c6bc0fc6e6205ecf7c676b958a71f28622af15dc3b8d8ac00fddabba308f57"); + } + + @Test + void everyRecordFamilyHasAFrozenGoldenEnvelopeDigest() { + assertThat(records().stream().map(CODEC::encode).map(CODEC::sha256Hex).toList()) + .containsExactly( + "82c6bc0fc6e6205ecf7c676b958a71f28622af15dc3b8d8ac00fddabba308f57", + "1cd03ede3147e9928c73f75b4909bdd3d0a885b719995729fba4192748d7e9b8", + "f54377d2cf25955f6b3a4a66bad9d98de1d777cbdd3e75b7c232a4ab752e2db1", + "790c4f49a5c959df1642a9a1064c4aa3b81df5cd144149a97dd51a3e1e4f3b00", + "2ab5c946438a5a77ad217444145600cbfc201743f171c4b5f6a456a289c02631", + "241bd1d9ee1a54c3becb6446f0ff95b5d1698a6060a229265df882270807c96d", + "41425cc6942a3a4056d5421e0e4fc0d760deb6f90ef147a73498aefbfda9e683", + "980b75f2aa25e4b1e7f348f8240edd8958916e4165ebee873b4600acefe87887", + "5633a3a9de2cfcf8a40ad2a465f1cf0cbd2a315c9430cce73be5f829e0b2eb81", + "c2d7a32e9b6895b98a96bd43d70f616a729d31809ed62b1fce99ce130caf07c0", + "2d5ced2ec411a85ed9b6ef59726e643607911f7cbcd9c43625d8d7cf8aa06b31"); + } + + @Test + void corruptionUnknownOuterFieldsTruncationAndOversizeFailClosed() { + byte[] encoded = CODEC.encode(records().getFirst()); + String text = new String(encoded, StandardCharsets.UTF_8); + byte[] corrupt = + text.replaceFirst("\"digest\":\"[0-9a-f]", "\"digest\":\"f") + .getBytes(StandardCharsets.UTF_8); + byte[] unknown = + text.replaceFirst("\\}$", ",\"unknown\":\"x\"}").getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> CODEC.decode(corrupt)) + .isInstanceOf(ObjectControlCorruptionException.class); + assertThatThrownBy(() -> CODEC.decode(unknown)) + .isInstanceOf(ObjectControlCorruptionException.class); + assertThatThrownBy(() -> CODEC.decode(java.util.Arrays.copyOf(encoded, encoded.length - 1))) + .isInstanceOf(ObjectControlCorruptionException.class); + assertThatThrownBy(() -> CODEC.decode(new byte[65_537])) + .isInstanceOf(ObjectControlCorruptionException.class); + } + + @Test + void duplicateUnknownMissingAndReorderedPayloadFieldsFailClosed() { + byte[] encoded = CODEC.encode(records().getFirst()); + String payload = payload(encoded); + List invalidPayloads = + List.of( + payload.replaceFirst("\"schema\":1", "\"schema\":1,\"schema\":1"), + payload.replaceFirst("\"schema\":1", "\"schema\":1,\"unknown\":\"x\""), + payload.replaceFirst(",\"revision\":1", ""), + payload.replaceFirst( + "\"schema\":1,\"recordId\":\"", + "\"recordId\":\"moved\",\"schema\":1,\"ignored\":\"")); + + for (String invalid : invalidPayloads) { + assertThatThrownBy( + () -> CODEC.decode(CODEC.envelope(invalid.getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(ObjectControlCorruptionException.class); + } + } + + @Test + void newerSchemaIsQuarantinedInsteadOfAppearingAbsent() { + byte[] encoded = CODEC.encode(records().getFirst()); + String envelope = new String(encoded, StandardCharsets.UTF_8); + byte[] payload = + java.util.Base64.getDecoder() + .decode(envelope.replaceFirst("^.*\"payload\":\"", "").replaceFirst("\"}$", "")); + String newer = + new String(payload, StandardCharsets.UTF_8).replaceFirst("\"schema\":1", "\"schema\":2"); + + assertThatThrownBy(() -> CODEC.decode(CODEC.envelope(newer.getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(UnsupportedObjectControlSchemaException.class); + } + + private static List records() { + ObjectOperationKey operationKey = + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("upload-42")); + ObjectPolicySnapshot snapshot = + new ObjectPolicySnapshot( + ObjectBindingRevision.of("binding-v1"), + ObjectPolicyRevision.of("policy-v1"), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of(ObjectCapabilityRequirement.IMMUTABLE_CREATE), + 1024, + 64, + Duration.ofDays(7)); + ObjectOperationRecord operation = + ObjectOperationRecord.reserved( + operationKey, + "a".repeat(64), + "PUBLISH", + ROUTE, + snapshot, + new ObjectContentIdentity(5, DIGEST), + CODEC.sha256Hex(ObjectPolicySnapshotCodec.encode(snapshot)), + NOW); + ObjectManifestRecord manifest = + new ObjectManifestRecord( + 1, + "manifest-1", + 1, + OBJECT, + ROUTE, + "filesystem-local-dev", + "jdk-21", + ObjectBindingRevision.of("binding-v1"), + 5, + DIGEST, + ObjectMediaType.of("text/plain"), + VERSION, + "provider-managed", + "none", + NOW); + ObjectReferencePointerRecord pointer = + new ObjectReferencePointerRecord(1, "pointer-1", 1, REFERENCE, 1, VERSION, NOW); + ObjectReferenceRecord reference = + new ObjectReferenceRecord( + 1, + "reference-1", + 1, + REFERENCE, + OBJECT, + VERSION, + PublishedReferenceState.PUBLISHED, + NOW); + DirectTransferSessionId directSessionId = ObjectHandleCodec.directUpload(ROUTE, OBJECT); + ObjectDirectSessionRecord direct = + new ObjectDirectSessionRecord( + 1, + "direct-session-1", + 1, + new DirectTransferSessionRecord( + operationKey, + directSessionId, + DirectGrantSessionState.GRANT_ISSUED, + 1, + "b".repeat(64), + NOW, + NOW.plusSeconds(300), + "credential-v1", + "reference-unpublished", + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + new ObjectContentIdentity(5, DIGEST), + ObjectMediaType.of("text/plain"), + null, + NOW)); + ObjectMultipartSessionRecord session = + new ObjectMultipartSessionRecord( + 1, + "session-1", + 1, + DirectTransferSessionId.parse( + "osm1.0123456789ab.0123456789abcdefghjkmnpqrs.13a5a3b163"), + MultipartUploadState.ACCEPTING_PARTS, + DirectGrantSessionState.GRANT_ISSUED, + "opaque-provider-session", + 2, + NOW); + DirectTransferSessionId directMultipartSessionId = ObjectHandleCodec.multipart(ROUTE, OBJECT); + ObjectDirectMultipartSessionRecord directMultipartSession = + new ObjectDirectMultipartSessionRecord( + 1, + "direct-multipart-session-1", + 2, + operationKey, + directMultipartSessionId, + MultipartUploadState.ACCEPTING_PARTS, + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + new ObjectContentIdentity(5, DIGEST), + ObjectMediaType.of("text/plain"), + "opaque-provider-upload", + 2, + 1, + NOW.plusSeconds(1800), + NOW.plusSeconds(300), + null, + null, + NOW); + ObjectDirectMultipartGrantRecord directMultipartGrant = + new ObjectDirectMultipartGrantRecord( + 1, + "direct-multipart-grant-1", + 3, + directMultipartSessionId, + MultipartPartNumber.of(1), + 1, + DirectGrantSessionState.DATA_UPLOADED, + 5, + DIGEST, + NOW, + NOW.plusSeconds(300), + "opaque-provider-part-evidence", + PartReceiptToken.parse("osp1.0123456789abcdefghjkmnpqrs.8f6f53696e"), + NOW); + ObjectMultipartPartRecord part = + new ObjectMultipartPartRecord( + 1, + "part-1", + 1, + session.sessionId(), + MultipartPartNumber.of(1), + PartReceiptToken.parse("osp1.0123456789abcdefghjkmnpqrs.8f6f53696e"), + 5, + DIGEST, + "opaque-provider-evidence", + NOW); + ObjectStageHandle stageHandle = ObjectHandleCodec.stage(ROUTE, OBJECT); + ObjectStagedObjectRecord staged = + new ObjectStagedObjectRecord( + 1, + "epoch-a:upload-42", + 3, + operationKey, + stageHandle, + OBJECT, + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + VERSION, + new ObjectContentIdentity(5, DIGEST), + ObjectMediaType.of("text/plain"), + ObjectPublicationRequirement.SCAN_CLEAN, + true, + ObjectOperationId.of("scan-42"), + "scanner-policy-v1", + ObjectScanState.CLEAN, + NOW); + ObjectPublicationHandoffRecord handoff = + ObjectPublicationHandoffRecord.claimed( + operationKey, stageHandle, VERSION, "poster-image-worker-a", NOW, NOW.plusSeconds(30)); + assertThat(operation.stablePhase()).isEqualTo(PublicationOperationState.RESERVED); + return List.of( + operation, + manifest, + pointer, + reference, + direct, + directMultipartSession, + directMultipartGrant, + session, + part, + staged, + handoff); + } + + private static String payload(byte[] encoded) { + String envelope = new String(encoded, StandardCharsets.UTF_8); + return new String( + java.util.Base64.getDecoder() + .decode(envelope.replaceFirst("^.*\"payload\":\"", "").replaceFirst("\"}$", "")), + StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java new file mode 100644 index 0000000..dc9f9f5 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java @@ -0,0 +1,327 @@ +package dev.caskeleton.adapter.outbound.objectstorage.direct; + +import static org.assertj.core.api.Assertions.assertThat; + +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 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.MultipartUploadState; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.MultipartSession; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +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 dev.caskeleton.application.outbound.CallBudget; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class DirectMultipartCoordinatorTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final long PART_BYTES = 5L * 1024 * 1024; + private static final ObjectDigest PART_DIGEST = + ObjectDigest.of( + dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + private static final ObjectContentIdentity CONTENT = + new ObjectContentIdentity(PART_BYTES, PART_DIGEST); + + @Test + void initiateIntentGrantAcknowledgementAndCompletionAreDurablyOrdered() { + RecordingStore store = new RecordingStore(); + FakeProvider provider = new FakeProvider(store); + DirectMultipartCoordinator coordinator = + new DirectMultipartCoordinator( + ROUTE, store, provider, CLOCK, Duration.ofSeconds(30), Duration.ofSeconds(10)); + + var session = coordinator.startMultipart(startRequest()); + var grant = coordinator.createPartGrant(partRequest(session.sessionId())); + var token = + coordinator.acknowledgePart( + new MultipartPartAcknowledgement( + operation(), + session.sessionId(), + MultipartPartNumber.of(1), + PART_BYTES, + PART_DIGEST, + "\"client-etag\"")); + assertThat(token).isNotNull(); + MultipartCompleteRequest completeRequest = + new MultipartCompleteRequest( + operation(), + session.sessionId(), + List.of(token), + CONTENT, + budget(), + CancellationView.never()); + var completed = coordinator.completeMultipart(completeRequest); + var replayed = coordinator.completeMultipart(completeRequest); + + assertThat(store.sessionStates) + .containsSubsequence( + MultipartUploadState.INITIATE_IN_PROGRESS, + MultipartUploadState.ACCEPTING_PARTS, + MultipartUploadState.COMPLETE_IN_PROGRESS, + MultipartUploadState.COMPLETED); + assertThat(grant.toString()).doesNotContain("provider-upload", "client-etag"); + assertThat(token.toString()).doesNotContain("client-etag", "private-part-etag"); + assertThat(completed.verifiedContent()).isEqualTo(CONTENT); + assertThat(replayed.exactVersion()).isEqualTo(completed.exactVersion()); + assertThat(replayed.outcome()).isEqualTo(ObjectMutationOutcome.REPLAYED); + assertThat(provider.completeCalls).hasValue(1); + } + + @Test + void startReplayResolvesTheDurableInitiationInsteadOfBlindlyCreatingAgain() { + RecordingStore store = new RecordingStore(); + FakeProvider provider = new FakeProvider(store); + DirectMultipartCoordinator coordinator = + new DirectMultipartCoordinator( + ROUTE, store, provider, CLOCK, Duration.ofSeconds(30), Duration.ofSeconds(10)); + + MultipartSession first = coordinator.startMultipart(startRequest()); + MultipartSession replay = coordinator.startMultipart(startRequest()); + + assertThat(provider.initiateCalls).hasValue(1); + assertThat(first.outcome()).isEqualTo(ObjectMutationOutcome.APPLIED); + assertThat(replay.outcome()).isEqualTo(ObjectMutationOutcome.REPLAYED); + assertThat(replay.sessionId()).isEqualTo(first.sessionId()); + } + + @Test + void lostCompleteResponseIsResolvedBeforeTheTerminalVersionIsPersisted() { + RecordingStore store = new RecordingStore(); + FakeProvider provider = new FakeProvider(store, true); + DirectMultipartCoordinator coordinator = + new DirectMultipartCoordinator( + ROUTE, store, provider, CLOCK, Duration.ofSeconds(30), Duration.ofSeconds(10)); + var session = coordinator.startMultipart(startRequest()); + coordinator.createPartGrant(partRequest(session.sessionId())); + var token = + coordinator.acknowledgePart( + new MultipartPartAcknowledgement( + operation(), + session.sessionId(), + MultipartPartNumber.of(1), + PART_BYTES, + PART_DIGEST, + "\"client-etag\"")); + + var completed = + coordinator.completeMultipart( + new MultipartCompleteRequest( + operation(), + session.sessionId(), + List.of(token), + CONTENT, + budget(), + CancellationView.never())); + + assertThat(completed.outcome()).isEqualTo(ObjectMutationOutcome.APPLIED); + assertThat(provider.completeCalls).hasValue(1); + assertThat(provider.resolveCompleteCalls).hasValue(1); + assertThat(store.onlySession().completedVersion()).isEqualTo(completed.exactVersion()); + } + + private static MultipartStartRequest startRequest() { + return new MultipartStartRequest( + operation(), + ObjectMediaType.of("application/octet-stream"), + CONTENT, + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + 1, + Duration.ofMinutes(30), + budget(), + CancellationView.never()); + } + + private static PartUploadGrantRequest partRequest( + dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId sessionId) { + return new PartUploadGrantRequest( + operation(), + sessionId, + MultipartPartNumber.of(1), + PART_BYTES, + PART_DIGEST, + Duration.ofMinutes(5), + budget(), + CancellationView.never()); + } + + private static ObjectOperationKey operation() { + return new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("direct-multipart-42")); + } + + private static CallBudget budget() { + return CallBudget.fromNow(Duration.ofSeconds(5)); + } + + private static final class FakeProvider implements DirectMultipartProvider { + + private final RecordingStore store; + private final boolean loseCompleteResponse; + private final AtomicInteger completeCalls = new AtomicInteger(); + private final AtomicInteger resolveCompleteCalls = new AtomicInteger(); + private final AtomicInteger initiateCalls = new AtomicInteger(); + + private FakeProvider(RecordingStore store) { + this(store, false); + } + + private FakeProvider(RecordingStore store, boolean loseCompleteResponse) { + this.store = store; + this.loseCompleteResponse = loseCompleteResponse; + } + + @Override + public InitiateResult initiate(ObjectDirectMultipartSessionRecord session) { + initiateCalls.incrementAndGet(); + assertThat(store.onlySession().multipartState()) + .isEqualTo(MultipartUploadState.INITIATE_IN_PROGRESS); + return new InitiateResult("provider-upload"); + } + + @Override + public InitiateResolution resolveInitiate(ObjectDirectMultipartSessionRecord session) { + return InitiateResolution.applied("provider-upload"); + } + + @Override + public DirectGrantProvider.DirectGrantMaterial signPart( + ObjectDirectMultipartSessionRecord session, ObjectDirectMultipartGrantRecord grant) { + return new DirectGrantProvider.DirectGrantMaterial( + URI.create("https://storage.example.test/part?signature=secret"), + Map.of("x-amz-checksum-sha256", "redacted"), + grant.expiresAt()); + } + + @Override + public VerifiedPart verifyPart( + ObjectDirectMultipartSessionRecord session, MultipartPartAcknowledgement acknowledgement) { + return new VerifiedPart( + acknowledgement.partNumber(), + acknowledgement.observedLength(), + acknowledgement.observedDigest(), + "\"private-part-etag\"", + acknowledgement.observedDigest().base64Value()); + } + + @Override + public boolean controlledIngressDrained(ObjectDirectMultipartSessionRecord session) { + return true; + } + + @Override + public DirectGrantProvider.VerifiedUpload complete( + ObjectDirectMultipartSessionRecord session, List parts) { + if (completeCalls.incrementAndGet() == 1 && loseCompleteResponse) { + throw new IllegalStateException("provider accepted complete but its response was lost"); + } + return new DirectGrantProvider.VerifiedUpload( + CONTENT, + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f")); + } + + @Override + public DirectGrantProvider.VerifiedUpload resolveComplete( + ObjectDirectMultipartSessionRecord session, List parts) { + resolveCompleteCalls.incrementAndGet(); + return new DirectGrantProvider.VerifiedUpload( + CONTENT, + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f")); + } + + @Override + public void abort(ObjectDirectMultipartSessionRecord session) {} + + @Override + public Resolution resolveAbort(ObjectDirectMultipartSessionRecord session) { + return Resolution.APPLIED; + } + } + + private static final class RecordingStore implements ObjectControlStore { + + private final Map records = new LinkedHashMap<>(); + private final List sessionStates = new ArrayList<>(); + + @Override + public Optional read(String key) { + return Optional.ofNullable(records.get(key)); + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + if (records.containsKey(key)) { + throw new ObjectControlConflictException("exists"); + } + StoredControlRecord stored = new StoredControlRecord(ObjectControlVersion.of("v1"), record); + records.put(key, stored); + capture(record); + return stored; + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + StoredControlRecord current = records.get(key); + if (current == null || !current.version().equals(mutation.expectedVersion())) { + throw new ObjectControlConflictException("stale"); + } + StoredControlRecord stored = + new StoredControlRecord( + ObjectControlVersion.of("v" + mutation.replacement().revision()), + mutation.replacement()); + records.put(key, stored); + capture(mutation.replacement()); + return stored; + } + + private ObjectDirectMultipartSessionRecord onlySession() { + return records.values().stream() + .map(StoredControlRecord::record) + .filter(ObjectDirectMultipartSessionRecord.class::isInstance) + .map(ObjectDirectMultipartSessionRecord.class::cast) + .findFirst() + .orElseThrow(); + } + + private void capture(ObjectControlRecord record) { + if (record instanceof ObjectDirectMultipartSessionRecord session) { + sessionStates.add(session.multipartState()); + } + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartRaceTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartRaceTest.java new file mode 100644 index 0000000..d5c64c0 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartRaceTest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.objectstorage.direct; + +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartSessionRecord; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class DirectMultipartRaceTest { + + @Test + void completionHorizonRejectsWhileIssuedPartRequestsMayStillArrive() { + assertThatIllegalStateException() + .isThrownBy( + () -> + DirectMultipartCompletionVerifier.requireAdmissionDrained( + Instant.parse("2026-07-28T00:00:00Z"), + Instant.parse("2026-07-28T00:05:00Z"), + Duration.ofSeconds(30), + Duration.ofSeconds(10), + false)); + } + + @Test + void lateGrantAdmissionRejectsAfterCompletionFence() { + assertThatIllegalStateException() + .isThrownBy( + () -> + DirectMultipartCoordinator.requireAcceptingParts( + org.mockito.Mockito.mock(ObjectDirectMultipartSessionRecord.class), + MultipartUploadState.COMPLETE_IN_PROGRESS)); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinatorTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinatorTest.java new file mode 100644 index 0000000..8c02e0a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinatorTest.java @@ -0,0 +1,251 @@ +package dev.caskeleton.adapter.outbound.objectstorage.direct; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +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 dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.DirectUploadGrant; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.request.DirectUploadCompletionRequest; +import dev.caskeleton.application.objectstorage.request.DirectUploadGrantRequest; +import dev.caskeleton.application.outbound.CallBudget; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class DirectTransferCoordinatorTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final ObjectContentIdentity CONTENT = + new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))); + + @Test + void preparedCasPrecedesSigningAndIssuedCasPrecedesReturningTheBearerGrant() { + RecordingStore store = new RecordingStore(); + AtomicInteger signs = new AtomicInteger(); + DirectGrantProvider provider = + new FakeProvider(store, signs, URI.create("https://storage.example.test/signed?secret=x")); + DirectTransferCoordinator coordinator = + new DirectTransferCoordinator(ROUTE, store, policy(), provider, CLOCK); + + DirectUploadGrant first = coordinator.createUploadGrant(grantRequest()); + DirectUploadGrant replay = coordinator.createUploadGrant(grantRequest()); + + assertThat(store.states) + .containsExactly( + DirectGrantSessionState.SESSION_RESERVED, + DirectGrantSessionState.GRANT_PREPARED, + DirectGrantSessionState.GRANT_ISSUED); + assertThat(first.requestUri()).isEqualTo(replay.requestUri()); + assertThat(signs).hasValue(1); + assertThat(store.only().session().grantGeneration()).isEqualTo(1); + assertThat(store.only().toString()).doesNotContain("secret=x", "storage.example.test"); + } + + @Test + void completionIgnoresClientSuccessAndAdvancesOnlyAfterExactProviderVerification() { + RecordingStore store = new RecordingStore(); + DirectTransferCoordinator coordinator = + new DirectTransferCoordinator( + ROUTE, + store, + policy(), + new FakeProvider( + store, new AtomicInteger(), URI.create("https://storage.example.test/grant")), + CLOCK); + DirectUploadGrant grant = coordinator.createUploadGrant(grantRequest()); + + var receipt = + coordinator.completeUpload( + new DirectUploadCompletionRequest( + operation(), grant.sessionId(), CONTENT, budget(), CancellationView.never())); + + assertThat(receipt.verifiedContent()).isEqualTo(CONTENT); + assertThat(store.only().session().state()).isEqualTo(DirectGrantSessionState.DATA_UPLOADED); + } + + @Test + void unhealthyQualifiedClockRejectsBeforePersistingOrSigning() { + RecordingStore store = new RecordingStore(); + DirectTransferPolicy unhealthy = + new DirectTransferPolicy( + DirectTransferPolicy.UploadProfile.DIRECT_SINGLE_SOFT_LIMIT_R1, + Set.of("storage.example.test"), + Duration.ofMinutes(10), + Duration.ofMinutes(10), + Duration.ofSeconds(30), + NOW.plus(Duration.ofHours(1)), + "credential-v1", + 2, + 1024, + false, + instant -> false); + AtomicInteger signs = new AtomicInteger(); + DirectTransferCoordinator coordinator = + new DirectTransferCoordinator( + ROUTE, + store, + unhealthy, + new FakeProvider(store, signs, URI.create("https://storage.example.test/grant")), + CLOCK); + + assertThatIllegalStateException() + .isThrownBy(() -> coordinator.createUploadGrant(grantRequest())); + assertThat(store.records).isEmpty(); + assertThat(signs).hasValue(0); + } + + private static DirectTransferPolicy policy() { + return new DirectTransferPolicy( + DirectTransferPolicy.UploadProfile.DIRECT_SINGLE_SOFT_LIMIT_R1, + Set.of("storage.example.test"), + Duration.ofMinutes(10), + Duration.ofMinutes(10), + Duration.ofSeconds(30), + NOW.plus(Duration.ofHours(1)), + "credential-v1", + 2, + 1024, + false, + instant -> true); + } + + private static DirectUploadGrantRequest grantRequest() { + return new DirectUploadGrantRequest( + operation(), + ObjectMediaType.of("text/plain"), + CONTENT, + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + Duration.ofMinutes(5), + budget(), + CancellationView.never()); + } + + private static ObjectOperationKey operation() { + return new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("direct-42")); + } + + private static CallBudget budget() { + return CallBudget.fromNow(Duration.ofSeconds(5)); + } + + private static final class FakeProvider implements DirectGrantProvider { + + private final RecordingStore store; + private final AtomicInteger signs; + private final URI uri; + + private FakeProvider(RecordingStore store, AtomicInteger signs, URI uri) { + this.store = store; + this.signs = signs; + this.uri = uri; + } + + @Override + public DirectGrantMaterial signUpload(DirectTransferSessionRecord session) { + assertThat(store.only().session().state()).isEqualTo(DirectGrantSessionState.GRANT_PREPARED); + signs.incrementAndGet(); + return new DirectGrantMaterial( + uri, Map.of("x-amz-checksum-sha256", "redacted"), session.expiresAt()); + } + + @Override + public DirectGrantMaterial signDownload( + DirectTransferSessionRecord session, PublishedObject published) { + throw new UnsupportedOperationException(); + } + + @Override + public VerifiedUpload verifyUpload(DirectTransferSessionRecord session) { + return new VerifiedUpload( + CONTENT, + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f")); + } + + @Override + public PublishedObject resolvePublished( + dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest request) { + throw new UnsupportedOperationException(); + } + } + + private static final class RecordingStore implements ObjectControlStore { + + private final Map records = new LinkedHashMap<>(); + private final List states = new ArrayList<>(); + + @Override + public Optional read(String key) { + return Optional.ofNullable(records.get(key)); + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + if (records.containsKey(key)) { + throw new ObjectControlConflictException("exists"); + } + StoredControlRecord stored = new StoredControlRecord(ObjectControlVersion.of("v1"), record); + records.put(key, stored); + capture(record); + return stored; + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + StoredControlRecord current = records.get(key); + if (current == null || !current.version().equals(mutation.expectedVersion())) { + throw new ObjectControlConflictException("stale"); + } + StoredControlRecord stored = + new StoredControlRecord( + ObjectControlVersion.of("v" + mutation.replacement().revision()), + mutation.replacement()); + records.put(key, stored); + capture(mutation.replacement()); + return stored; + } + + private dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectSessionRecord only() { + return (dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectSessionRecord) + records.values().iterator().next().record(); + } + + private void capture(ObjectControlRecord record) { + states.add( + ((dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectSessionRecord) record) + .session() + .state()); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java new file mode 100644 index 0000000..dbbfa05 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.objectstorage.direct; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DirectTransferCorsContractTest { + + @Test + void browserContractContainsOnlyTheSignedUploadAndCompletionEvidenceSurface() { + assertThat(DirectTransferCorsPolicy.allowedMethods()) + .containsExactlyInAnyOrder("PUT", "GET", "HEAD"); + assertThat(DirectTransferCorsPolicy.allowedRequestHeaders()) + .containsExactlyInAnyOrder( + "content-length", + "content-type", + "if-none-match", + "x-amz-checksum-sha256", + "x-amz-server-side-encryption"); + assertThat(DirectTransferCorsPolicy.exposedResponseHeaders()) + .containsExactlyInAnyOrder("etag", "x-amz-checksum-sha256", "x-amz-version-id"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactionTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactionTest.java new file mode 100644 index 0000000..8e3e3a2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactionTest.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.objectstorage.direct; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class PresignedGrantRedactionTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void bearerUriAndSignedValuesAreNeverRendered() { + URI secret = + URI.create( + "https://storage.example.test/private/key?X-Amz-Credential=private&X-Amz-Signature=secret"); + + assertThat(PresignedGrantRedactor.redact(secret)).isEqualTo("[REDACTED_PRESIGNED_URI]"); + assertThat( + PresignedGrantRedactor.describeHeaders( + Map.of("x-amz-checksum-sha256", "private-digest"))) + .contains("x-amz-checksum-sha256") + .doesNotContain("private-digest"); + } + + @Test + void endpointExpiryCredentialHorizonClockAndHardCeilingFailClosed() { + DirectTransferPolicy policy = + new DirectTransferPolicy( + DirectTransferPolicy.UploadProfile.DIRECT_SINGLE_HARD_CEILING, + Set.of("storage.example.test"), + Duration.ofMinutes(10), + Duration.ofMinutes(8), + Duration.ofSeconds(30), + NOW.plus(Duration.ofMinutes(6)), + "credential-v1", + 1, + 1024, + false, + instant -> true); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> + policy.planGrant( + URI.create("http://storage.example.test"), Duration.ofMinutes(5), 512, NOW)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + policy.planGrant( + URI.create("https://evil.example.test"), Duration.ofMinutes(5), 512, NOW)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + policy.planGrant( + URI.create("https://storage.example.test"), Duration.ofMinutes(6), 512, NOW)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + policy.planGrant( + URI.create("https://storage.example.test"), Duration.ofMinutes(5), 512, NOW)); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProviderTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProviderTest.java new file mode 100644 index 0000000..8c7560b --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProviderTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.objectstorage.filesystem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalDevObjectStorageProviderTest extends ObjectStorageProviderContract { + + @TempDir Path root; + + private LocalDevObjectStorageProvider provider; + + @BeforeEach + void setUp() { + provider = + new LocalDevObjectStorageProvider( + root, MAXIMUM_BYTES, CHUNK_BYTES, LocalObjectStreamTransfer.FaultInjector.none()); + } + + @Override + protected ObjectStorageProvider provider() { + return provider; + } + + @Test + void createsRestrictivePermissionsWherePosixIsSupported() throws Exception { + byte[] content = {1, 2, 3}; + ObjectStorageProvider.StoredData stored = + provider.create(operation(70, content, () -> false), producer(content)); + Path path = new LocalObjectPathGuard(root).resolveData(stored.canonicalDataKey(), false); + + if (Files.getFileStore(path).supportsFileAttributeView("posix")) { + Set permissions = + Files.getPosixFilePermissions(path); + assertThat(permissions) + .doesNotContain( + java.nio.file.attribute.PosixFilePermission.GROUP_WRITE, + java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE, + java.nio.file.attribute.PosixFilePermission.OTHERS_READ); + } + } + + @Test + void injectedDiskFailureLeavesNoFinalOrTemporaryData() { + LocalDevObjectStorageProvider failingProvider = + new LocalDevObjectStorageProvider( + root.resolve("fault"), + MAXIMUM_BYTES, + CHUNK_BYTES, + (written, target) -> { + throw new java.io.IOException("simulated disk full"); + }); + byte[] content = {1, 2, 3}; + var operation = operation(71, content, () -> false); + + assertThatThrownBy(() -> failingProvider.create(operation, producer(content))) + .isInstanceOf( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException + .class); + assertThat(failingProvider.inspect(operation.canonicalDataKey())).isEmpty(); + assertThat(root.resolve("fault/.tmp")) + .satisfies( + temporaryRoot -> { + try (var entries = Files.list(temporaryRoot)) { + assertThat(entries).isEmpty(); + } + }); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java new file mode 100644 index 0000000..91eb702 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java @@ -0,0 +1,153 @@ +package dev.caskeleton.adapter.outbound.objectstorage.filesystem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.control.CanonicalJsonObjectControlRecordCodec; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlCorruptionException; +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.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import dev.caskeleton.application.objectstorage.identity.ObjectId; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalDevObjectStorageRecoveryTest { + + @TempDir Path root; + + @Test + void restartInspectsCommittedDataWithoutReplayingProducer() { + byte[] content = {1, 2, 3, 4}; + LocalDevObjectStorageProvider first = + new LocalDevObjectStorageProvider( + root, 1024, 64, LocalObjectStreamTransfer.FaultInjector.none()); + var operation = + dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract + .operation(80, content, () -> false); + ObjectStorageProvider.StoredData stored = + first.create( + operation, + dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract + .producer(content)); + + LocalDevObjectStorageProvider restarted = + new LocalDevObjectStorageProvider( + root, 1024, 64, LocalObjectStreamTransfer.FaultInjector.none()); + + assertThat(restarted.resolveCreate(operation)) + .isEqualTo(ObjectStorageProvider.Resolution.APPLIED); + assertThat(restarted.inspect(stored.canonicalDataKey())).contains(stored); + } + + @Test + void traversalAbsoluteUnicodePercentAndSymlinkEscapesAreRejected() throws Exception { + LocalObjectPathGuard guard = new LocalObjectPathGuard(root); + for (String invalid : + java.util.List.of( + "../escape", + "/absolute", + "data/v1/a/../escape", + "data/v1/a/%2f/escape", + "data/v1/é/x", + "data\\v1\\escape")) { + assertThatThrownBy(() -> guard.resolveData(invalid, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + Path outside = Files.createDirectory(root.resolve("outside")); + Path nested = root.resolve("data"); + Files.deleteIfExists(nested); + try { + Files.createSymbolicLink(nested, outside); + assertThatThrownBy( + () -> + guard.resolveData( + "data/v1/0123456789ab/aa/0123456789abcdefghjkmnpqrs/0000000000000001", true)) + .isInstanceOf(IllegalArgumentException.class); + } catch (UnsupportedOperationException exception) { + assertThat(exception).isNotNull(); + } + } + + @Test + void corruptControlRecordRemainsPresentAndNeverAppearsAbsent() throws Exception { + LocalObjectPathGuard guard = new LocalObjectPathGuard(root); + LocalDevObjectControlStore store = + new LocalDevObjectControlStore(guard, new CanonicalJsonObjectControlRecordCodec()); + String key = "control/v1/references/0123456789ab/aa/reference"; + ObjectReferenceRecord record = + new ObjectReferenceRecord( + 1, + "reference", + 1, + ObjectReference.parse("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"), + ObjectId.parse("0123456789abcdefghjkmnpqrs"), + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"), + PublishedReferenceState.PUBLISHED, + Instant.EPOCH); + store.create(key, record); + Path physical = guard.resolveControl(key, false); + physical.toFile().setWritable(true); + Files.writeString(physical, "corrupt"); + + assertThatThrownBy(() -> store.read(key)).isInstanceOf(ObjectControlCorruptionException.class); + assertThat(physical).exists(); + } + + @Test + void exclusiveCreateRaceHasOneWinner() throws Exception { + LocalDevObjectStorageProvider provider = + new LocalDevObjectStorageProvider( + root, 1024, 64, LocalObjectStreamTransfer.FaultInjector.none()); + byte[] content = {1, 2, 3}; + var operation = + dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract + .operation(81, content, () -> false); + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var calls = + java.util.List.of( + (java.util.concurrent.Callable) () -> create(provider, operation, content), + (java.util.concurrent.Callable) () -> create(provider, operation, content)); + assertThat( + executor.invokeAll(calls).stream() + .map(LocalDevObjectStorageRecoveryTest::get) + .toList()) + .containsExactlyInAnyOrder(true, false); + } finally { + executor.shutdownNow(); + } + } + + private static boolean create( + LocalDevObjectStorageProvider provider, + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation + operation, + byte[] content) { + try { + provider.create( + operation, + dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract + .producer(content)); + return true; + } catch (ObjectStorageProviderException exception) { + return false; + } + } + + private static boolean get(java.util.concurrent.Future result) { + try { + return result.get(); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java new file mode 100644 index 0000000..a690e90 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThat; + +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 dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.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.time.Instant; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ObjectMutationResponseLossTest { + + @Test + void everyPendingMutationIsResolvedOnceWithoutBlindMutationReplay() { + for (PendingObjectEffect.Kind kind : PendingObjectEffect.Kind.values()) { + InMemoryStore store = new InMemoryStore(); + ObjectOperationKernel kernel = new ObjectOperationKernel(store); + ObjectOperationRecord reserved = + kernel + .reserve(key(kind), "a".repeat(64), "PUBLISH", route(), policy(), Instant.EPOCH) + .record(); + ObjectOperationRecord indeterminate = + kernel.markResponseLost( + kernel.markEffectSent( + reserved, + new PendingObjectEffect( + kind, + "attempt-1", + "exact-target", + "desired", + "exact-precondition", + "b".repeat(64)))); + AtomicInteger probes = new AtomicInteger(); + ObjectOperationResolutionService service = new ObjectOperationResolutionService(kernel); + + ObjectOperationResolutionService.Decision decision = + service.resolve( + indeterminate, + (record, effect) -> { + probes.incrementAndGet(); + return kind == PendingObjectEffect.Kind.DATA_PUT + ? ObjectOperationResolutionService.Decision.APPLIED + : ObjectOperationResolutionService.Decision.INDETERMINATE; + }, + Instant.EPOCH.plusSeconds(1)); + + assertThat(probes).hasValue(1); + assertThat(decision) + .isEqualTo( + kind == PendingObjectEffect.Kind.DATA_PUT + ? ObjectOperationResolutionService.Decision.APPLIED + : ObjectOperationResolutionService.Decision.INDETERMINATE); + ObjectOperationRecord stored = + (ObjectOperationRecord) store.records.values().iterator().next().record(); + if (kind == PendingObjectEffect.Kind.DATA_PUT) { + assertThat(stored.effectCertainty()).isEqualTo(ObjectEffectCertainty.CONFIRMED); + } else { + assertThat(stored.effectCertainty()).isEqualTo(ObjectEffectCertainty.INDETERMINATE); + } + } + } + + private static ObjectOperationKey key(PendingObjectEffect.Kind kind) { + return new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("operation-" + kind.name().toLowerCase(java.util.Locale.ROOT))); + } + + private static ObjectRouteToken route() { + return ObjectRouteToken.parse("0123456789ab"); + } + + private static ObjectPolicySnapshot policy() { + return new ObjectPolicySnapshot( + ObjectBindingRevision.of("binding-v1"), + ObjectPolicyRevision.of("policy-v1"), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of(ObjectCapabilityRequirement.IMMUTABLE_CREATE), + 1024, + 64, + Duration.ofDays(7)); + } + + private static final class InMemoryStore implements ObjectControlStore { + + private final Map records = new HashMap<>(); + private long version; + + @Override + public Optional read(String key) { + return Optional.ofNullable(records.get(key)); + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + StoredControlRecord stored = + new StoredControlRecord(ObjectControlVersion.of(Long.toString(++version)), record); + records.put(key, stored); + return stored; + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + StoredControlRecord stored = + new StoredControlRecord( + ObjectControlVersion.of(Long.toString(++version)), mutation.replacement()); + records.put(key, stored); + return stored; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java new file mode 100644 index 0000000..f553a33 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +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.Set; +import org.junit.jupiter.api.Test; + +class ObjectOperationEpochTest { + + private static final Instant CREATED_AT = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void epochMovesOnlyThroughTheFrozenTransitionChain() { + ObjectOperationEpochRecord warm = warm(); + ObjectOperationEpochRecord active = warm.activate(); + ObjectOperationEpochRecord draining = active.startDraining(); + ObjectOperationEpochRecord sealed = draining.seal(CREATED_AT.plus(Duration.ofDays(2)), false); + ObjectOperationEpochRecord compacted = sealed.compact(CREATED_AT.plus(Duration.ofDays(10))); + + assertThat(warm.state()).isEqualTo(ObjectOperationEpochState.WARM); + assertThat(active.state()).isEqualTo(ObjectOperationEpochState.ACTIVE); + assertThat(draining.state()).isEqualTo(ObjectOperationEpochState.DRAINING); + assertThat(sealed.state()).isEqualTo(ObjectOperationEpochState.SEALED); + assertThat(compacted.state()).isEqualTo(ObjectOperationEpochState.COMPACTED); + assertThat(warm.admissionRejection()).contains(ObjectOperationError.OPERATION_EPOCH_NOT_ACTIVE); + assertThat(active.admissionRejection()).isEmpty(); + assertThat(draining.admissionRejection()) + .contains(ObjectOperationError.OPERATION_EPOCH_NOT_ACTIVE); + assertThat(sealed.admissionRejection()).contains(ObjectOperationError.OPERATION_EXPIRED); + assertThat(compacted.admissionRejection()).contains(ObjectOperationError.OPERATION_EXPIRED); + } + + @Test + void invalidTransitionsSealConditionsAndEarlyCompactionFailClosed() { + ObjectOperationEpochRecord warm = warm(); + assertThatIllegalStateException().isThrownBy(warm::startDraining); + assertThatIllegalStateException() + .isThrownBy( + () -> warm.activate().startDraining().seal(CREATED_AT.plus(Duration.ofDays(2)), true)); + ObjectOperationEpochRecord sealed = + warm.activate().startDraining().seal(CREATED_AT.plus(Duration.ofDays(2)), false); + assertThatIllegalStateException() + .isThrownBy(() -> sealed.compact(CREATED_AT.plus(Duration.ofDays(3)))); + } + + @Test + void issuedEpochTokensCannotBeReused() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + ObjectOperationEpochRecord.warm( + ObjectOperationEpoch.of("epoch-a"), + CREATED_AT, + Duration.ofDays(1), + 10, + 65_536, + 2, + Duration.ofDays(7), + Set.of(ObjectOperationEpoch.of("epoch-a")))); + } + + private static ObjectOperationEpochRecord warm() { + return ObjectOperationEpochRecord.warm( + ObjectOperationEpoch.of("epoch-a"), + CREATED_AT, + Duration.ofDays(1), + 10, + 65_536, + 2, + Duration.ofDays(7), + Set.of(ObjectOperationEpoch.of("epoch-old"))); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java new file mode 100644 index 0000000..1624b66 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java @@ -0,0 +1,124 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThat; + +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 dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.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.time.Instant; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ObjectOperationKernelTest { + + private static final ObjectOperationKey KEY = + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("upload-42")); + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final ObjectPolicySnapshot POLICY = + new ObjectPolicySnapshot( + ObjectBindingRevision.of("binding-v1"), + ObjectPolicyRevision.of("policy-v1"), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of(ObjectCapabilityRequirement.IMMUTABLE_CREATE), + 1024, + 64, + Duration.ofDays(7)); + + @Test + void reserveReplaysSameFingerprintAndConflictsOnDifferentIntent() { + InMemoryStore store = new InMemoryStore(); + ObjectOperationKernel kernel = new ObjectOperationKernel(store); + + ObjectOperationKernelResult created = + kernel.reserve(KEY, "a".repeat(64), "PUBLISH", ROUTE, POLICY, Instant.EPOCH); + ObjectOperationKernelResult replay = + kernel.reserve(KEY, "a".repeat(64), "PUBLISH", ROUTE, POLICY, Instant.EPOCH); + ObjectOperationKernelResult conflict = + kernel.reserve(KEY, "b".repeat(64), "PUBLISH", ROUTE, POLICY, Instant.EPOCH); + + assertThat(created.decision()).isEqualTo(ObjectOperationKernelResult.Decision.RESERVED); + assertThat(replay.decision()) + .isEqualTo(ObjectOperationKernelResult.Decision.REPLAY_NON_TERMINAL); + assertThat(conflict.decision()).isEqualTo(ObjectOperationKernelResult.Decision.CONFLICT); + } + + @Test + void pendingEffectIsDurableBeforeIoAndResponseLossRemainsPhaseSpecific() { + InMemoryStore store = new InMemoryStore(); + ObjectOperationKernel kernel = new ObjectOperationKernel(store); + ObjectOperationRecord reserved = + kernel.reserve(KEY, "a".repeat(64), "PUBLISH", ROUTE, POLICY, Instant.EPOCH).record(); + PendingObjectEffect effect = + new PendingObjectEffect( + PendingObjectEffect.Kind.DATA_PUT, + "attempt-1", + "target-evidence", + PublicationOperationState.DATA_UPLOADED.name(), + "create-if-absent", + "c".repeat(64)); + + ObjectOperationRecord sent = kernel.markEffectSent(reserved, effect); + ObjectOperationRecord indeterminate = kernel.markResponseLost(sent); + + assertThat(sent.pendingEffect()).isEqualTo(effect); + assertThat(sent.effectCertainty()).isEqualTo(ObjectEffectCertainty.SENT); + assertThat(indeterminate.stablePhase()).isEqualTo(PublicationOperationState.RESERVED); + assertThat(indeterminate.pendingEffect().kind()).isEqualTo(PendingObjectEffect.Kind.DATA_PUT); + assertThat(indeterminate.effectCertainty()).isEqualTo(ObjectEffectCertainty.INDETERMINATE); + } + + private static final class InMemoryStore implements ObjectControlStore { + + private final Map records = new HashMap<>(); + private long version; + + @Override + public Optional read(String key) { + return Optional.ofNullable(records.get(key)); + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + if (records.containsKey(key)) { + throw new ObjectControlConflictException("already exists"); + } + StoredControlRecord stored = + new StoredControlRecord(ObjectControlVersion.of(Long.toString(++version)), record); + records.put(key, stored); + return stored; + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + StoredControlRecord current = records.get(key); + if (current == null || !current.version().equals(mutation.expectedVersion())) { + throw new ObjectControlConflictException("version conflict"); + } + StoredControlRecord stored = + new StoredControlRecord( + ObjectControlVersion.of(Long.toString(++version)), mutation.replacement()); + records.put(key, stored); + return stored; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java new file mode 100644 index 0000000..85a81a9 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import org.junit.jupiter.api.Test; + +class ObjectOperationStateMachineTest { + + @Test + void publicationFollowsScanFreeAndScanRequiredPaths() { + assertThat( + ObjectOperationStateMachine.advancePublication( + PublicationOperationState.RESERVED, + PublicationOperationState.DATA_UPLOAD_IN_PROGRESS)) + .isEqualTo(PublicationOperationState.DATA_UPLOAD_IN_PROGRESS); + assertThat( + ObjectOperationStateMachine.advancePublication( + PublicationOperationState.INTEGRITY_VERIFIED, + PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS)) + .isEqualTo(PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS); + assertThat( + ObjectOperationStateMachine.advancePublication( + PublicationOperationState.INTEGRITY_VERIFIED, + PublicationOperationState.SCAN_PENDING)) + .isEqualTo(PublicationOperationState.SCAN_PENDING); + assertThat( + ObjectOperationStateMachine.advancePublication( + PublicationOperationState.SCAN_CLEAN, + PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS)) + .isEqualTo(PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS); + } + + @Test + void terminalOutOfOrderAndStaleRevisionTransitionsFailClosed() { + assertThatIllegalStateException() + .isThrownBy( + () -> + ObjectOperationStateMachine.advancePublication( + PublicationOperationState.PUBLISHED, + PublicationOperationState.DATA_UPLOAD_IN_PROGRESS)); + assertThatIllegalStateException() + .isThrownBy(() -> ObjectOperationStateMachine.requireNextRevision(7, 7)); + assertThatIllegalStateException() + .isThrownBy( + () -> + ObjectOperationStateMachine.advanceMultipart( + MultipartUploadState.SESSION_RESERVED, MultipartUploadState.COMPLETED)); + } + + @Test + void independentStateFamiliesDoNotImplyEachOther() { + assertThat( + ObjectOperationStateMachine.advanceScan( + ScanOperationState.PENDING, ScanOperationState.MALICIOUS)) + .isEqualTo(ScanOperationState.MALICIOUS); + assertThat( + ObjectOperationStateMachine.advanceReference( + PublishedReferenceState.RETIRED, PublishedReferenceState.PURGE_ELIGIBLE)) + .isEqualTo(PublishedReferenceState.PURGE_ELIGIBLE); + assertThat( + ObjectOperationStateMachine.advanceDirectGrant( + DirectGrantSessionState.GRANT_ISSUED, + DirectGrantSessionState.UPLOAD_VERIFICATION_IN_PROGRESS)) + .isEqualTo(DirectGrantSessionState.UPLOAD_VERIFICATION_IN_PROGRESS); + assertThat( + ObjectOperationStateMachine.advanceMultipart( + MultipartUploadState.ACCEPTING_PARTS, MultipartUploadState.COMPLETE_IN_PROGRESS)) + .isEqualTo(MultipartUploadState.COMPLETE_IN_PROGRESS); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java new file mode 100644 index 0000000..f24f217 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectPublicationHandoffRecord; +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 java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ObjectPublicationHandoffRaceTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final ObjectId OBJECT = ObjectId.parse("0123456789abcdefghjkmnpqrs"); + + @Test + void expiredClaimTakeoverRaisesTheFenceAndRejectsTheStaleWorker() { + ObjectPublicationHandoffRecord first = + ObjectPublicationHandoffRecord.claimed( + operation(), + ObjectHandleCodec.stage(ROUTE, OBJECT), + ObjectHandleCodec.version(ROUTE, OBJECT), + "worker-a", + NOW, + NOW.plusSeconds(30)); + ObjectPublicationHandoffRecord takeover = + first.claim("worker-b", NOW.plusSeconds(31), Duration.ofSeconds(30)); + + assertThat(takeover.fence()).isEqualTo(2); + assertThatIllegalStateException() + .isThrownBy( + () -> takeover.renew(1, "worker-a", NOW.plusSeconds(32), Duration.ofSeconds(30))); + } + + private static ObjectOperationKey operation() { + return new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("publication-1")); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java new file mode 100644 index 0000000..c9ce5db --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectStagedObjectRecord; +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.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectScanState; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ObjectScanVerdictFenceTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final ObjectId OBJECT = ObjectId.parse("0123456789abcdefghjkmnpqrs"); + + @Test + void duplicateExactVerdictReplaysButDifferentScannerEvidenceConflicts() { + ObjectStagedObjectRecord pending = staged().verified(NOW).scanPending(NOW); + + ObjectStagedObjectRecord clean = + pending.recordVerdict( + ObjectOperationId.of("scan-1"), "scanner-policy-v1", ObjectScanState.CLEAN, NOW); + + assertThat( + clean.recordVerdict( + ObjectOperationId.of("scan-1"), "scanner-policy-v1", ObjectScanState.CLEAN, NOW)) + .isSameAs(clean); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + clean.recordVerdict( + ObjectOperationId.of("scan-2"), + "scanner-policy-v1", + ObjectScanState.CLEAN, + NOW)); + } + + private static ObjectStagedObjectRecord staged() { + ObjectOperationKey operation = + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("publication-1")); + return ObjectStagedObjectRecord.staged( + operation, + ObjectHandleCodec.stage(ROUTE, OBJECT), + OBJECT, + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + ObjectHandleCodec.version(ROUTE, OBJECT), + new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))), + ObjectMediaType.of("text/plain"), + ObjectPublicationRequirement.SCAN_CLEAN, + NOW); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java new file mode 100644 index 0000000..5ff36c7 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.objectstorage.kernel; + +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectScanState; +import org.junit.jupiter.api.Test; + +class StagedObjectPublicationKernelTest { + + @Test + void scanRequiredPublicationCannotFinalizeWithoutAnExactCleanVerdict() { + assertThatIllegalStateException() + .isThrownBy( + () -> + ObjectScanVerdictPolicy.requireFinalizable( + ObjectPublicationRequirement.SCAN_CLEAN, ObjectScanState.PENDING)); + + ObjectScanVerdictPolicy.requireFinalizable( + ObjectPublicationRequirement.SCAN_CLEAN, ObjectScanState.CLEAN); + ObjectScanVerdictPolicy.requireFinalizable( + ObjectPublicationRequirement.INTEGRITY_VERIFIED, ObjectScanState.NOT_REQUIRED); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java new file mode 100644 index 0000000..54331d7 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +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.request.ObjectPublishRequest; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; +import dev.caskeleton.application.storage.migration.LegacyObjectLocator; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.EnumSet; +import java.util.Map; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("removal") +class LegacyAdoptionApprovalVerifierTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final String HEX_A = "a".repeat(64); + private static final String HEX_B = "b".repeat(64); + private static final String HEX_C = "c".repeat(64); + + @Test + void verifiesCanonicalExactBindingWithTwoDistinctTrustedApprovers() throws Exception { + KeyPair first = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + KeyPair second = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + LegacyAdoptionApprovalCodec codec = new LegacyAdoptionApprovalCodec(); + LegacyObjectAdoptionRequest request = request(); + LegacyAdoptionApprovalDocument unsigned = + document("approver-a", "approver-b", new byte[64], new byte[64]); + byte[] payload = codec.signingPayload(unsigned); + LegacyAdoptionApprovalDocument signed = + document("approver-a", "approver-b", sign(first, payload), sign(second, payload)); + byte[] encoded = codec.encode(signed); + + var verifier = + new Ed25519LegacyAdoptionApprovalVerifier( + codec, + Map.of( + "approver-a", first.getPublic(), + "approver-b", second.getPublic()), + Clock.fixed(NOW, ZoneOffset.UTC), + Duration.ofHours(1)); + var approval = verifier.verify(encoded, request); + + assertThat(approval.operationKey()).isEqualTo(request.publicationRequest().operationKey()); + assertThat(approval.manifestSha256()).isEqualTo(HEX_A); + assertThat(approval.approvalDigest()).matches("[0-9a-f]{64}"); + } + + @Test + void rejectsDuplicateApproverAndAnyBindingTamper() throws Exception { + KeyPair first = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + LegacyAdoptionApprovalCodec codec = new LegacyAdoptionApprovalCodec(); + LegacyAdoptionApprovalDocument unsigned = + document("approver-a", "approver-a", new byte[64], new byte[64]); + byte[] signature = sign(first, codec.signingPayload(unsigned)); + byte[] encoded = codec.encode(document("approver-a", "approver-a", signature, signature)); + var verifier = + new Ed25519LegacyAdoptionApprovalVerifier( + codec, + Map.of("approver-a", first.getPublic()), + Clock.fixed(NOW, ZoneOffset.UTC), + Duration.ofHours(1)); + + assertThatThrownBy(() -> verifier.verify(encoded, request())) + .isInstanceOf(IllegalArgumentException.class); + + encoded[encoded.length - 1] ^= 1; + assertThatThrownBy(() -> verifier.verify(encoded, request())) + .isInstanceOf(IllegalArgumentException.class); + } + + private static LegacyAdoptionApprovalDocument document( + String firstKey, String secondKey, byte[] firstSignature, byte[] secondSignature) { + return new LegacyAdoptionApprovalDocument( + 1, + "poster", + "poster-image-v1", + "adoption-operation-1", + HEX_A, + HEX_B, + HEX_C, + NOW.minusSeconds(60), + NOW.plusSeconds(600), + "nonce-0123456789abcdef", + firstKey, + secondKey, + firstSignature, + secondSignature); + } + + private static byte[] sign(KeyPair key, byte[] payload) throws Exception { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(key.getPrivate()); + signer.update(payload); + return signer.sign(); + } + + private static LegacyObjectAdoptionRequest request() { + return new LegacyObjectAdoptionRequest( + LegacyObjectLocator.of("legacy/posters/image.png"), + publishRequest(), + HEX_A, + HEX_B, + HEX_C, + LegacyObjectAdoptionRequest.Mode.APPLY, + null); + } + + private static ObjectPublishRequest publishRequest() { + return new ObjectPublishRequest( + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("poster-image-v1"), + ObjectOperationId.of("adoption-operation-1")), + ObjectMediaType.of("image/png"), + new ObjectContentIdentity(3, ObjectDigest.sha256(new byte[] {1, 2, 3})), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.noneOf( + dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement.class), + CallBudget.fromNow(Duration.ofSeconds(10)), + CancellationView.never()); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java new file mode 100644 index 0000000..4023024 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.objectstorage.maintenance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement; +import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; +import dev.caskeleton.application.storage.migration.LegacyObjectLocator; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("removal") +class LegacyObjectAdoptionServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final ObjectContentIdentity CONTENT = + new ObjectContentIdentity(3, ObjectDigest.sha256(new byte[] {1, 2, 3})); + private static final ObjectVersionToken VERSION = + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); + + @Test + void reportOnlyInspectsExactEvidenceAndPerformsNoMutation() { + AtomicInteger publications = new AtomicInteger(); + AtomicInteger replays = new AtomicInteger(); + LegacyObjectLocator locator = LegacyObjectLocator.of("legacy/posters/private.png"); + LegacyObjectAdoptionService service = + new LegacyObjectAdoptionService( + ignored -> + new LegacyObjectInspection( + CONTENT, + ObjectMediaType.of("image/png"), + VERSION, + (context, sink) -> sink.write(new byte[] {1, 2, 3}, 0, 3)), + (request, producer) -> { + publications.incrementAndGet(); + throw new AssertionError("report mode must not publish"); + }, + new LegacyAdoptionApprovalReplayStore() { + @Override + public ClaimResult claim(LegacyAdoptionApprovalReplayRecord proposed) { + replays.incrementAndGet(); + return ClaimResult.CLAIMED; + } + + @Override + public void markTerminal( + String nonce, + long expectedRevision, + LegacyAdoptionApprovalReplayRecord terminal) { + throw new AssertionError("report mode must not mutate replay state"); + } + }, + Clock.fixed(NOW, ZoneOffset.UTC)); + + var receipt = + service.adopt( + new LegacyObjectAdoptionRequest( + locator, + publishRequest(), + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + LegacyObjectAdoptionRequest.Mode.REPORT_ONLY, + null)); + + assertThat(receipt.outcome()).isEqualTo(ObjectMutationOutcome.NO_CHANGE); + assertThat(receipt.contentIdentity()).isEqualTo(CONTENT); + assertThat(receipt.adoptedReference()).isNull(); + assertThat(locator.toString()).doesNotContain("legacy/posters"); + assertThat(publications).hasValue(0); + assertThat(replays).hasValue(0); + } + + private static ObjectPublishRequest publishRequest() { + return new ObjectPublishRequest( + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("poster-image-v1"), + ObjectOperationId.of("adoption-operation-1")), + ObjectMediaType.of("image/png"), + CONTENT, + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.noneOf( + dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement.class), + CallBudget.fromNow(Duration.ofSeconds(10)), + CancellationView.never()); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinatorTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinatorTest.java new file mode 100644 index 0000000..4a57af0 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinatorTest.java @@ -0,0 +1,178 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec; +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.ObjectControlRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlVersion; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartSessionRecord; +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.s3.S3ClientPolicy; +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.model.ObjectDigest; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ManagedMultipartCoordinatorTest { + + private static final ObjectRouteToken ROUTE = ObjectRouteToken.parse("0123456789ab"); + private static final DirectTransferSessionId SESSION = + ObjectHandleCodec.multipart(ROUTE, ObjectId.parse("0123456789abcdefghjkmnpqrs")); + + @Test + void freezesSingleVersusMultipartAndEveryS3ResourceBoundary() { + assertThat( + MultipartUploadPlan.compile( + 8 * 1024 * 1024, 8 * 1024 * 1024, 5 * 1024 * 1024, 2, 10 * 1024 * 1024, true) + .mode()) + .isEqualTo(MultipartUploadPlan.Mode.SINGLE); + MultipartUploadPlan multipart = + MultipartUploadPlan.compile( + 12 * 1024 * 1024, 8 * 1024 * 1024, 5 * 1024 * 1024, 2, 10 * 1024 * 1024, true); + assertThat(multipart.mode()).isEqualTo(MultipartUploadPlan.Mode.MULTIPART); + assertThat(multipart.partCount()).isEqualTo(3); + assertThat(multipart.partBytes(1)).isEqualTo(5L * 1024 * 1024); + assertThat(multipart.partBytes(3)).isEqualTo(2L * 1024 * 1024); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> + MultipartUploadPlan.compile( + (long) Integer.MAX_VALUE + 1, + 8 * 1024 * 1024, + 5 * 1024 * 1024, + 1, + 5 * 1024 * 1024, + true)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + MultipartUploadPlan.compile( + 50L * 1024 * 1024 * 1024 + 1, + 1, + S3ClientPolicy.MINIMUM_NON_FINAL_PART_BYTES, + 1, + S3ClientPolicy.MINIMUM_NON_FINAL_PART_BYTES, + false)); + } + + @Test + void immutableShardedPartLedgerReplaysSameEvidenceAndConflictsOnDifferentEvidence() { + InMemoryStore store = new InMemoryStore(); + MultipartPartLedger ledger = new MultipartPartLedger(store, ROUTE, SESSION); + ObjectMultipartPartRecord first = part(1, 10, ObjectDigest.sha256(new byte[] {1})); + + assertThat(ledger.record(first)).isEqualTo(MultipartPartLedger.Decision.RECORDED); + assertThat(ledger.record(first)).isEqualTo(MultipartPartLedger.Decision.REPLAYED); + assertThatThrownBy(() -> ledger.record(part(1, 11, ObjectDigest.sha256(new byte[] {2})))) + .isInstanceOf(ObjectControlConflictException.class); + assertThat(ledger.ordered(1)).containsExactly(first); + assertThat(store.records) + .containsKey( + ObjectControlKeyCodec.multipartPart(ROUTE, SESSION, MultipartPartNumber.of(1))); + } + + @Test + void initiationIntentIsDurableBeforeTheProviderCall() { + InMemoryStore store = new InMemoryStore(); + ObjectMultipartSessionRecord reserved = + session(MultipartUploadState.SESSION_RESERVED, "pending"); + String controlKey = ObjectControlKeyCodec.multipart(ROUTE, SESSION); + store.create(controlKey, reserved); + ManagedMultipartCoordinator coordinator = new ManagedMultipartCoordinator(store); + + ObjectMultipartSessionRecord accepting = + coordinator.initiate( + controlKey, + reserved, + () -> { + ObjectMultipartSessionRecord current = + (ObjectMultipartSessionRecord) store.read(controlKey).orElseThrow().record(); + assertThat(current.multipartState()) + .isEqualTo(MultipartUploadState.INITIATE_IN_PROGRESS); + return "private-upload-id"; + }, + Instant.parse("2026-07-28T00:00:01Z")); + + assertThat(accepting.multipartState()).isEqualTo(MultipartUploadState.ACCEPTING_PARTS); + assertThat(accepting.providerSessionEvidence()).isEqualTo("private-upload-id"); + } + + private static ObjectMultipartSessionRecord session( + MultipartUploadState state, String providerEvidence) { + return new ObjectMultipartSessionRecord( + 1, + "session-1", + 1, + SESSION, + state, + DirectGrantSessionState.SESSION_RESERVED, + providerEvidence, + 0, + Instant.parse("2026-07-28T00:00:00Z")); + } + + private static ObjectMultipartPartRecord part(int partNumber, long size, ObjectDigest digest) { + MultipartPartNumber number = MultipartPartNumber.of(partNumber); + return new ObjectMultipartPartRecord( + 1, + "part-" + partNumber, + 1, + SESSION, + number, + ObjectHandleCodec.partReceipt(SESSION, number, digest), + size, + digest, + "private-etag-" + partNumber, + Instant.parse("2026-07-28T00:00:00Z")); + } + + private static final class InMemoryStore implements ObjectControlStore { + + private final Map records = new HashMap<>(); + private long version; + + @Override + public Optional read(String key) { + return Optional.ofNullable(records.get(key)); + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + if (records.containsKey(key)) { + throw new ObjectControlConflictException("exists"); + } + StoredControlRecord stored = + new StoredControlRecord(ObjectControlVersion.of(Long.toString(++version)), record); + records.put(key, stored); + return stored; + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + StoredControlRecord current = records.get(key); + if (current == null || !current.version().equals(mutation.expectedVersion())) { + throw new ObjectControlConflictException("stale"); + } + StoredControlRecord stored = + new StoredControlRecord( + ObjectControlVersion.of(Long.toString(++version)), mutation.replacement()); + records.put(key, stored); + return stored; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartResponseLossTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartResponseLossTest.java new file mode 100644 index 0000000..325e11d --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartResponseLossTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.objectstorage.multipart; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class MultipartResponseLossTest { + + @Test + void initiateLossAdoptsOnlyOneExactBoundedCandidate() { + MultipartOperationResolver resolver = new MultipartOperationResolver(); + Instant lower = Instant.parse("2026-07-28T00:00:00Z"); + Instant upper = lower.plusSeconds(30); + MultipartOperationResolver.UploadCandidate exact = + new MultipartOperationResolver.UploadCandidate( + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + "private-upload-id", + lower.plusSeconds(1)); + + assertThat(resolver.resolveInitiate(exact.canonicalDataKey(), lower, upper, List.of(exact))) + .isEqualTo( + new MultipartOperationResolver.InitiateResolution( + ObjectStorageProvider.Resolution.APPLIED, Optional.of("private-upload-id"))); + assertThat( + resolver.resolveInitiate(exact.canonicalDataKey(), lower, upper, List.of(exact, exact))) + .isEqualTo( + new MultipartOperationResolver.InitiateResolution( + ObjectStorageProvider.Resolution.INDETERMINATE, Optional.empty())); + assertThat(resolver.resolveInitiate(exact.canonicalDataKey(), lower, upper, List.of())) + .isEqualTo( + new MultipartOperationResolver.InitiateResolution( + ObjectStorageProvider.Resolution.INDETERMINATE, Optional.empty())); + } + + @Test + void completionLossUsesExactHeadAndNeverRetriesAnOldUploadId() { + MultipartOperationResolver resolver = new MultipartOperationResolver(); + ObjectContentIdentity expected = + new ObjectContentIdentity(3, ObjectDigest.sha256(new byte[] {1, 2, 3})); + ObjectStorageProvider.StoredData stored = + new ObjectStorageProvider.StoredData( + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + expected, + dev.caskeleton.adapter.outbound.objectstorage.s3.S3ObjectEvidenceMapper.versionForKey( + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001")); + + assertThat(resolver.resolveCompletion(Optional.of(stored), expected)) + .isEqualTo(ObjectStorageProvider.Resolution.APPLIED); + assertThat( + resolver.resolveCompletion( + Optional.of( + new ObjectStorageProvider.StoredData( + stored.canonicalDataKey(), + new ObjectContentIdentity(4, ObjectDigest.sha256(new byte[] {1, 2, 3, 4})), + stored.exactVersion())), + expected)) + .isEqualTo(ObjectStorageProvider.Resolution.PRECONDITION_FAILED); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java new file mode 100644 index 0000000..51d6c73 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.outbound.objectstorage.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +class ObjectStorageReadinessRegistryTest { + + private static final Set AVAILABLE_TASKS = + Set.of(":application-core:check", ":adapter:outbound:objectstorage:check"); + private static final Set KNOWN_PROVIDERS = Set.of("filesystem-local-dev"); + + @Test + void registryHasTheFrozenNineCardsAndOnlyEvidencedClaims() throws Exception { + Map registry = loadRegistry(); + assertThat(registry).containsOnlyKeys("schema_version", "claims"); + assertThat(registry.get("schema_version")).isEqualTo(1); + + List claims = claims(registry); + assertThat(claims) + .extracting(claim -> claim.card().cardId()) + .containsExactlyInAnyOrderElementsOf(ObjectStorageCapabilityCard.cardIds()); + assertThat(claims).allSatisfy(claim -> claim.validate(KNOWN_PROVIDERS, AVAILABLE_TASKS)); + assertThat(claims) + .filteredOn(claim -> claim.claimedLevel() == ObjectStorageReadinessLevel.R1) + .extracting(claim -> claim.card().cardId()) + .containsExactlyInAnyOrder( + "object-storage-managed-upload-single", "object-storage-managed-download"); + assertThat(claims) + .filteredOn( + claim -> + claim.card() == ObjectStorageCapabilityCard.DIRECT_UPLOAD_SINGLE + || claim.card() == ObjectStorageCapabilityCard.DIRECT_UPLOAD_MULTIPART + || claim.card() == ObjectStorageCapabilityCard.DIRECT_DOWNLOAD + || claim.card() == ObjectStorageCapabilityCard.QUARANTINE_PUBLICATION + || claim.card() == ObjectStorageCapabilityCard.RETENTION + || claim.card() == ObjectStorageCapabilityCard.RECONCILIATION) + .allMatch(claim -> claim.claimedLevel() == ObjectStorageReadinessLevel.R0); + } + + @Test + void validatorRejectsUnknownGlobalOrOverclaimedRows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ObjectStorageCapabilityCard.parse("objectstorage")); + assertThatIllegalArgumentException() + .isThrownBy(() -> ObjectStorageCapabilityCard.parse("object-storage-unknown")); + + ObjectStorageCapabilityEvidence missingIdentity = + evidence( + ObjectStorageCapabilityCard.MANAGED_UPLOAD_SINGLE, + ObjectStorageReadinessLevel.R1, + "", + "", + Optional.empty(), + List.of("bounded local stream")); + assertThatIllegalArgumentException() + .isThrownBy(() -> missingIdentity.validate(KNOWN_PROVIDERS, AVAILABLE_TASKS)); + + ObjectStorageCapabilityEvidence expiredR2 = + evidence( + ObjectStorageCapabilityCard.MANAGED_UPLOAD_SINGLE, + ObjectStorageReadinessLevel.R2, + "filesystem-local-dev", + "local-managed-integrity", + Optional.empty(), + List.of("single-process only")); + assertThatIllegalArgumentException() + .isThrownBy(() -> expiredR2.validate(KNOWN_PROVIDERS, AVAILABLE_TASKS)); + + ObjectStorageCapabilityEvidence unknownProvider = + evidence( + ObjectStorageCapabilityCard.MANAGED_DOWNLOAD, + ObjectStorageReadinessLevel.R1, + "unknown-provider", + "local-managed-integrity", + Optional.empty(), + List.of("local only")); + assertThatIllegalArgumentException() + .isThrownBy(() -> unknownProvider.validate(KNOWN_PROVIDERS, AVAILABLE_TASKS)); + + ObjectStorageCapabilityEvidence absentTask = + new ObjectStorageCapabilityEvidence( + ObjectStorageCapabilityCard.MANAGED_DOWNLOAD, + "filesystem-local-dev", + "jdk-21", + "local-managed-integrity", + ObjectStorageReadinessLevel.R1, + "batch-b", + Optional.empty(), + List.of(":missing:readinessTask"), + List.of("local only")); + assertThatIllegalArgumentException() + .isThrownBy(() -> absentTask.validate(KNOWN_PROVIDERS, AVAILABLE_TASKS)); + + ObjectStorageCapabilityEvidence noLimitations = + evidence( + ObjectStorageCapabilityCard.MANAGED_DOWNLOAD, + ObjectStorageReadinessLevel.R1, + "filesystem-local-dev", + "local-managed-integrity", + Optional.empty(), + List.of()); + assertThatIllegalArgumentException() + .isThrownBy(() -> noLimitations.validate(KNOWN_PROVIDERS, AVAILABLE_TASKS)); + } + + @SuppressWarnings("unchecked") + private static Map loadRegistry() throws Exception { + Path path = registryPath(); + try (InputStream input = Files.newInputStream(path)) { + return new Yaml().load(input); + } + } + + private static Path registryPath() { + Path directory = Path.of("").toAbsolutePath(); + for (int depth = 0; depth < 8 && directory != null; depth++) { + Path candidate = + directory.resolve("docs/registries/object-storage-readiness.yaml").normalize(); + if (Files.isRegularFile(candidate)) { + return candidate; + } + directory = directory.getParent(); + } + throw new IllegalStateException("object-storage readiness registry is absent"); + } + + @SuppressWarnings("unchecked") + private static List claims(Map registry) { + return ((List>) registry.get("claims")) + .stream().map(ObjectStorageReadinessRegistryTest::claim).toList(); + } + + @SuppressWarnings("unchecked") + private static ObjectStorageCapabilityEvidence claim(Map row) { + assertThat(row) + .containsOnlyKeys( + "card_id", + "provider_type", + "provider_version", + "destination_profile", + "claimed_level", + "evidence_revision", + "evidence_expires_on", + "required_tasks", + "limitations"); + String expiry = (String) row.get("evidence_expires_on"); + return new ObjectStorageCapabilityEvidence( + ObjectStorageCapabilityCard.parse((String) row.get("card_id")), + (String) row.get("provider_type"), + (String) row.get("provider_version"), + (String) row.get("destination_profile"), + ObjectStorageReadinessLevel.valueOf((String) row.get("claimed_level")), + (String) row.get("evidence_revision"), + expiry == null || expiry.isBlank() + ? Optional.empty() + : Optional.of(LocalDate.parse(expiry)), + (List) row.get("required_tasks"), + (List) row.get("limitations")); + } + + private static ObjectStorageCapabilityEvidence evidence( + ObjectStorageCapabilityCard card, + ObjectStorageReadinessLevel level, + String provider, + String profile, + Optional expiry, + List limitations) { + return new ObjectStorageCapabilityEvidence( + card, + provider, + "jdk-21", + profile, + level, + "batch-b", + expiry, + List.of(":adapter:outbound:objectstorage:check"), + limitations); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java new file mode 100644 index 0000000..75bb915 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +import java.net.URI; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.services.s3.S3AsyncClient; + +class S3AsyncClientFactoryTest { + + @Test + void buildsExplicitNettyAsyncClientWithoutCrtAndOwnsCloseOrder() { + try (S3ClientLifecycle lifecycle = new S3AsyncClientFactory().create(validPolicy().build())) { + assertThat(lifecycle.client().getClass().getName()).contains("DefaultS3AsyncClient"); + assertThat(lifecycle.httpClient().getClass().getName()) + .contains("NettyNioAsyncHttpClient") + .doesNotContainIgnoringCase("crt"); + } + + S3AsyncClient client = mock(S3AsyncClient.class); + SdkAsyncHttpClient http = mock(SdkAsyncHttpClient.class); + S3ClientLifecycle lifecycle = new S3ClientLifecycle(client, http, Duration.ofSeconds(1)); + lifecycle.close(); + lifecycle.close(); + + InOrder order = inOrder(client, http); + order.verify(client).close(); + order.verify(http).close(); + order.verifyNoMoreInteractions(); + } + + @Test + void rejectsMissingNonPositiveAndContradictoryTimeoutPoolAndRetryPolicy() { + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().apiCallTimeout(null).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().apiCallAttemptTimeout(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + validPolicy() + .apiCallTimeout(Duration.ofSeconds(1)) + .apiCallAttemptTimeout(Duration.ofSeconds(2)) + .build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().connectionTimeout(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().tlsNegotiationTimeout(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().connectionAcquireTimeout(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().readTimeout(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().writeTimeout(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().maximumConcurrency(0).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().maximumPendingAcquires(0).build()); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + validPolicy() + .apiCallTimeout(Duration.ofSeconds(3)) + .apiCallAttemptTimeout(Duration.ofSeconds(2)) + .maximumAttempts(2) + .build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().retryBaseDelay(Duration.ZERO).build()); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + validPolicy() + .retryBaseDelay(Duration.ofSeconds(2)) + .retryMaximumBackoff(Duration.ofSeconds(1)) + .build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().shutdownGrace(Duration.ZERO).build()); + } + + @Test + void rejectsUnsafeEndpointAndPartialStaticCredentialsWithoutExposingSecrets() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + validPolicy() + .endpointOverride(URI.create("http://s3.us-east-1.amazonaws.com")) + .build()); + for (String endpoint : + java.util.List.of( + "https://user@example.test", + "https://example.test/path?query=yes", + "https://example.test/path#fragment")) { + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().endpointOverride(URI.create(endpoint)).build()); + } + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().staticCredentials("access", null).build()); + assertThatIllegalArgumentException() + .isThrownBy(() -> validPolicy().staticCredentials(null, "secret").build()); + + S3ClientPolicy policy = + validPolicy().staticCredentials("local-access", "private-secret").build(); + assertThat(policy.toString()).doesNotContain("local-access").doesNotContain("private-secret"); + } + + static S3ClientPolicy.Builder validPolicy() { + return S3ClientPolicy.builder() + .region("us-east-1") + .apiCallTimeout(Duration.ofSeconds(10)) + .apiCallAttemptTimeout(Duration.ofSeconds(2)) + .connectionTimeout(Duration.ofMillis(500)) + .tlsNegotiationTimeout(Duration.ofMillis(500)) + .connectionAcquireTimeout(Duration.ofMillis(500)) + .readTimeout(Duration.ofSeconds(1)) + .writeTimeout(Duration.ofSeconds(1)) + .maximumConcurrency(8) + .maximumPendingAcquires(16) + .maximumAttempts(3) + .retryBaseDelay(Duration.ofMillis(100)) + .retryMaximumBackoff(Duration.ofMillis(500)) + .shutdownGrace(Duration.ofSeconds(5)) + .pathStyleAccess(false); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridgeTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridgeTest.java new file mode 100644 index 0000000..d389c9a --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridgeTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +class S3AsyncRequestBodyBridgeTest { + + @Test + void streamsOnceOffTheSubscriberThreadWithOneChunkOfProducerLead() throws Exception { + byte[] content = new byte[193]; + for (int index = 0; index < content.length; index++) { + content[index] = (byte) index; + } + ObjectStorageProviderOperation operation = + ObjectStorageProviderContract.operation( + 501, + content, + dev.caskeleton.application.objectstorage.content.CancellationView.never()); + AtomicInteger invocations = new AtomicInteger(); + AtomicReference producerThread = new AtomicReference<>(); + var executor = + Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "s3-content-worker")); + try { + S3AsyncRequestBodyBridge bridge = + new S3AsyncRequestBodyBridge( + operation, + (context, sink) -> { + invocations.incrementAndGet(); + producerThread.set(Thread.currentThread().getName()); + ObjectStorageProviderContract.producer(content).produce(context, sink); + }, + executor); + CollectingSubscriber subscriber = new CollectingSubscriber(); + + bridge.subscribe(subscriber); + assertThat(subscriber.completed.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(subscriber.content()).containsExactly(content); + assertThat(invocations).hasValue(1); + assertThat(producerThread.get()).startsWith("s3-content-worker"); + assertThat(bridge.maximumBufferedBytesObserved()).isLessThanOrEqualTo(64); + assertThat(bridge.observedIdentity().get(1, TimeUnit.SECONDS)) + .isEqualTo(operation.expectedContentIdentity()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void cancellationAndDigestMismatchFailWithoutProducerReplay() throws Exception { + byte[] content = {1, 2, 3}; + ObjectStorageProviderOperation operation = + ObjectStorageProviderContract.operation( + 502, + content, + dev.caskeleton.application.objectstorage.content.CancellationView.never()); + var executor = Executors.newSingleThreadExecutor(); + try { + S3AsyncRequestBodyBridge cancelled = + new S3AsyncRequestBodyBridge( + operation, ObjectStorageProviderContract.producer(content), executor); + Subscriber subscriber = + new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.cancel(); + } + + @Override + public void onNext(ByteBuffer item) {} + + @Override + public void onError(Throwable throwable) {} + + @Override + public void onComplete() {} + }; + cancelled.subscribe(subscriber); + assertThatThrownBy(() -> cancelled.observedIdentity().get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(ObjectStorageProviderException.class); + + ObjectStorageProviderOperation wrong = + new ObjectStorageProviderOperation( + operation.operationKey(), + operation.canonicalDataKey(), + new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity( + content.length, + dev.caskeleton.application.objectstorage.model.ObjectDigest.sha256( + new byte[] {9})), + operation.mediaType(), + dev.caskeleton.application.outbound.CallBudget.fromNow(Duration.ofSeconds(5)), + operation.cancellation(), + operation.maximumChunkBytes(), + operation.maximumObjectBytes()); + S3AsyncRequestBodyBridge mismatch = + new S3AsyncRequestBodyBridge( + wrong, ObjectStorageProviderContract.producer(content), executor); + CollectingSubscriber mismatchSubscriber = new CollectingSubscriber(); + mismatch.subscribe(mismatchSubscriber); + + assertThat(mismatchSubscriber.completed.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(mismatchSubscriber.failure).isInstanceOf(ObjectStorageProviderException.class); + } finally { + executor.shutdownNow(); + } + } + + private static final class CollectingSubscriber implements Subscriber { + + private final List bytes = new ArrayList<>(); + private final CountDownLatch completed = new CountDownLatch(1); + private volatile Throwable failure; + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(1); + this.subscription = subscription; + } + + private Subscription subscription; + + @Override + public void onNext(ByteBuffer item) { + while (item.hasRemaining()) { + bytes.add(item.get()); + } + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + failure = throwable; + completed.countDown(); + } + + @Override + public void onComplete() { + completed.countDown(); + } + + byte[] content() { + byte[] result = new byte[bytes.size()]; + for (int index = 0; index < bytes.size(); index++) { + result[index] = bytes.get(index); + } + return result; + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridgeTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridgeTest.java new file mode 100644 index 0000000..ae891ad --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridgeTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; + +class S3AsyncResponseBodyBridgeTest { + + @Test + void deliversAnExactBoundedBodyOnTheWorkerAndInvalidatesTheSource() throws Exception { + byte[] content = new byte[129]; + for (int index = 0; index < content.length; index++) { + content[index] = (byte) (index * 7); + } + var stored = + new ObjectStorageProvider.StoredData( + ObjectStorageProviderContract.operation(511, content, CancellationView.never()) + .canonicalDataKey(), + new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity( + content.length, + dev.caskeleton.application.objectstorage.model.ObjectDigest.sha256(content)), + S3ObjectEvidenceMapper.versionForKey( + ObjectStorageProviderContract.operation(511, content, CancellationView.never()) + .canonicalDataKey())); + ObjectStorageProvider.ReadOperation operation = + new ObjectStorageProvider.ReadOperation( + stored.canonicalDataKey(), + stored.exactVersion(), + stored.contentIdentity(), + ObjectReadRange.of(0, content.length), + CallBudget.fromNow(Duration.ofSeconds(5)), + CancellationView.never(), + 64, + 1024); + ByteArrayOutputStream delivered = new ByteArrayOutputStream(); + var escaped = + new java.util.concurrent.atomic.AtomicReference< + dev.caskeleton.application.objectstorage.content.ObjectChunkSource>(); + var executor = + Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "s3-response-worker")); + try { + S3AsyncResponseBodyBridge bridge = + new S3AsyncResponseBodyBridge<>( + operation, + (context, source) -> { + assertThat(Thread.currentThread().getName()).startsWith("s3-response-worker"); + escaped.set(source); + byte[] buffer = new byte[31]; + for (int count = source.read(buffer, 0, buffer.length); + count != -1; + count = source.read(buffer, 0, buffer.length)) { + delivered.write(buffer, 0, count); + } + }, + executor, + response -> assertThat(response.contentLength()).isEqualTo((long) content.length)); + var result = bridge.prepare(); + bridge.onResponse(GetObjectResponse.builder().contentLength((long) content.length).build()); + bridge.onStream( + SdkPublisher.fromIterable( + List.of( + ByteBuffer.wrap(java.util.Arrays.copyOfRange(content, 0, 64)), + ByteBuffer.wrap(java.util.Arrays.copyOfRange(content, 64, 128)), + ByteBuffer.wrap(java.util.Arrays.copyOfRange(content, 128, 129))))); + + assertThat(result.get(5, TimeUnit.SECONDS).contentLength()).isEqualTo(content.length); + assertThat(delivered.toByteArray()).containsExactly(content); + assertThat(bridge.maximumBufferedBytesObserved()).isLessThanOrEqualTo(64); + assertThatThrownBy(() -> escaped.get().read(new byte[1], 0, 1)) + .isInstanceOf(IllegalStateException.class); + } finally { + executor.shutdownNow(); + } + } + + @Test + void truncatedAndOversizedSdkChunksFailClosed() throws Exception { + byte[] expected = new byte[65]; + var operation = + ObjectStorageProviderContract.operation(512, expected, CancellationView.never()); + ObjectStorageProvider.ReadOperation read = + new ObjectStorageProvider.ReadOperation( + operation.canonicalDataKey(), + S3ObjectEvidenceMapper.versionForKey(operation.canonicalDataKey()), + operation.expectedContentIdentity(), + ObjectReadRange.of(0, expected.length), + CallBudget.fromNow(Duration.ofSeconds(5)), + CancellationView.never(), + 64, + 1024); + var executor = Executors.newSingleThreadExecutor(); + try { + S3AsyncResponseBodyBridge truncated = + new S3AsyncResponseBodyBridge<>( + read, + (context, source) -> { + byte[] buffer = new byte[64]; + while (source.read(buffer, 0, buffer.length) != -1) {} + }, + executor, + ignored -> {}); + var truncatedResult = truncated.prepare(); + truncated.onResponse(GetObjectResponse.builder().contentLength(65L).build()); + truncated.onStream(SdkPublisher.fromIterable(List.of(ByteBuffer.wrap(new byte[64])))); + assertThatThrownBy(() -> truncatedResult.get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(RuntimeException.class); + + S3AsyncResponseBodyBridge oversized = + new S3AsyncResponseBodyBridge<>(read, (context, source) -> {}, executor, ignored -> {}); + var oversizedResult = oversized.prepare(); + oversized.onResponse(GetObjectResponse.builder().contentLength(65L).build()); + oversized.onStream(SdkPublisher.fromIterable(List.of(ByteBuffer.wrap(new byte[65])))); + assertThatThrownBy(() -> oversizedResult.get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(RuntimeException.class); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java new file mode 100644 index 0000000..d204191 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.ObjectControlVersion; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord; +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.nio.ByteBuffer; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +class S3ConditionalObjectControlStoreTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC); + private static final String KEY = + "control/v1/references/0123456789ab/01/" + + "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"; + private static final CanonicalJsonObjectControlRecordCodec CODEC = + new CanonicalJsonObjectControlRecordCodec(); + + @Test + void createAndCasUseExactConditionalHeadersAndPrivateEtags() { + S3AsyncClient client = mock(S3AsyncClient.class); + ArgumentCaptor put = ArgumentCaptor.forClass(PutObjectRequest.class); + when(client.putObject(put.capture(), any(AsyncRequestBody.class))) + .thenReturn( + CompletableFuture.completedFuture( + PutObjectResponse.builder().eTag("\"etag-1\"").build()), + CompletableFuture.completedFuture( + PutObjectResponse.builder().eTag("\"etag-2\"").build())); + S3ConditionalObjectControlStore store = + new S3ConditionalObjectControlStore(client, awsBinding(), CODEC); + ObjectReferenceRecord first = record(1, PublishedReferenceState.PUBLISHED); + + ObjectControlStore.StoredControlRecord created = store.create(KEY, first); + ObjectReferenceRecord retired = record(2, PublishedReferenceState.RETIRED); + ObjectControlStore.StoredControlRecord updated = + store.compareAndSet(KEY, new ObjectControlMutation(created.version(), retired)); + + assertThat(put.getAllValues().get(0).ifNoneMatch()).isEqualTo("*"); + assertThat(put.getAllValues().get(0).ifMatch()).isNull(); + assertThat(put.getAllValues().get(1).ifMatch()).isEqualTo("\"etag-1\""); + assertThat(put.getAllValues().get(1).ifNoneMatch()).isNull(); + assertThat(created.version()).isEqualTo(ObjectControlVersion.of("\"etag-1\"")); + assertThat(updated.version()).isEqualTo(ObjectControlVersion.of("\"etag-2\"")); + verify(client, never()).listObjectsV2(any(ListObjectsV2Request.class)); + } + + @Test + void droppedCreateResponseResolvesByExactGetAndDigestComparison() { + S3AsyncClient client = mock(S3AsyncClient.class); + ObjectReferenceRecord record = record(1, PublishedReferenceState.PUBLISHED); + when(client.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))) + .thenReturn( + CompletableFuture.failedFuture( + software.amazon.awssdk.core.exception.SdkClientException.create("response lost"))); + respondToGet(client, CODEC.encode(record), "\"resolved-etag\""); + S3ConditionalObjectControlStore store = + new S3ConditionalObjectControlStore(client, awsBinding(), CODEC); + + ObjectControlStore.StoredControlRecord resolved = store.create(KEY, record); + + assertThat(resolved.record()).isEqualTo(record); + assertThat(resolved.version()).isEqualTo(ObjectControlVersion.of("\"resolved-etag\"")); + } + + @Test + void staleWriterConflictAndCorruptControlNeverBecomeAbsence() { + S3AsyncClient client = mock(S3AsyncClient.class); + when(client.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))) + .thenReturn(CompletableFuture.failedFuture(S3Exception.builder().statusCode(412).build())); + S3ConditionalObjectControlStore store = + new S3ConditionalObjectControlStore(client, awsBinding(), CODEC); + + assertThatThrownBy( + () -> + store.compareAndSet( + KEY, + new ObjectControlMutation( + ObjectControlVersion.of("\"stale\""), + record(2, PublishedReferenceState.RETIRED)))) + .isInstanceOf(ObjectControlConflictException.class); + + respondToGet(client, new byte[] {1, 2, 3}, "\"corrupt\""); + assertThatThrownBy(() -> store.read(KEY)) + .isInstanceOf( + dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlCorruptionException + .class); + } + + @Test + void exact404IsTheOnlyAbsentRead() { + S3AsyncClient client = mock(S3AsyncClient.class); + when(client.getObject( + any(GetObjectRequest.class), + org.mockito.ArgumentMatchers.>any())) + .thenReturn(CompletableFuture.failedFuture(S3Exception.builder().statusCode(404).build())); + S3ConditionalObjectControlStore store = + new S3ConditionalObjectControlStore(client, awsBinding(), CODEC); + + assertThat(store.read(KEY)).isEmpty(); + } + + private static S3ProviderBinding awsBinding() { + return S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + CLOCK); + } + + private static ObjectReferenceRecord record(long revision, PublishedReferenceState state) { + return new ObjectReferenceRecord( + 1, + "reference-1", + revision, + ObjectReference.parse("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"), + ObjectId.parse("0123456789abcdefghjkmnpqrs"), + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"), + state, + Instant.parse("2026-07-28T00:00:00Z").plusSeconds(revision)); + } + + private static void respondToGet(S3AsyncClient client, byte[] encoded, String eTag) { + when(client.getObject( + any(GetObjectRequest.class), + org.mockito.ArgumentMatchers.>any())) + .thenAnswer( + invocation -> { + @SuppressWarnings("unchecked") + AsyncResponseTransformer transformer = + invocation.getArgument(1); + CompletableFuture result = transformer.prepare(); + transformer.onResponse( + GetObjectResponse.builder() + .contentLength((long) encoded.length) + .eTag(eTag) + .build()); + transformer.onStream(SdkPublisher.fromIterable(List.of(ByteBuffer.wrap(encoded)))); + return result; + }); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProviderTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProviderTest.java new file mode 100644 index 0000000..65e05b1 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProviderTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord; +import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartSessionRecord; +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectGrantProvider; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import java.net.URL; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedUploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.model.UploadPartPresignRequest; + +class S3DirectMultipartProviderTest { + + @Test + void presignsOneExactPartWithoutReturningTheProviderUploadIdentity() throws Exception { + S3Presigner presigner = mock(S3Presigner.class); + PresignedUploadPartRequest signed = mock(PresignedUploadPartRequest.class); + when(signed.url()).thenReturn(new URL("https://storage.example.test/part?signature=secret")); + when(signed.expiration()).thenReturn(Instant.parse("2026-07-28T00:05:00Z")); + when(signed.signedHeaders()).thenReturn(Map.of("x-amz-checksum-sha256", List.of("digest"))); + ArgumentCaptor request = + ArgumentCaptor.forClass(UploadPartPresignRequest.class); + when(presigner.presignUploadPart(request.capture())).thenReturn(signed); + S3DirectMultipartProvider provider = + new S3DirectMultipartProvider( + presigner, + mock(S3AsyncClient.class), + S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + java.time.Clock.fixed( + Instant.parse("2026-07-28T00:00:00Z"), java.time.ZoneOffset.UTC))); + ObjectDirectMultipartSessionRecord session = + org.mockito.Mockito.mock(ObjectDirectMultipartSessionRecord.class); + when(session.providerSessionEvidence()).thenReturn("private-upload-id"); + when(session.canonicalDataKey()) + .thenReturn("data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001"); + ObjectDirectMultipartGrantRecord grant = + org.mockito.Mockito.mock(ObjectDirectMultipartGrantRecord.class); + when(grant.partNumber()).thenReturn(MultipartPartNumber.of(1)); + when(grant.exactPartLength()).thenReturn(5L * 1024 * 1024); + when(grant.expectedPartDigest()) + .thenReturn( + ObjectDigest.of( + dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")); + when(grant.signingTime()).thenReturn(Instant.parse("2026-07-28T00:00:00Z")); + when(grant.expiresAt()).thenReturn(Instant.parse("2026-07-28T00:05:00Z")); + + DirectGrantProvider.DirectGrantMaterial material = provider.signPart(session, grant); + + UploadPartRequest part = request.getValue().uploadPartRequest(); + assertThat(part.uploadId()).isEqualTo("private-upload-id"); + assertThat(part.partNumber()).isEqualTo(1); + assertThat(part.contentLength()).isEqualTo(5L * 1024 * 1024); + assertThat(material.toString()).doesNotContain("private-upload-id", "signature=secret"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProviderTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProviderTest.java new file mode 100644 index 0000000..b707951 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProviderTest.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec; +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectGrantProvider; +import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectTransferSessionRecord; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken; +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.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +class S3DirectTransferProviderTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final ObjectContentIdentity CONTENT = + new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))); + + @Test + void signsExactChecksumMediaEncryptionLengthAndCreateOnlyPutAndVerifiesWithHead() + throws Exception { + S3Presigner presigner = mock(S3Presigner.class); + PresignedPutObjectRequest signed = mock(PresignedPutObjectRequest.class); + when(signed.url()).thenReturn(new URL("https://storage.example.test/key?signature=secret")); + when(signed.expiration()).thenReturn(NOW.plusSeconds(300)); + when(signed.signedHeaders()) + .thenReturn(Map.of("x-amz-checksum-sha256", List.of(CONTENT.fullDigest().base64Value()))); + ArgumentCaptor signing = + ArgumentCaptor.forClass(PutObjectPresignRequest.class); + when(presigner.presignPutObject(signing.capture())).thenReturn(signed); + + S3AsyncClient client = mock(S3AsyncClient.class); + ArgumentCaptor head = ArgumentCaptor.forClass(HeadObjectRequest.class); + when(client.headObject(head.capture())) + .thenReturn( + CompletableFuture.completedFuture( + HeadObjectResponse.builder() + .contentLength(5L) + .checksumSHA256(CONTENT.fullDigest().base64Value()) + .metadata( + Map.of( + S3ChecksumPolicy.LOGICAL_SHA_256_METADATA, + CONTENT.fullDigest().base64Value())) + .serverSideEncryption(ServerSideEncryption.AES256) + .eTag("\"private-etag\"") + .build())); + S3DirectTransferProvider provider = + new S3DirectTransferProvider(presigner, client, awsBinding()); + DirectTransferSessionRecord session = session(); + + DirectGrantProvider.DirectGrantMaterial grant = provider.signUpload(session); + DirectGrantProvider.VerifiedUpload verified = provider.verifyUpload(session); + + PutObjectRequest put = signing.getValue().putObjectRequest(); + assertThat(put.ifNoneMatch()).isEqualTo("*"); + assertThat(put.contentLength()).isEqualTo(5L); + assertThat(put.contentType()).isEqualTo("text/plain"); + assertThat(put.checksumSHA256()).isEqualTo(CONTENT.fullDigest().base64Value()); + assertThat(put.serverSideEncryption()).isEqualTo(ServerSideEncryption.AES256); + assertThat(grant.toString()).doesNotContain("signature=secret", "storage.example.test"); + assertThat(verified.contentIdentity()).isEqualTo(CONTENT); + assertThat(head.getValue().checksumModeAsString()).isEqualTo("ENABLED"); + } + + private static DirectTransferSessionRecord session() { + return new DirectTransferSessionRecord( + operation(), + ObjectHandleCodec.directUpload( + ObjectRouteToken.parse("0123456789ab"), ObjectId.parse("0123456789abcdefghjkmnpqrs")), + DirectGrantSessionState.GRANT_PREPARED, + 1, + "a".repeat(64), + NOW, + NOW.plusSeconds(300), + "credential-v1", + "reference-v1", + "data/v1/0123456789ab/01/0123456789abcdefghjkmnpqrs/0000000000000001", + CONTENT, + ObjectMediaType.of("text/plain"), + null, + NOW); + } + + private static ObjectOperationKey operation() { + return new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-a"), + ObjectOperationId.of("direct-42")); + } + + private static S3ProviderBinding awsBinding() { + return S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + java.time.Clock.fixed(NOW, java.time.ZoneOffset.UTC)); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProviderTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProviderTest.java new file mode 100644 index 0000000..71bd20f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProviderTest.java @@ -0,0 +1,134 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract; +import dev.caskeleton.adapter.outbound.objectstorage.multipart.MultipartCompletionEvidence; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import java.nio.ByteBuffer; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.model.UploadPartResponse; + +class S3ManagedMultipartProviderTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC); + + @Test + void mapsCreatePartAndCreateOnlyCompletionToLowLevelSdkCalls() { + byte[] content = {1, 2, 3}; + ObjectStorageProviderOperation full = + ObjectStorageProviderContract.operation(541, content, CancellationView.never()); + S3AsyncClient client = mock(S3AsyncClient.class); + ArgumentCaptor initiate = + ArgumentCaptor.forClass(CreateMultipartUploadRequest.class); + when(client.createMultipartUpload(initiate.capture())) + .thenReturn( + CompletableFuture.completedFuture( + CreateMultipartUploadResponse.builder().uploadId("private-upload-id").build())); + ArgumentCaptor upload = ArgumentCaptor.forClass(UploadPartRequest.class); + when(client.uploadPart(upload.capture(), any(AsyncRequestBody.class))) + .thenAnswer( + invocation -> { + drain(invocation.getArgument(1)); + return CompletableFuture.completedFuture( + UploadPartResponse.builder() + .eTag("\"part-etag\"") + .checksumSHA256(full.expectedContentIdentity().fullDigest().base64Value()) + .build()); + }); + ArgumentCaptor complete = + ArgumentCaptor.forClass(CompleteMultipartUploadRequest.class); + when(client.completeMultipartUpload(complete.capture())) + .thenReturn( + CompletableFuture.completedFuture( + CompleteMultipartUploadResponse.builder() + .versionId("private-version") + .eTag("\"complete-etag\"") + .checksumSHA256(full.expectedContentIdentity().fullDigest().base64Value()) + .build())); + var executor = Executors.newFixedThreadPool(2); + try { + S3ManagedMultipartProvider provider = + new S3ManagedMultipartProvider(client, awsBinding(), executor); + + S3ManagedMultipartProvider.Session session = provider.initiate(full); + S3ManagedMultipartProvider.Part part = + provider.uploadPart( + session, + MultipartPartNumber.of(1), + full, + ObjectStorageProviderContract.producer(content), + true); + MultipartCompletionEvidence evidence = + provider.complete(session, List.of(part), full.expectedContentIdentity()); + + assertThat(initiate.getValue().serverSideEncryption()).isEqualTo(ServerSideEncryption.AES256); + assertThat(initiate.getValue().checksumAlgorithm()).isEqualTo(ChecksumAlgorithm.SHA256); + assertThat(upload.getValue().partNumber()).isEqualTo(1); + assertThat(upload.getValue().checksumSHA256()) + .isEqualTo(full.expectedContentIdentity().fullDigest().base64Value()); + assertThat(complete.getValue().ifNoneMatch()).isEqualTo("*"); + assertThat(complete.getValue().mpuObjectSize()).isEqualTo(content.length); + assertThat(complete.getValue().multipartUpload().parts()) + .extracting(software.amazon.awssdk.services.s3.model.CompletedPart::eTag) + .containsExactly("\"part-etag\""); + assertThat(evidence.contentIdentity()).isEqualTo(full.expectedContentIdentity()); + assertThat(evidence.providerVersionId()).isEqualTo("private-version"); + } finally { + executor.shutdownNow(); + } + } + + private static S3ProviderBinding awsBinding() { + return S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + CLOCK); + } + + private static void drain(AsyncRequestBody body) { + body.subscribe( + new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) { + item.position(item.limit()); + } + + @Override + public void onError(Throwable throwable) {} + + @Override + public void onComplete() {} + }); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProviderTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProviderTest.java new file mode 100644 index 0000000..5feb8bd --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProviderTest.java @@ -0,0 +1,253 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.ChecksumMode; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; + +class S3ManagedObjectProviderTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC); + + @Test + void mapsImmutablePutAndExactHeadWithoutExposingProviderEvidence() { + byte[] content = {1, 2, 3, 4}; + ObjectStorageProviderOperation operation = + ObjectStorageProviderContract.operation(521, content, CancellationView.never()); + S3AsyncClient client = mock(S3AsyncClient.class); + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + when(client.putObject(request.capture(), any(AsyncRequestBody.class))) + .thenAnswer( + invocation -> { + drain(invocation.getArgument(1)); + return CompletableFuture.completedFuture( + PutObjectResponse.builder() + .versionId("private-version") + .checksumSHA256( + operation.expectedContentIdentity().fullDigest().base64Value()) + .serverSideEncryption(ServerSideEncryption.AES256) + .build()); + }); + when(client.headObject(any(HeadObjectRequest.class))) + .thenReturn( + CompletableFuture.completedFuture( + head(operation, "private-version", "\"private-etag\""))); + var executor = Executors.newFixedThreadPool(2); + try { + S3ManagedObjectProvider provider = + new S3ManagedObjectProvider(client, awsBinding(), executor); + + ObjectStorageProvider.StoredData stored = + provider.create(operation, ObjectStorageProviderContract.producer(content)); + Optional inspected = + provider.inspect(operation.canonicalDataKey()); + + assertThat(request.getValue().bucket()).isEqualTo("qualified-object-bucket"); + assertThat(request.getValue().key()).isEqualTo(operation.canonicalDataKey()); + assertThat(request.getValue().ifNoneMatch()).isEqualTo("*"); + assertThat(request.getValue().expectedBucketOwner()).isEqualTo("123456789012"); + assertThat(request.getValue().contentLength()).isEqualTo(content.length); + assertThat(request.getValue().checksumAlgorithm()).isEqualTo(ChecksumAlgorithm.SHA256); + assertThat(request.getValue().checksumSHA256()) + .isEqualTo(operation.expectedContentIdentity().fullDigest().base64Value()); + assertThat(request.getValue().serverSideEncryption()).isEqualTo(ServerSideEncryption.AES256); + assertThat(stored.canonicalDataKey()).isEqualTo(operation.canonicalDataKey()); + assertThat(stored.exactVersion()) + .isEqualTo(S3ObjectEvidenceMapper.versionForKey(operation.canonicalDataKey())); + assertThat(inspected).contains(stored); + } finally { + executor.shutdownNow(); + } + } + + @Test + void exactFullAndRangeReadsUseHeadEvidenceAndConditionalGet() { + byte[] content = new byte[100]; + for (int index = 0; index < content.length; index++) { + content[index] = (byte) (index * 3); + } + ObjectStorageProviderOperation operation = + ObjectStorageProviderContract.operation(522, content, CancellationView.never()); + ObjectStorageProvider.StoredData stored = + new ObjectStorageProvider.StoredData( + operation.canonicalDataKey(), + operation.expectedContentIdentity(), + S3ObjectEvidenceMapper.versionForKey(operation.canonicalDataKey())); + S3AsyncClient client = mock(S3AsyncClient.class); + when(client.headObject(any(HeadObjectRequest.class))) + .thenReturn( + CompletableFuture.completedFuture( + head(operation, "private-version", "\"private-etag\""))); + ArgumentCaptor get = ArgumentCaptor.forClass(GetObjectRequest.class); + when(client.getObject( + get.capture(), + org.mockito.ArgumentMatchers + .>any())) + .thenAnswer( + invocation -> { + GetObjectRequest actual = invocation.getArgument(0); + @SuppressWarnings("unchecked") + AsyncResponseTransformer transformer = + invocation.getArgument(1); + CompletableFuture future = transformer.prepare(); + int start = 0; + int end = content.length; + String contentRange = null; + if (actual.range() != null) { + String[] bounds = actual.range().substring("bytes=".length()).split("-"); + start = Integer.parseInt(bounds[0]); + end = Integer.parseInt(bounds[1]) + 1; + contentRange = "bytes " + start + "-" + (end - 1) + "/" + content.length; + } + byte[] selected = java.util.Arrays.copyOfRange(content, start, end); + transformer.onResponse( + GetObjectResponse.builder() + .contentLength((long) selected.length) + .contentRange(contentRange) + .versionId("private-version") + .eTag("\"private-etag\"") + .serverSideEncryption(ServerSideEncryption.AES256) + .build()); + transformer.onStream(SdkPublisher.fromIterable(List.of(ByteBuffer.wrap(selected)))); + return future; + }); + var executor = Executors.newFixedThreadPool(2); + try { + S3ManagedObjectProvider provider = + new S3ManagedObjectProvider(client, awsBinding(), executor); + ByteArrayOutputStream delivered = new ByteArrayOutputStream(); + + provider.transfer( + read(stored, 17, 51), + (context, source) -> { + byte[] buffer = new byte[31]; + for (int count = source.read(buffer, 0, buffer.length); + count != -1; + count = source.read(buffer, 0, buffer.length)) { + delivered.write(buffer, 0, count); + } + }); + + assertThat(get.getValue().ifMatch()).isEqualTo("\"private-etag\""); + assertThat(get.getValue().range()).isEqualTo("bytes=17-67"); + assertThat(get.getValue().checksumMode()).isEqualTo(ChecksumMode.ENABLED); + assertThat(delivered.toByteArray()) + .containsExactly(java.util.Arrays.copyOfRange(content, 17, 68)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void absentHeadIsOptionalEmpty() { + S3AsyncClient client = mock(S3AsyncClient.class); + when(client.headObject(any(HeadObjectRequest.class))) + .thenReturn(CompletableFuture.failedFuture(S3Exception.builder().statusCode(404).build())); + var executor = Executors.newSingleThreadExecutor(); + try { + S3ManagedObjectProvider provider = + new S3ManagedObjectProvider(client, awsBinding(), executor); + String key = + ObjectStorageProviderContract.operation(523, new byte[0], CancellationView.never()) + .canonicalDataKey(); + + assertThat(provider.inspect(key)).isEmpty(); + } finally { + executor.shutdownNow(); + } + } + + private static S3ProviderBinding awsBinding() { + return S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + CLOCK); + } + + private static HeadObjectResponse head( + ObjectStorageProviderOperation operation, String versionId, String eTag) { + return HeadObjectResponse.builder() + .contentLength(operation.expectedContentIdentity().exactLength()) + .checksumSHA256(operation.expectedContentIdentity().fullDigest().base64Value()) + .metadata( + Map.of( + S3ChecksumPolicy.LOGICAL_SHA_256_METADATA, + operation.expectedContentIdentity().fullDigest().base64Value())) + .versionId(versionId) + .eTag(eTag) + .serverSideEncryption(ServerSideEncryption.AES256) + .build(); + } + + private static ObjectStorageProvider.ReadOperation read( + ObjectStorageProvider.StoredData stored, long offset, long length) { + return new ObjectStorageProvider.ReadOperation( + stored.canonicalDataKey(), + stored.exactVersion(), + stored.contentIdentity(), + ObjectReadRange.of(offset, length), + CallBudget.fromNow(Duration.ofSeconds(5)), + CancellationView.never(), + 64, + 1024); + } + + private static void drain(AsyncRequestBody body) { + body.subscribe( + new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) { + item.position(item.limit()); + } + + @Override + public void onError(Throwable throwable) {} + + @Override + public void onComplete() {} + }); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java new file mode 100644 index 0000000..405c3e9 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.objectstorage.contract.ObjectStorageProviderContract; +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 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.CancellationView; +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.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class S3ObjectOperationResolverTest { + + @Test + void resolvesDataCreateFromExactHeadEvidenceWithoutReplayingAProducer() { + byte[] content = {1, 2, 3}; + var operation = ObjectStorageProviderContract.operation(531, content, CancellationView.never()); + ObjectStorageProvider.StoredData stored = + new ObjectStorageProvider.StoredData( + operation.canonicalDataKey(), + operation.expectedContentIdentity(), + S3ObjectEvidenceMapper.versionForKey(operation.canonicalDataKey())); + StubProvider provider = new StubProvider(Optional.of(stored)); + S3ObjectOperationResolver resolver = + new S3ObjectOperationResolver(provider, new MapControlStore()); + + assertThat( + resolver.resolveDataPut( + operation.canonicalDataKey(), operation.expectedContentIdentity())) + .isEqualTo(ObjectStorageProvider.Resolution.APPLIED); + assertThat(provider.mutations).isZero(); + + provider.stored = Optional.empty(); + assertThat( + resolver.resolveDataPut( + operation.canonicalDataKey(), operation.expectedContentIdentity())) + .isEqualTo(ObjectStorageProvider.Resolution.NOT_APPLIED); + } + + @Test + void resolvesControlMutationOnlyFromExactRecordEvidence() { + MapControlStore store = new MapControlStore(); + ObjectReferenceRecord record = record(1, PublishedReferenceState.PUBLISHED); + store.records.put( + "control-key", + new ObjectControlStore.StoredControlRecord(ObjectControlVersion.of("\"etag\""), record)); + S3ObjectOperationResolver resolver = + new S3ObjectOperationResolver(new StubProvider(Optional.empty()), store); + + assertThat(resolver.resolveControlRecord("control-key", record)) + .isEqualTo(ObjectStorageProvider.Resolution.APPLIED); + assertThat( + resolver.resolveControlRecord( + "control-key", record(2, PublishedReferenceState.RETIRED))) + .isEqualTo(ObjectStorageProvider.Resolution.PRECONDITION_FAILED); + assertThat(resolver.resolveControlRecord("absent", record)) + .isEqualTo(ObjectStorageProvider.Resolution.NOT_APPLIED); + } + + private static ObjectReferenceRecord record(long revision, PublishedReferenceState state) { + return new ObjectReferenceRecord( + 1, + "reference-1", + revision, + ObjectReference.parse("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"), + ObjectId.parse("0123456789abcdefghjkmnpqrs"), + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"), + state, + Instant.parse("2026-07-28T00:00:00Z").plusSeconds(revision)); + } + + private static final class StubProvider implements ObjectStorageProvider { + + private Optional stored; + private int mutations; + + private StubProvider(Optional stored) { + this.stored = stored; + } + + @Override + public dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor + descriptor() { + throw new UnsupportedOperationException(); + } + + @Override + public StoredData create( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation + operation, + dev.caskeleton.application.objectstorage.content.ObjectContentProducer producer) { + mutations++; + throw new UnsupportedOperationException(); + } + + @Override + public Optional inspect(String canonicalDataKey) { + return stored; + } + + @Override + public void transfer( + ReadOperation operation, + dev.caskeleton.application.objectstorage.content.ObjectContentConsumer consumer) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retire(RetireOperation operation) { + mutations++; + throw new UnsupportedOperationException(); + } + + @Override + public Resolution resolveCreate( + dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation + operation) { + throw new UnsupportedOperationException(); + } + } + + private static final class MapControlStore implements ObjectControlStore { + + private final Map records = new java.util.HashMap<>(); + + @Override + public Optional read(String key) { + return Optional.ofNullable(records.get(key)); + } + + @Override + public StoredControlRecord create(String key, ObjectControlRecord record) { + throw new UnsupportedOperationException(); + } + + @Override + public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBindingTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBindingTest.java new file mode 100644 index 0000000..23fd021 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBindingTest.java @@ -0,0 +1,468 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageProviderSettings; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidenceSource; +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidenceStatus; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.util.unit.DataSize; + +class S3ProviderBindingTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + + @Test + void acceptsOnlyTheTwoFrozenProviderIdentitiesAndTheirExactVersions() { + S3ProviderBinding aws = + S3ProviderBinding.compile("aws", provider("aws-s3-general-purpose", awsSettings()), CLOCK); + S3ProviderBinding minio = + S3ProviderBinding.compile( + "minio", + provider("s3-compatible-minio-community-release-2024-01-16t16-07-38z", minioSettings()), + CLOCK); + + assertThat(aws.providerType()).isEqualTo(S3ProviderType.AWS_S3_GENERAL_PURPOSE); + assertThat(aws.providerVersion()).isEqualTo(S3ProviderVersion.AWS_SDK_2_30_0); + assertThat(minio.providerType()).isEqualTo(S3ProviderType.MINIO_COMMUNITY_2024_01_16); + assertThat(minio.providerVersion()).isEqualTo(S3ProviderVersion.MINIO_2024_01_16_SDK_2_30_0); + + assertThatIllegalArgumentException() + .isThrownBy(() -> S3ProviderBinding.compile("alias", provider("s3", awsSettings()), CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "alias", provider("s3-compatible", minioSettings()), CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "unknown", + provider( + "aws-s3-general-purpose", withVersion(awsSettings(), "aws-sdk-2.31.0")), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "minio", + provider( + "s3-compatible-minio-community-release-2024-01-16t16-07-38z", + minioDirectClaimSettings()), + CLOCK)); + } + + @Test + void rejectsUnsafeOrIncompleteProviderBindingsBeforeResourceConstruction() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider("aws-s3-general-purpose", withoutExpectedOwner(awsSettings())), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "minio", + provider( + "s3-compatible-minio-community-release-2024-01-16t16-07-38z", + mutate( + minioSettings(), null, "", null, null, null, null, null, null, null)), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), + URI.create("http://s3.amazonaws.com"), + null, + null, + null, + null, + null, + null, + null, + null)), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), + URI.create("https://user@example.test/path?secret=value"), + null, + null, + null, + null, + null, + null, + null, + null)), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), null, null, true, null, null, null, null, null, null)), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), null, null, null, true, null, null, null, null, null)), + CLOCK)); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), + null, + null, + null, + null, + new S3ProviderSettings.Credentials( + "environment", "AWS_ACCESS_KEY_ID", null), + null, + null, + null, + null)), + CLOCK)); + } + + @Test + void exactNamedProfileRequiresCurrentSupportedEvidence() { + S3ProviderBinding binding = + S3ProviderBinding.compile("aws", provider("aws-s3-general-purpose", awsSettings()), CLOCK); + + assertThat(binding.requireSupportedProfile("managed-common").name()) + .isEqualTo("managed-common"); + assertThatIllegalArgumentException() + .isThrownBy(() -> binding.requireSupportedProfile("unqualified")); + + S3ProviderCapabilityConfig expired = + capability( + CapabilityEvidenceStatus.SUPPORTED, + NOW.minusSeconds(1), + identity( + S3ProviderType.AWS_S3_GENERAL_PURPOSE, + S3ProviderVersion.AWS_SDK_2_30_0, + awsSettings().deploymentIdentity())); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), + null, + null, + null, + null, + null, + null, + null, + null, + Map.of("managed-common", expired))), + CLOCK) + .requireSupportedProfile("managed-common")); + S3ProviderCapabilityConfig unverifiable = + capability( + CapabilityEvidenceStatus.UNVERIFIABLE, + NOW.plus(Duration.ofDays(1)), + identity( + S3ProviderType.AWS_S3_GENERAL_PURPOSE, + S3ProviderVersion.AWS_SDK_2_30_0, + awsSettings().deploymentIdentity())); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ProviderBinding.compile( + "aws", + provider( + "aws-s3-general-purpose", + mutate( + awsSettings(), + null, + null, + null, + null, + null, + null, + null, + null, + Map.of("managed-common", unverifiable))), + CLOCK) + .requireSupportedProfile("managed-common")); + } + + static ObjectStorageProviderSettings provider(String type, S3ProviderSettings settings) { + return new ObjectStorageProviderSettings( + type, + settings.providerVersion(), + null, + DataSize.ofMegabytes(32), + DataSize.ofKilobytes(64), + settings); + } + + static S3ProviderSettings awsSettings() { + return settings( + "aws-sdk-2.30.0", + "123456789012", + "arn:aws:s3:::qualified-object-bucket", + null, + "virtual-hosted", + new S3ProviderSettings.Credentials("default-chain", null, null)); + } + + static S3ProviderSettings awsDirectSettings() { + S3ProviderSettings source = awsSettings(); + S3ProviderCapabilityConfig managed = source.capabilityProfiles().get("managed-common"); + java.util.EnumSet capabilities = + java.util.EnumSet.copyOf(managed.capabilities()); + capabilities.add(ObjectStorageProvider.Capability.DIRECT_UPLOAD); + capabilities.add(ObjectStorageProvider.Capability.DIRECT_MULTIPART); + S3ProviderCapabilityConfig direct = + new S3ProviderCapabilityConfig( + managed.status(), + managed.source(), + managed.evidenceDigest(), + managed.observedAt(), + managed.validUntil(), + managed.providerAndDeploymentIdentity(), + capabilities, + List.of("direct primitives; no public endpoint")); + return mutate( + source, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of("direct-single-soft-limit-r1", direct)); + } + + private static S3ProviderSettings minioDirectClaimSettings() { + S3ProviderSettings source = minioSettings(); + S3ProviderCapabilityConfig existing = source.capabilityProfiles().get("managed-common"); + S3ProviderCapabilityConfig invalidClaim = + new S3ProviderCapabilityConfig( + CapabilityEvidenceStatus.SUPPORTED, + existing.source(), + existing.evidenceDigest(), + existing.observedAt(), + existing.validUntil(), + existing.providerAndDeploymentIdentity(), + Set.of( + ObjectStorageProvider.Capability.IMMUTABLE_CREATE, + ObjectStorageProvider.Capability.DIRECT_UPLOAD), + List.of("invalid exact-release direct claim")); + return mutate( + source, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of("direct-single-soft-limit-r1", invalidClaim)); + } + + static S3ProviderSettings minioSettings() { + return settings( + "release-2024-01-16t16-07-38z-sdk-2.30.0", + null, + "minio-cluster-test-01", + URI.create("https://minio.example.test"), + "path-style", + new S3ProviderSettings.Credentials( + "environment", "OBJECT_STORAGE_ACCESS_KEY", "OBJECT_STORAGE_SECRET_KEY")); + } + + private static S3ProviderSettings settings( + String version, + String expectedOwner, + String deployment, + URI endpoint, + String addressing, + S3ProviderSettings.Credentials credentials) { + return new S3ProviderSettings( + version, + "qualified-object-bucket", + "ap-northeast-2", + expectedOwner, + deployment, + endpoint, + addressing, + false, + false, + credentials, + new S3ProviderSettings.Timeouts( + Duration.ofSeconds(20), + Duration.ofSeconds(4), + Duration.ofSeconds(1), + Duration.ofSeconds(1), + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(2)), + new S3ProviderSettings.Pool(8, 16), + new S3ProviderSettings.Retry(2, Duration.ofMillis(10), Duration.ofMillis(20)), + Duration.ofSeconds(2), + "binding-v1", + "evidence-v1", + Map.of( + "managed-common", + capability( + "aws-sdk-2.30.0".equals(version) + ? CapabilityEvidenceStatus.SUPPORTED + : CapabilityEvidenceStatus.UNSUPPORTED, + NOW.plus(Duration.ofDays(1)), + ("aws-sdk-2.30.0".equals(version) + ? identity( + S3ProviderType.AWS_S3_GENERAL_PURPOSE, + S3ProviderVersion.AWS_SDK_2_30_0, + deployment) + : identity( + S3ProviderType.MINIO_COMMUNITY_2024_01_16, + S3ProviderVersion.MINIO_2024_01_16_SDK_2_30_0, + deployment))))); + } + + private static S3ProviderCapabilityConfig capability( + CapabilityEvidenceStatus status, Instant validUntil, String identity) { + return new S3ProviderCapabilityConfig( + status, + CapabilityEvidenceSource.CI_QUALIFICATION, + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + NOW.minus(Duration.ofHours(1)), + validUntil, + identity, + Set.of( + ObjectStorageProvider.Capability.IMMUTABLE_CREATE, + ObjectStorageProvider.Capability.EXACT_INSPECT, + ObjectStorageProvider.Capability.FULL_READ, + ObjectStorageProvider.Capability.RANGE_READ, + ObjectStorageProvider.Capability.CONDITIONAL_RETIREMENT, + ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION), + List.of("managed common subset only")); + } + + private static String identity( + S3ProviderType type, S3ProviderVersion version, String deployment) { + return type.token() + ":" + version.token() + ":" + deployment; + } + + private static S3ProviderSettings withoutExpectedOwner(S3ProviderSettings source) { + return new S3ProviderSettings( + source.providerVersion(), + source.bucket(), + source.region(), + null, + source.deploymentIdentity(), + source.endpointOverride(), + source.addressing(), + source.autoCreateBucket(), + source.publicAcl(), + source.credentials(), + source.timeouts(), + source.pool(), + source.retry(), + source.shutdownGrace(), + source.bindingRevision(), + source.evidenceRevision(), + source.capabilityProfiles()); + } + + private static S3ProviderSettings withVersion(S3ProviderSettings source, String version) { + return new S3ProviderSettings( + version, + source.bucket(), + source.region(), + source.expectedOwner(), + source.deploymentIdentity(), + source.endpointOverride(), + source.addressing(), + source.autoCreateBucket(), + source.publicAcl(), + source.credentials(), + source.timeouts(), + source.pool(), + source.retry(), + source.shutdownGrace(), + source.bindingRevision(), + source.evidenceRevision(), + source.capabilityProfiles()); + } + + private static S3ProviderSettings mutate( + S3ProviderSettings source, + URI endpoint, + String deployment, + Boolean autoCreate, + Boolean publicAcl, + S3ProviderSettings.Credentials credentials, + S3ProviderSettings.Timeouts timeouts, + S3ProviderSettings.Pool pool, + S3ProviderSettings.Retry retry, + Map profiles) { + return new S3ProviderSettings( + source.providerVersion(), + source.bucket(), + source.region(), + source.expectedOwner(), + deployment == null ? source.deploymentIdentity() : deployment, + endpoint == null ? source.endpointOverride() : endpoint, + source.addressing(), + autoCreate == null ? source.autoCreateBucket() : autoCreate, + publicAcl == null ? source.publicAcl() : publicAcl, + credentials == null ? source.credentials() : credentials, + timeouts == null ? source.timeouts() : timeouts, + pool == null ? source.pool() : pool, + retry == null ? source.retry() : retry, + source.shutdownGrace(), + source.bindingRevision(), + source.evidenceRevision(), + profiles == null ? source.capabilityProfiles() : profiles); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java new file mode 100644 index 0000000..fa7c910 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java @@ -0,0 +1,197 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageBindingCompiler; +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageCapabilityAssembler; +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageCapabilitySettings; +import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageDestinationSettings; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +class S3ProviderCompositionTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC); + + @Test + void disabledUnselectedAndInvalidBindingsConstructNoS3Resource() { + S3AsyncClient client = mock(S3AsyncClient.class); + SdkAsyncHttpClient http = mock(SdkAsyncHttpClient.class); + AtomicInteger constructions = new AtomicInteger(); + S3ObjectStorageProviderContribution contribution = + contribution(client, http, constructions, Map.of()); + ObjectStorageBindingCompiler compiler = + new ObjectStorageBindingCompiler(List.of(contribution), Set.of("prod"), false, CLOCK); + + assertThat( + compiler + .compile(new ObjectStorageCapabilitySettings(false, null, null, null, null)) + .providers()) + .isEmpty(); + assertThat(constructions).hasValue(0); + verifyNoInteractions(client, http); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> + compiler.compile( + new ObjectStorageCapabilitySettings(true, null, Map.of(), Map.of(), null))); + assertThat(constructions).hasValue(0); + verifyNoInteractions(client, http); + } + + @Test + void selectedExactBindingConstructsAndClosesOneOwnedClientExactlyOnce() { + S3AsyncClient client = mock(S3AsyncClient.class); + SdkAsyncHttpClient http = mock(SdkAsyncHttpClient.class); + AtomicInteger constructions = new AtomicInteger(); + S3ObjectStorageProviderContribution contribution = + contribution(client, http, constructions, Map.of()); + ObjectStorageBindingCompiler compiler = + new ObjectStorageBindingCompiler(List.of(contribution), Set.of("test"), false, CLOCK); + ObjectStorageCapabilityAssembler assembler = + new ObjectStorageCapabilityAssembler(List.of(contribution), compiler); + + ObjectStorageCapabilityAssembler.AssembledCapability capability = + assembler.assemble(settings()); + capability.close(); + capability.close(); + + assertThat(constructions).hasValue(1); + verify(client).close(); + verify(http).close(); + } + + @Test + void selectedDirectProfileConstructsAndClosesOneOwnedPresigner() { + S3AsyncClient client = mock(S3AsyncClient.class); + SdkAsyncHttpClient http = mock(SdkAsyncHttpClient.class); + S3Presigner presigner = mock(S3Presigner.class); + AtomicInteger constructions = new AtomicInteger(); + S3CapabilityProbe probe = + (binding, plan) -> + binding.capabilityDescriptor().profile("direct-single-soft-limit-r1").evidence(); + S3ObjectStorageProviderContribution contribution = + new S3ObjectStorageProviderContribution( + CLOCK, + new S3ProviderQualifier(CLOCK, probe), + policy -> { + constructions.incrementAndGet(); + return new S3ClientLifecycle(client, http, Duration.ofSeconds(1)); + }, + policy -> presigner, + ignored -> null); + ObjectStorageBindingCompiler compiler = + new ObjectStorageBindingCompiler(List.of(contribution), Set.of("test"), false, CLOCK); + ObjectStorageCapabilityAssembler assembler = + new ObjectStorageCapabilityAssembler(List.of(contribution), compiler); + + ObjectStorageCapabilityAssembler.AssembledCapability capability = + assembler.assemble(directSettings()); + assertThat( + capability + .destination( + dev.caskeleton.application.objectstorage.identity.ObjectDestinationId.of( + "poster")) + .providerFactory() + .directTransferProvider(S3DirectTransferProvider.class)) + .isPresent(); + assertThat( + capability + .destination( + dev.caskeleton.application.objectstorage.identity.ObjectDestinationId.of( + "poster")) + .providerFactory() + .directMultipartProvider(S3DirectMultipartProvider.class)) + .isPresent(); + capability.close(); + capability.close(); + + assertThat(constructions).hasValue(1); + verify(presigner).close(); + verify(client).close(); + verify(http).close(); + } + + private static S3ObjectStorageProviderContribution contribution( + S3AsyncClient client, + SdkAsyncHttpClient http, + AtomicInteger constructions, + Map environment) { + S3CapabilityProbe probe = + (binding, plan) -> binding.capabilityDescriptor().profile("managed-common").evidence(); + return new S3ObjectStorageProviderContribution( + CLOCK, + new S3ProviderQualifier(CLOCK, probe), + policy -> { + constructions.incrementAndGet(); + return new S3ClientLifecycle(client, http, Duration.ofSeconds(1)); + }, + environment::get); + } + + private static ObjectStorageCapabilitySettings settings() { + return new ObjectStorageCapabilitySettings( + true, + "poster", + Map.of( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings())), + Map.of( + "poster", + new ObjectStorageDestinationSettings( + "aws", + "0123456789ab", + "binding-v1", + "policy-v1", + null, + null, + null, + null, + null, + "managed-common")), + null); + } + + private static ObjectStorageCapabilitySettings directSettings() { + return new ObjectStorageCapabilitySettings( + true, + "poster", + Map.of( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsDirectSettings())), + Map.of( + "poster", + new ObjectStorageDestinationSettings( + "aws", + "0123456789ab", + "binding-v1", + "policy-v1", + null, + null, + null, + Set.of( + dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement + .DIRECT_UPLOAD), + null, + "direct-single-soft-limit-r1")), + null); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapperTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapperTest.java new file mode 100644 index 0000000..f63aa1c --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapperTest.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException.Failure; +import java.net.SocketTimeoutException; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.s3.model.S3Exception; + +class S3ProviderErrorMapperTest { + + private final S3ProviderErrorMapper mapper = new S3ProviderErrorMapper(); + + @Test + void mapsStableS3FailuresWithoutLeakingProviderDetails() { + assertMapped(403, "AccessDenied", Failure.PERMISSION_DENIED); + assertMapped(403, "InvalidBucketOwner", Failure.OWNER_MISMATCH); + assertMapped(301, "PermanentRedirect", Failure.REGION_MISMATCH); + assertMapped(404, "NoSuchKey", Failure.NOT_FOUND); + assertMapped(409, "ConditionalRequestConflict", Failure.CONFLICT); + assertMapped(412, "PreconditionFailed", Failure.PRECONDITION_FAILED); + assertMapped(503, "SlowDown", Failure.THROTTLED); + assertMapped(400, "BadDigest", Failure.CONTENT_MISMATCH); + assertMapped(403, "ObjectLockConfigurationNotFoundError", Failure.RETENTION_BLOCKED); + } + + @Test + void finalMutationTransportFailureIsIndeterminateRatherThanAuthoritativeFailure() { + S3ProviderErrorMapper.Mapping mapping = + mapper.map( + SdkClientException.builder() + .message("request body and secret endpoint must not leak") + .cause(new SocketTimeoutException("timed out")) + .build(), + true); + + assertThat(mapping.failure()).isEqualTo(Failure.INDETERMINATE); + assertThat(mapping.authoritative()).isFalse(); + assertThat(mapping.safeMessage()).doesNotContain("secret", "endpoint"); + } + + private void assertMapped(int status, String code, Failure expected) { + S3Exception exception = + (S3Exception) + S3Exception.builder() + .statusCode(status) + .awsErrorDetails( + AwsErrorDetails.builder() + .errorCode(code) + .errorMessage("sensitive") + .serviceName("S3") + .build()) + .message("sensitive request") + .build(); + + S3ProviderErrorMapper.Mapping mapping = mapper.map(exception, false); + + assertThat(mapping.failure()).isEqualTo(expected); + assertThat(mapping.safeMessage()).doesNotContain("sensitive"); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifierTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifierTest.java new file mode 100644 index 0000000..a979652 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifierTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import dev.caskeleton.adapter.outbound.objectstorage.readiness.CapabilityEvidenceSource; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class S3ProviderQualifierTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + + @Test + void staticCiEvidenceIsValidatedWithoutRunningAProbe() { + AtomicReference observed = new AtomicReference<>(); + S3ProviderQualifier qualifier = + new S3ProviderQualifier( + CLOCK, + (binding, plan) -> { + observed.set(plan); + throw new AssertionError("CI evidence must not cause a startup probe"); + }); + S3ProviderBinding binding = + S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + CLOCK); + + S3QualificationEvidence evidence = qualifier.qualify(binding, "managed-common"); + + assertThat(evidence.evidence().source()).isEqualTo(CapabilityEvidenceSource.CI_QUALIFICATION); + assertThat(observed).hasValue(null); + } + + @Test + void startupProbeIsConfinedToAReservedPrefixAndSafeActions() { + AtomicReference observed = new AtomicReference<>(); + S3CapabilityProbe probe = + (binding, plan) -> { + observed.set(plan); + return binding.capabilityDescriptor().profile("managed-common").evidence(); + }; + S3ProviderQualifier qualifier = new S3ProviderQualifier(CLOCK, probe); + S3ProviderBinding binding = + S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + CLOCK); + + qualifier.qualifyWithProbe(binding, "managed-common"); + + assertThat(observed.get().reservedPrefix()).startsWith("_capability-probe/v1/"); + assertThat(observed.get().maximumProbeBytes()).isBetween(1, 4096); + assertThat(observed.get().actions()) + .containsExactlyInAnyOrder( + S3CapabilityProbe.Action.BUCKET_HEAD, + S3CapabilityProbe.Action.CONDITIONAL_CREATE, + S3CapabilityProbe.Action.EXACT_HEAD, + S3CapabilityProbe.Action.EXACT_GET, + S3CapabilityProbe.Action.CONDITIONAL_CONTROL_CAS, + S3CapabilityProbe.Action.DELETE_PROBE_OBJECT); + assertThat(observed.get().actions()) + .doesNotContain( + S3CapabilityProbe.Action.CREATE_BUCKET, + S3CapabilityProbe.Action.CHANGE_BUCKET_CONFIGURATION); + } + + @Test + void probeCannotPromoteMismatchedOrExpiredEvidence() { + S3ProviderBinding binding = + S3ProviderBinding.compile( + "aws", + S3ProviderBindingTest.provider( + "aws-s3-general-purpose", S3ProviderBindingTest.awsSettings()), + CLOCK); + S3ProviderQualifier qualifier = + new S3ProviderQualifier( + CLOCK, + (ignored, plan) -> + binding + .capabilityDescriptor() + .profile("managed-common") + .evidence() + .withProviderAndDeploymentIdentity("wrong:deployment")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> qualifier.qualifyWithProbe(binding, "managed-common")); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3SdkApiCharacterizationTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3SdkApiCharacterizationTest.java new file mode 100644 index 0000000..9fc3ebe --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3SdkApiCharacterizationTest.java @@ -0,0 +1,115 @@ +package dev.caskeleton.adapter.outbound.objectstorage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.core.util.VersionInfo; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.ListPartsRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +class S3SdkApiCharacterizationTest { + + @Test + void pinnedSdkExposesRequiredConditionalChecksumAndMultipartApis() throws Exception { + assertThat(VersionInfo.SDK_VERSION).isEqualTo("2.30.0"); + + PutObjectRequest put = + PutObjectRequest.builder() + .ifNoneMatch("*") + .ifMatch("\"private-etag\"") + .checksumSHA256("base64-sha256") + .expectedBucketOwner("owner") + .build(); + assertThat(put.ifNoneMatch()).isEqualTo("*"); + assertThat(put.ifMatch()).isEqualTo("\"private-etag\""); + assertThat(put.checksumSHA256()).isEqualTo("base64-sha256"); + assertThat(put.expectedBucketOwner()).isEqualTo("owner"); + + CompleteMultipartUploadRequest complete = + CompleteMultipartUploadRequest.builder() + .ifNoneMatch("*") + .ifMatch("\"private-etag\"") + .mpuObjectSize(Integer.MAX_VALUE) + .expectedBucketOwner("owner") + .build(); + assertThat(complete.mpuObjectSize()).isEqualTo(Integer.MAX_VALUE); + Method objectSize = + CompleteMultipartUploadRequest.Builder.class.getMethod("mpuObjectSize", Integer.class); + assertThat(objectSize.getParameterTypes()).containsExactly(Integer.class); + + assertThat( + S3AsyncClient.builder() + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED)) + .isNotNull(); + assertThat(S3Presigner.builder()).isNotNull(); + } + + @Test + void everyManagedAndMultipartRequestHasExpectedOwnerSupport() { + List> builders = + List.of( + PutObjectRequest.Builder.class, + HeadObjectRequest.Builder.class, + GetObjectRequest.Builder.class, + DeleteObjectRequest.Builder.class, + CreateMultipartUploadRequest.Builder.class, + UploadPartRequest.Builder.class, + ListPartsRequest.Builder.class, + CompleteMultipartUploadRequest.Builder.class, + AbortMultipartUploadRequest.Builder.class); + + assertThat(builders) + .allSatisfy( + builder -> + assertThatCode(() -> builder.getMethod("expectedBucketOwner", String.class))); + } + + @Test + void sdkMultipartIntegerPartCountAndMinimumPartBoundariesFailClosed() { + assertThat(S3ClientPolicy.requireSdkCompatibleMpuObjectSize(Integer.MAX_VALUE)) + .isEqualTo(Integer.MAX_VALUE); + assertThatIllegalArgumentException() + .isThrownBy( + () -> S3ClientPolicy.requireSdkCompatibleMpuObjectSize((long) Integer.MAX_VALUE + 1L)); + + S3ClientPolicy.requirePartCount(10_000); + assertThatIllegalArgumentException().isThrownBy(() -> S3ClientPolicy.requirePartCount(10_001)); + + S3ClientPolicy.requirePartSize(S3ClientPolicy.MINIMUM_NON_FINAL_PART_BYTES, false); + S3ClientPolicy.requirePartSize(S3ClientPolicy.MINIMUM_NON_FINAL_PART_BYTES - 1, true); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + S3ClientPolicy.requirePartSize( + S3ClientPolicy.MINIMUM_NON_FINAL_PART_BYTES - 1, false)); + } + + private static void assertThatCode(ThrowingCall call) { + try { + call.run(); + } catch (ReflectiveOperationException exception) { + throw new LinkageError(exception.getMessage(), exception); + } + } + + @FunctionalInterface + private interface ThrowingCall { + + void run() throws ReflectiveOperationException; + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/resources/object-storage/minio-provider-evidence.json b/src/adapter/outbound/objectstorage/src/test/resources/object-storage/minio-provider-evidence.json new file mode 100644 index 0000000..f4e41f2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/resources/object-storage/minio-provider-evidence.json @@ -0,0 +1,25 @@ +{ + "schema": 1, + "providerType": "s3-compatible-minio-community-release-2024-01-16t16-07-38z", + "providerVersion": "release-2024-01-16t16-07-38z-sdk-2.30.0", + "image": "minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8", + "toxiproxyImage": "ghcr.io/shopify/toxiproxy@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e", + "profile": "managed-common", + "status": "UNSUPPORTED", + "excludedProfiles": [ + "managed-single-create-only", + "conditional-control-create", + "managed-multipart-create-only", + "direct-single-create-only", + "direct-multipart-create-only" + ], + "limitations": [ + "PutObject If-None-Match was accepted and overwrote an existing object", + "stale PutObject If-Match returned HTTP 412 but create-if-absent control CAS is not safe", + "CompleteMultipartUpload accepted If-None-Match despite a pre-existing target and overwrote it", + "managed immutable create, conditional control creation, and multipart create-only are disabled", + "local single-node container evidence only", + "not AWS evidence", + "not production TLS or deployment-topology evidence" + ] +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java new file mode 100644 index 0000000..d0b41d1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +/** Vendor-neutral SPI for a transaction-safe insert-or-expired-reclaim claim. */ +public interface IdempotencyClaimRepository { + + /** + * Returns the new/reclaimed record ID when this caller won, or empty for a live winner. + * Implementations must not poison the caller transaction on an ordinary uniqueness race. + */ + Optional tryClaim( + IdempotencyRecordEntity proposed, + Instant now, + @Nullable IdempotencyRecordEntity exactExpiredEntity); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java index a425899..b2ce71d 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java @@ -15,6 +15,7 @@ import java.util.UUID; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @@ -33,14 +34,25 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort { private final IdempotencyRecordJpaRepository repository; private final @Nullable IdempotencyResponseObjectStore objectStore; + private final @Nullable IdempotencyClaimRepository claimRepository; private final Clock clock; public IdempotencyStoreAdapter( IdempotencyRecordJpaRepository repository, @Nullable IdempotencyResponseObjectStore objectStore, Clock clock) { + this(repository, objectStore, null, clock); + } + + @Autowired + public IdempotencyStoreAdapter( + IdempotencyRecordJpaRepository repository, + @Nullable IdempotencyResponseObjectStore objectStore, + @Nullable IdempotencyClaimRepository claimRepository, + Clock clock) { this.repository = repository; this.objectStore = objectStore; + this.claimRepository = claimRepository; this.clock = clock; } @@ -51,9 +63,7 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort { Optional existing = lookup(scope); if (existing.isPresent()) { IdempotencyRecordEntity row = existing.get(); - if (!clock.instant().isBefore(row.getExpiresAt())) { - repository.delete(row); // reclaim an expired row so the new claim can win - } else { + if (clock.instant().isBefore(row.getExpiresAt())) { return false; // a live record already owns the scope } } @@ -70,6 +80,22 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort { null, clock.instant(), expiresAt); + if (claimRepository != null) { + Optional claimed = + claimRepository.tryClaim(claim, clock.instant(), existing.orElse(null)); + if (claimed.isEmpty()) { + return false; + } + IdempotencyRecordEntity reloaded = + repository + .findById(claimed.orElseThrow()) + .orElseThrow(() -> new IllegalStateException("claimed idempotency row is absent")); + if (!reloaded.getRequestHash().equals(fingerprint.hex())) { + throw new IllegalStateException("claimed idempotency row conflicts"); + } + return true; + } + existing.ifPresent(repository::delete); try { repository.saveAndFlush(claim); // flush forces the unique-constraint check now return true; diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java new file mode 100644 index 0000000..e210c55 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +/** PostgreSQL atomic scope claim using the named V1 unique constraint. */ +public final class PostgreSqlIdempotencyClaimRepository implements IdempotencyClaimRepository { + + private static final String CLAIM_SQL = + """ + INSERT INTO idempotency_record ( + id, tenant, principal, idempotency_key, use_case_name, + request_hash, status, response_payload, response_ref, created_at, expires_at + ) VALUES ( + :id, :tenant, :principal, :idempotencyKey, :useCaseName, + :requestHash, 'IN_FLIGHT', NULL, NULL, :createdAt, :expiresAt + ) + ON CONFLICT ON CONSTRAINT uq_idempotency_scope + DO UPDATE SET + id = EXCLUDED.id, + request_hash = EXCLUDED.request_hash, + status = 'IN_FLIGHT', + response_payload = NULL, + response_ref = NULL, + created_at = EXCLUDED.created_at, + expires_at = EXCLUDED.expires_at + WHERE idempotency_record.expires_at <= :now + RETURNING id + """; + + private final EntityManager entityManager; + + public PostgreSqlIdempotencyClaimRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional tryClaim( + IdempotencyRecordEntity proposed, + Instant now, + @Nullable IdempotencyRecordEntity exactExpiredEntity) { + if (exactExpiredEntity != null && entityManager.contains(exactExpiredEntity)) { + entityManager.detach(exactExpiredEntity); + } + @SuppressWarnings("unchecked") + java.util.List rows = + entityManager + .createNativeQuery(CLAIM_SQL) + .setParameter("id", proposed.getId()) + .setParameter("tenant", proposed.getTenant()) + .setParameter("principal", proposed.getPrincipal()) + .setParameter("idempotencyKey", proposed.getIdempotencyKey()) + .setParameter("useCaseName", proposed.getUseCaseName()) + .setParameter("requestHash", proposed.getRequestHash()) + .setParameter("createdAt", proposed.getCreatedAt()) + .setParameter("expiresAt", proposed.getExpiresAt()) + .setParameter("now", now) + .getResultList(); + if (rows.isEmpty()) { + return Optional.empty(); + } + Object id = rows.getFirst(); + return Optional.of(id instanceof UUID uuid ? uuid : UUID.fromString(id.toString())); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java index 787fa62..3d3eeed 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java @@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.postgresql; import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; import jakarta.persistence.EntityManager; import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; @@ -27,6 +28,11 @@ public class PostgreSqlPersistenceConfig { return new PostgreSqlSqlStateErrorMapping(); } + @Bean + public IdempotencyClaimRepository idempotencyClaimRepository(EntityManager entityManager) { + return new PostgreSqlIdempotencyClaimRepository(entityManager); + } + @Bean public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() { return configuration -> configuration.locations("classpath:db/migration/postgresql"); diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java index ef1e484..df1c1ae 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java @@ -1,5 +1,6 @@ package dev.caskeleton.adapter.outbound.persistence.idempotency; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -29,7 +30,7 @@ class IdempotencyStoreAdapterTest { private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); private static final IdempotencyScope SCOPE = IdempotencyScope.of("user-1", "key-1", "CreateWorkLogUseCase"); - private static final RequestFingerprint FP = RequestFingerprint.ofSha256("body".getBytes()); + private static final RequestFingerprint FP = RequestFingerprint.ofSha256("body".getBytes(UTF_8)); private static final Instant EXPIRES = NOW.plus(Duration.ofHours(24)); private final IdempotencyRecordJpaRepository repo = mock(IdempotencyRecordJpaRepository.class); @@ -38,6 +39,10 @@ class IdempotencyStoreAdapterTest { return new IdempotencyStoreAdapter(repo, objectStore, CLOCK); } + private IdempotencyStoreAdapter vendorAdapter(IdempotencyClaimRepository claims) { + return new IdempotencyStoreAdapter(repo, null, claims, CLOCK); + } + private IdempotencyRecordEntity entity( String status, Instant expiresAt, String payload, String ref) { return new IdempotencyRecordEntity( @@ -91,6 +96,27 @@ class IdempotencyStoreAdapterTest { verify(repo).saveAndFlush(any()); } + @Test + void vendorClaimReclaimsWithoutDeleteInsertOrTransactionPoisoning() { + IdempotencyRecordEntity expired = entity("COMPLETED", NOW.minusSeconds(1), "stale", null); + IdempotencyClaimRepository claims = mock(IdempotencyClaimRepository.class); + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(expired)); + when(claims.tryClaim(any(), any(), any())) + .thenAnswer( + invocation -> { + IdempotencyRecordEntity proposed = invocation.getArgument(0); + when(repo.findById(proposed.getId())).thenReturn(Optional.of(proposed)); + return Optional.of(proposed.getId()); + }); + + assertThat(vendorAdapter(claims).tryBegin(SCOPE, FP, EXPIRES)).isTrue(); + + verify(repo, never()).delete(any()); + verify(repo, never()).saveAndFlush(any()); + verify(claims).tryClaim(any(), any(), org.mockito.Mockito.same(expired)); + } + @Test void tryBeginLosesOnUniqueConstraintRace() { when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index f259fa9..7bbc82d 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -247,6 +247,24 @@ class CleanArchitectureTest { + "adapters own MDC") .allowEmptyShould(true); + @ArchTest + static final ArchRule SAMPLE_APPLICATION_LEGACY_OBJECT_STORAGE_IMPORT_IS_FROZEN = + noClasses() + .that() + .resideInAPackage("dev.caskeleton.sample.portfolio.application..") + .and() + .doNotHaveFullyQualifiedName( + "dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase") + .should() + .dependOnClassesThat() + .resideInAPackage("dev.caskeleton.application.storage") + .as( + "Only the characterized UploadPosterImageUseCase may temporarily import the legacy " + + "ObjectStoragePort/StoredObject contract. New sample business code must use " + + "the semantic objectstorage ports. Task 23 moves compatibility into the exact " + + "legacy slice; Task 24 separately reviews the administrative migration seam.") + .allowEmptyShould(true); + @ArchTest static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION = noClasses() @@ -1233,6 +1251,12 @@ class CleanArchitectureTest { .that() .areDeclaredInClassesThat() .resideInAPackage("..adapter.outbound..") + // Object storage has a provider-neutral adapter-internal SPI/control/kernel whose + // public visibility is needed across its own subpackages. The actual semantic adapter + // boundary is covered by the dedicated rule below. + .and() + .areDeclaredInClassesThat() + .resideOutsideOfPackage("..adapter.outbound.objectstorage..") // @Configuration factory classes are excluded: a @Bean method legitimately // returns the adapter's own outbound port type for DI wiring (e.g. // MessagingConfig#messagePublisher -> MessagePublisher) — that is @@ -1272,6 +1296,30 @@ class CleanArchitectureTest { + "response surfaces.") .allowEmptyShould(true); + @ArchTest + static final ArchRule OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES = + methods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..adapter.outbound.objectstorage..") + .and() + .areDeclaredInClassesThat() + .haveSimpleNameEndingWith("Adapter") + .and() + .arePublic() + .and() + .areNotStatic() + .should() + .notHaveRawReturnType( + JavaClass.Predicates.resideInAnyPackage( + "..adapter.outbound..", + "..adapter.inbound.web..", + "..adapter.outbound.persistence..")) + .as( + "object-storage semantic adapter methods must return application types or " + + "primitives; provider/control/kernel types stay behind the adapter") + .allowEmptyShould(false); + @ArchTest static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE = classes() diff --git a/src/application-core/build.gradle b/src/application-core/build.gradle index a643938..cedbe29 100644 --- a/src/application-core/build.gradle +++ b/src/application-core/build.gradle @@ -2,4 +2,7 @@ // composition and diagnostic rendering belong to adapters/bootstrap. dependencies { implementation project(':shared-contract') + + // Property-based verification for bounded object-storage identity and value contracts. + testImplementation 'net.jqwik:jqwik:1.9.1' } diff --git a/src/application-core/gradle.lockfile b/src/application-core/gradle.lockfile index ff6d49b..9e549eb 100644 --- a/src/application-core/gradle.lockfile +++ b/src/application-core/gradle.lockfile @@ -35,6 +35,11 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-api:1.9.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-engine:1.9.1=testRuntimeClasspath +net.jqwik:jqwik-time:1.9.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-web:1.9.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik:1.9.1=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 @@ -50,7 +55,7 @@ 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.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/CancellationView.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/CancellationView.java new file mode 100644 index 0000000..f9314f5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/CancellationView.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.objectstorage.content; + +/** Read-only, process-local cancellation signal for a synchronous content callback. */ +@FunctionalInterface +public interface CancellationView { + + boolean isCancelled(); + + static CancellationView never() { + return () -> false; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkReadException.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkReadException.java new file mode 100644 index 0000000..7222b97 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkReadException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.content; + +/** Failure while a consumer reads one bounded chunk from the adapter-owned source. */ +public class ObjectChunkReadException extends ObjectContentConsumptionException { + + public ObjectChunkReadException(String message) { + super(message); + } + + public ObjectChunkReadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkSink.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkSink.java new file mode 100644 index 0000000..c9f1976 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkSink.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.objectstorage.content; + +import java.util.Objects; + +/** Adapter-owned bounded sink; producer arrays are valid only until {@link #write} returns. */ +@FunctionalInterface +public interface ObjectChunkSink { + + void write(byte[] bytes, int offset, int length) throws ObjectChunkWriteException; + + static ObjectChunkSink scoped(ObjectContentProductionContext context, ObjectChunkSink delegate) { + Objects.requireNonNull(context, "context must be non-null"); + Objects.requireNonNull(delegate, "delegate must be non-null"); + return (bytes, offset, length) -> { + context.requireActive(); + Objects.requireNonNull(bytes, "bytes must be non-null"); + Objects.checkFromIndexSize(offset, length, bytes.length); + if (length > context.maximumChunkBytes()) { + throw new IllegalArgumentException("chunk exceeds maximumChunkBytes"); + } + if (length > 0) { + delegate.write(bytes, offset, length); + } + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkSource.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkSource.java new file mode 100644 index 0000000..d33840c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkSource.java @@ -0,0 +1,50 @@ +package dev.caskeleton.application.objectstorage.content; + +import java.util.Objects; + +/** Adapter-owned bounded source with strict EOF and progress semantics. */ +@FunctionalInterface +public interface ObjectChunkSource { + + int EOF = -1; + int MAXIMUM_ZERO_PROGRESS_READS = 3; + + int read(byte[] destination, int offset, int length) throws ObjectChunkReadException; + + static ObjectChunkSource scoped(ObjectContentReadContext context, ObjectChunkSource delegate) { + Objects.requireNonNull(context, "context must be non-null"); + Objects.requireNonNull(delegate, "delegate must be non-null"); + return new ObjectChunkSource() { + private int consecutiveZeroProgressReads; + + @Override + public int read(byte[] destination, int offset, int length) throws ObjectChunkReadException { + context.requireActive(); + Objects.requireNonNull(destination, "destination must be non-null"); + Objects.checkFromIndexSize(offset, length, destination.length); + if (length > context.maximumChunkBytes()) { + throw new IllegalArgumentException("read exceeds maximumChunkBytes"); + } + if (length == 0) { + return 0; + } + int count = delegate.read(destination, offset, length); + if (count == 0) { + consecutiveZeroProgressReads++; + if (consecutiveZeroProgressReads > MAXIMUM_ZERO_PROGRESS_READS) { + throw new ObjectChunkReadException("source made no bounded progress"); + } + return 0; + } + consecutiveZeroProgressReads = 0; + if (count == EOF) { + return EOF; + } + if (count < 1 || count > length) { + throw new ObjectChunkReadException("source returned an invalid byte count"); + } + return count; + } + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkWriteException.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkWriteException.java new file mode 100644 index 0000000..0905338 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectChunkWriteException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.content; + +/** Failure while a producer writes one bounded chunk to the adapter-owned sink. */ +public class ObjectChunkWriteException extends ObjectContentProductionException { + + public ObjectChunkWriteException(String message) { + super(message); + } + + public ObjectChunkWriteException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentConsumer.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentConsumer.java new file mode 100644 index 0000000..995decb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentConsumer.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.objectstorage.content; + +/** + * Synchronous blocking consumer callback. Adapters own and close the provider resource and + * invalidate the scoped source when this callback returns. + */ +@FunctionalInterface +public interface ObjectContentConsumer { + + void consume(ObjectContentReadContext context, ObjectChunkSource source) + throws ObjectContentConsumptionException; +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentConsumptionException.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentConsumptionException.java new file mode 100644 index 0000000..495d1b0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentConsumptionException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.content; + +/** Application consumer failure, distinct from a provider read failure. */ +public class ObjectContentConsumptionException extends Exception { + + public ObjectContentConsumptionException(String message) { + super(message); + } + + public ObjectContentConsumptionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProducer.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProducer.java new file mode 100644 index 0000000..a884ef0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProducer.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.objectstorage.content; + +/** + * Synchronous blocking producer callback. Adapters must not invoke it on an SDK event-loop thread + * and must invalidate its scoped sink when the callback returns. + */ +@FunctionalInterface +public interface ObjectContentProducer { + + void produce(ObjectContentProductionContext context, ObjectChunkSink sink) + throws ObjectContentProductionException; +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProductionContext.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProductionContext.java new file mode 100644 index 0000000..d435bfa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProductionContext.java @@ -0,0 +1,56 @@ +package dev.caskeleton.application.objectstorage.content; + +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; + +/** Process-local callback scope for a bounded content producer. */ +public final class ObjectContentProductionContext { + + public static final int MAXIMUM_CHUNK_BYTES = 16 * 1024 * 1024; + + private final CallBudget budget; + private final CancellationView cancellation; + private final int maximumChunkBytes; + private volatile boolean active = true; + + private ObjectContentProductionContext( + CallBudget budget, CancellationView cancellation, int maximumChunkBytes) { + this.budget = Objects.requireNonNull(budget, "budget must be non-null"); + this.cancellation = Objects.requireNonNull(cancellation, "cancellation must be non-null"); + if (maximumChunkBytes < 1 || maximumChunkBytes > MAXIMUM_CHUNK_BYTES) { + throw new IllegalArgumentException("maximumChunkBytes is outside the supported range"); + } + this.maximumChunkBytes = maximumChunkBytes; + } + + public static ObjectContentProductionContext open( + CallBudget budget, CancellationView cancellation, int maximumChunkBytes) { + return new ObjectContentProductionContext(budget, cancellation, maximumChunkBytes); + } + + public CallBudget budget() { + return budget; + } + + public CancellationView cancellation() { + return cancellation; + } + + public int maximumChunkBytes() { + return maximumChunkBytes; + } + + /** Adapter lifecycle hook; scoped sinks reject every subsequent call. */ + public void invalidate() { + active = false; + } + + void requireActive() { + if (!active) { + throw new IllegalStateException("content production callback scope is closed"); + } + if (cancellation.isCancelled()) { + throw new IllegalStateException("content production callback is cancelled"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProductionException.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProductionException.java new file mode 100644 index 0000000..0f2b292 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentProductionException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.content; + +/** Application producer failure, distinct from a provider write failure. */ +public class ObjectContentProductionException extends Exception { + + public ObjectContentProductionException(String message) { + super(message); + } + + public ObjectContentProductionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentReadContext.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentReadContext.java new file mode 100644 index 0000000..698c878 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/ObjectContentReadContext.java @@ -0,0 +1,91 @@ +package dev.caskeleton.application.objectstorage.content; + +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; + +/** Process-local callback scope with the validated exact read facts visible to a consumer. */ +public final class ObjectContentReadContext { + + private final CallBudget budget; + private final CancellationView cancellation; + private final int maximumChunkBytes; + private final ObjectContentIdentity contentIdentity; + private final ObjectVersionToken exactVersion; + private final ObjectReadRange deliveredRange; + private volatile boolean active = true; + + private ObjectContentReadContext( + CallBudget budget, + CancellationView cancellation, + int maximumChunkBytes, + ObjectContentIdentity contentIdentity, + ObjectVersionToken exactVersion, + ObjectReadRange deliveredRange) { + this.budget = Objects.requireNonNull(budget, "budget must be non-null"); + this.cancellation = Objects.requireNonNull(cancellation, "cancellation must be non-null"); + if (maximumChunkBytes < 1 + || maximumChunkBytes > ObjectContentProductionContext.MAXIMUM_CHUNK_BYTES) { + throw new IllegalArgumentException("maximumChunkBytes is outside the supported range"); + } + this.maximumChunkBytes = maximumChunkBytes; + this.contentIdentity = + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + this.exactVersion = Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + this.deliveredRange = Objects.requireNonNull(deliveredRange, "deliveredRange must be non-null"); + if (deliveredRange.endExclusive() > contentIdentity.exactLength()) { + throw new IllegalArgumentException("deliveredRange exceeds exact content length"); + } + } + + public static ObjectContentReadContext open( + CallBudget budget, + CancellationView cancellation, + int maximumChunkBytes, + ObjectContentIdentity contentIdentity, + ObjectVersionToken exactVersion, + ObjectReadRange deliveredRange) { + return new ObjectContentReadContext( + budget, cancellation, maximumChunkBytes, contentIdentity, exactVersion, deliveredRange); + } + + public CallBudget budget() { + return budget; + } + + public CancellationView cancellation() { + return cancellation; + } + + public int maximumChunkBytes() { + return maximumChunkBytes; + } + + public ObjectContentIdentity contentIdentity() { + return contentIdentity; + } + + public ObjectVersionToken exactVersion() { + return exactVersion; + } + + public ObjectReadRange deliveredRange() { + return deliveredRange; + } + + /** Adapter lifecycle hook; scoped sources reject every subsequent call. */ + public void invalidate() { + active = false; + } + + void requireActive() { + if (!active) { + throw new IllegalStateException("content read callback scope is closed"); + } + if (cancellation.isCancelled()) { + throw new IllegalStateException("content read callback is cancelled"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/DirectTransferSessionId.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/DirectTransferSessionId.java new file mode 100644 index 0000000..f172ccb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/DirectTransferSessionId.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Opaque direct-transfer workflow identity for single-part or multipart sessions. */ +public final class DirectTransferSessionId { + + private final String canonicalText; + + private DirectTransferSessionId(String canonicalText) { + this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osu1", "osm1"); + } + + public static DirectTransferSessionId parse(String canonicalText) { + return new DirectTransferSessionId(canonicalText); + } + + public String canonicalText() { + return canonicalText; + } + + public String redactedLogToken() { + return ObjectIdentitySupport.redactedLogToken(canonicalText); + } + + @Override + public String toString() { + return redactedLogToken(); + } + + @Override + public boolean equals(Object other) { + return other instanceof DirectTransferSessionId that + && canonicalText.equals(that.canonicalText); + } + + @Override + public int hashCode() { + return Objects.hash(canonicalText); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/MultipartPartNumber.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/MultipartPartNumber.java new file mode 100644 index 0000000..814ec7a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/MultipartPartNumber.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.objectstorage.identity; + +/** One-based multipart part number, bounded by the S3-compatible maximum. */ +public record MultipartPartNumber(int value) { + + public MultipartPartNumber { + if (value < 1 || value > 10_000) { + throw new IllegalArgumentException("multipart part number must be between 1 and 10000"); + } + } + + public static MultipartPartNumber of(int value) { + return new MultipartPartNumber(value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectDestinationId.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectDestinationId.java new file mode 100644 index 0000000..ef67643 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectDestinationId.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.identity; + +/** Logical storage/security destination; never a provider bucket, path, or endpoint. */ +public record ObjectDestinationId(String value) { + + public ObjectDestinationId { + value = ObjectIdentitySupport.requireSimple("object destination id", value, 64); + } + + public static ObjectDestinationId of(String value) { + return new ObjectDestinationId(value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectId.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectId.java new file mode 100644 index 0000000..20bc910 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectId.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Adapter-minted immutable data-object identity with no provider coordinates. */ +public final class ObjectId { + + private final String canonicalText; + + private ObjectId(String canonicalText) { + this.canonicalText = ObjectIdentitySupport.requireObjectId(canonicalText); + } + + public static ObjectId parse(String canonicalText) { + return new ObjectId(canonicalText); + } + + public String canonicalText() { + return canonicalText; + } + + public String redactedLogToken() { + return ObjectIdentitySupport.redactedLogToken(canonicalText); + } + + @Override + public String toString() { + return redactedLogToken(); + } + + @Override + public boolean equals(Object other) { + return other instanceof ObjectId that && canonicalText.equals(that.canonicalText); + } + + @Override + public int hashCode() { + return Objects.hash(canonicalText); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectIdentitySupport.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectIdentitySupport.java new file mode 100644 index 0000000..3a4bf6b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectIdentitySupport.java @@ -0,0 +1,103 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +final class ObjectIdentitySupport { + + private static final String CROCKFORD = "[0-9abcdefghjkmnpqrstvwxyz]+"; + private static final String SIMPLE = "[a-z0-9][a-z0-9_-]*"; + + private ObjectIdentitySupport() {} + + static String requireSimple(String label, String value, int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength + || !value.matches(SIMPLE)) { + throw invalid(label); + } + return value; + } + + static String requireObjectId(String value) { + if (value == null || value.length() != 26 || !value.matches(CROCKFORD)) { + throw invalid("object id"); + } + return value; + } + + static String requireRouted(String value, String... acceptedPrefixes) { + if (value == null || value.length() > 64) { + throw invalid("opaque object identity"); + } + String[] parts = value.split("\\.", -1); + if (parts.length != 4 + || !accepted(parts[0], acceptedPrefixes) + || parts[1].length() != 12 + || !parts[1].matches(CROCKFORD) + || parts[2].length() != 26 + || !parts[2].matches(CROCKFORD) + || !parts[3].matches("[0-9a-f]{10}")) { + throw invalid("opaque object identity"); + } + String payload = parts[0] + "." + parts[1] + "." + parts[2]; + requireCheck(payload, parts[3]); + return value; + } + + static String requirePartReceipt(String value) { + if (value == null || value.length() > 48) { + throw invalid("part receipt token"); + } + String[] parts = value.split("\\.", -1); + if (parts.length != 3 + || !"osp1".equals(parts[0]) + || parts[1].length() != 26 + || !parts[1].matches(CROCKFORD) + || !parts[2].matches("[0-9a-f]{10}")) { + throw invalid("part receipt token"); + } + requireCheck(parts[0] + "." + parts[1], parts[2]); + return value; + } + + static String redactedLogToken(String canonicalText) { + int separator = canonicalText.indexOf('.'); + String family = separator < 0 ? "object-id" : canonicalText.substring(0, separator); + return family + "#" + sha256Hex(canonicalText).substring(0, 12); + } + + private static boolean accepted(String candidate, String[] acceptedPrefixes) { + for (String acceptedPrefix : acceptedPrefixes) { + if (acceptedPrefix.equals(candidate)) { + return true; + } + } + return false; + } + + private static void requireCheck(String payload, String actual) { + byte[] expected = sha256Hex(payload).substring(0, 10).getBytes(StandardCharsets.US_ASCII); + byte[] supplied = actual.getBytes(StandardCharsets.US_ASCII); + if (!MessageDigest.isEqual(expected, supplied)) { + throw invalid("opaque object identity"); + } + } + + private static String sha256Hex(String value) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 must be available", e); + } + } + + private static IllegalArgumentException invalid(String label) { + return new IllegalArgumentException(label + " is not canonical"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationEpoch.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationEpoch.java new file mode 100644 index 0000000..09ff163 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationEpoch.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.identity; + +/** Bounded namespace epoch used to rotate or seal operation identities. */ +public record ObjectOperationEpoch(String value) { + + public ObjectOperationEpoch { + value = ObjectIdentitySupport.requireSimple("object operation epoch", value, 64); + } + + public static ObjectOperationEpoch of(String value) { + return new ObjectOperationEpoch(value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationId.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationId.java new file mode 100644 index 0000000..19132d3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationId.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.objectstorage.identity; + +/** Stable identity reused only for retries of the same logical mutation. */ +public record ObjectOperationId(String value) { + + public ObjectOperationId { + value = ObjectIdentitySupport.requireSimple("object operation id", value, 64); + } + + public static ObjectOperationId of(String value) { + return new ObjectOperationId(value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationKey.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationKey.java new file mode 100644 index 0000000..d134f89 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectOperationKey.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Exact mutation key: logical destination, retained operation epoch, and stable operation ID. */ +public record ObjectOperationKey( + ObjectDestinationId destination, ObjectOperationEpoch epoch, ObjectOperationId operationId) { + + public ObjectOperationKey { + Objects.requireNonNull(destination, "destination must be non-null"); + Objects.requireNonNull(epoch, "epoch must be non-null"); + Objects.requireNonNull(operationId, "operationId must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectReference.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectReference.java new file mode 100644 index 0000000..f8203cc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectReference.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Opaque published reference. It is an identity, not an authorization credential. */ +public final class ObjectReference { + + private final String canonicalText; + + private ObjectReference(String canonicalText) { + this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osr1"); + } + + public static ObjectReference parse(String canonicalText) { + return new ObjectReference(canonicalText); + } + + public String canonicalText() { + return canonicalText; + } + + public String redactedLogToken() { + return ObjectIdentitySupport.redactedLogToken(canonicalText); + } + + @Override + public String toString() { + return redactedLogToken(); + } + + @Override + public boolean equals(Object other) { + return other instanceof ObjectReference that && canonicalText.equals(that.canonicalText); + } + + @Override + public int hashCode() { + return Objects.hash(canonicalText); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectStageHandle.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectStageHandle.java new file mode 100644 index 0000000..862f295 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectStageHandle.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Opaque handle for an unpublished object; published-reference parsers reject it. */ +public final class ObjectStageHandle { + + private final String canonicalText; + + private ObjectStageHandle(String canonicalText) { + this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osh1"); + } + + public static ObjectStageHandle parse(String canonicalText) { + return new ObjectStageHandle(canonicalText); + } + + public String canonicalText() { + return canonicalText; + } + + public String redactedLogToken() { + return ObjectIdentitySupport.redactedLogToken(canonicalText); + } + + @Override + public String toString() { + return redactedLogToken(); + } + + @Override + public boolean equals(Object other) { + return other instanceof ObjectStageHandle that && canonicalText.equals(that.canonicalText); + } + + @Override + public int hashCode() { + return Objects.hash(canonicalText); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectVersionToken.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectVersionToken.java new file mode 100644 index 0000000..c294b33 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/ObjectVersionToken.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Opaque exact-generation precondition; it never exposes provider version or ETag values. */ +public final class ObjectVersionToken { + + private final String canonicalText; + + private ObjectVersionToken(String canonicalText) { + this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osv1"); + } + + public static ObjectVersionToken parse(String canonicalText) { + return new ObjectVersionToken(canonicalText); + } + + public String canonicalText() { + return canonicalText; + } + + public String redactedLogToken() { + return ObjectIdentitySupport.redactedLogToken(canonicalText); + } + + @Override + public String toString() { + return redactedLogToken(); + } + + @Override + public boolean equals(Object other) { + return other instanceof ObjectVersionToken that && canonicalText.equals(that.canonicalText); + } + + @Override + public int hashCode() { + return Objects.hash(canonicalText); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/PartReceiptToken.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/PartReceiptToken.java new file mode 100644 index 0000000..7461c08 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/PartReceiptToken.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.objectstorage.identity; + +import java.util.Objects; + +/** Server-issued opaque acknowledgement of a verified multipart part. */ +public final class PartReceiptToken { + + private final String canonicalText; + + private PartReceiptToken(String canonicalText) { + this.canonicalText = ObjectIdentitySupport.requirePartReceipt(canonicalText); + } + + public static PartReceiptToken parse(String canonicalText) { + return new PartReceiptToken(canonicalText); + } + + public String canonicalText() { + return canonicalText; + } + + public String redactedLogToken() { + return ObjectIdentitySupport.redactedLogToken(canonicalText); + } + + @Override + public String toString() { + return redactedLogToken(); + } + + @Override + public boolean equals(Object other) { + return other instanceof PartReceiptToken that && canonicalText.equals(that.canonicalText); + } + + @Override + public int hashCode() { + return Objects.hash(canonicalText); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectDownloadGrant.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectDownloadGrant.java new file mode 100644 index 0000000..81c01b8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectDownloadGrant.java @@ -0,0 +1,61 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** Transient download bearer grant; persistence and string rendering must omit its secret URI. */ +public final class DirectDownloadGrant { + + private final DirectTransferSessionId sessionId; + private final URI requestUri; + private final Map signedHeaders; + private final Instant expiresAt; + + public DirectDownloadGrant( + DirectTransferSessionId sessionId, + URI requestUri, + Map signedHeaders, + Instant expiresAt) { + this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null"); + this.requestUri = requireGrantUri(requestUri); + this.signedHeaders = ObjectModelSupport.immutableHeaders(signedHeaders); + this.expiresAt = ObjectModelSupport.requireExpiry(expiresAt); + } + + public DirectTransferSessionId sessionId() { + return sessionId; + } + + public URI requestUri() { + return requestUri; + } + + public Map signedHeaders() { + return signedHeaders; + } + + public Instant expiresAt() { + return expiresAt; + } + + private static URI requireGrantUri(URI uri) { + if (uri == null + || uri.toASCIIString().length() > 4096 + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getFragment() != null + || !("https".equalsIgnoreCase(uri.getScheme()) + || "http".equalsIgnoreCase(uri.getScheme()))) { + throw new IllegalArgumentException("grant URI is invalid"); + } + return uri; + } + + @Override + public String toString() { + return "DirectDownloadGrant[session=" + sessionId.redactedLogToken() + ", redacted]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectUploadCompletionReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectUploadCompletionReceipt.java new file mode 100644 index 0000000..8d1901e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectUploadCompletionReceipt.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.util.Objects; + +/** Server-verified completion; a client success claim alone never creates this receipt. */ +public record DirectUploadCompletionReceipt( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectContentIdentity verifiedContent, + ObjectMutationOutcome outcome) { + + public DirectUploadCompletionReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(sessionId, "sessionId must be non-null"); + Objects.requireNonNull(stageHandle, "stageHandle must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(verifiedContent, "verifiedContent must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectUploadGrant.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectUploadGrant.java new file mode 100644 index 0000000..a19a2e8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/DirectUploadGrant.java @@ -0,0 +1,61 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** Transient bearer grant; string rendering never includes its URI or signed header values. */ +public final class DirectUploadGrant { + + private final DirectTransferSessionId sessionId; + private final URI requestUri; + private final Map signedHeaders; + private final Instant expiresAt; + + public DirectUploadGrant( + DirectTransferSessionId sessionId, + URI requestUri, + Map signedHeaders, + Instant expiresAt) { + this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null"); + this.requestUri = requireGrantUri(requestUri); + this.signedHeaders = ObjectModelSupport.immutableHeaders(signedHeaders); + this.expiresAt = ObjectModelSupport.requireExpiry(expiresAt); + } + + public DirectTransferSessionId sessionId() { + return sessionId; + } + + public URI requestUri() { + return requestUri; + } + + public Map signedHeaders() { + return signedHeaders; + } + + public Instant expiresAt() { + return expiresAt; + } + + private static URI requireGrantUri(URI uri) { + if (uri == null + || uri.toASCIIString().length() > 4096 + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getFragment() != null + || !("https".equalsIgnoreCase(uri.getScheme()) + || "http".equalsIgnoreCase(uri.getScheme()))) { + throw new IllegalArgumentException("grant URI is invalid"); + } + return uri; + } + + @Override + public String toString() { + return "DirectUploadGrant[session=" + sessionId.redactedLogToken() + ", redacted]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/MultipartReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/MultipartReceipt.java new file mode 100644 index 0000000..aa77fba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/MultipartReceipt.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.util.Objects; + +/** Verified multipart completion receipt that still represents an unpublished stage. */ +public record MultipartReceipt( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectContentIdentity verifiedContent, + ObjectMutationOutcome outcome) { + + public MultipartReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(sessionId, "sessionId must be non-null"); + Objects.requireNonNull(stageHandle, "stageHandle must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(verifiedContent, "verifiedContent must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/MultipartSession.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/MultipartSession.java new file mode 100644 index 0000000..3ee83da --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/MultipartSession.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import java.time.Instant; +import java.util.Objects; + +/** Opaque multipart session with a bounded part budget and expiry. */ +public record MultipartSession( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + Instant expiresAt, + int maximumParts, + ObjectMutationOutcome outcome) { + + public MultipartSession { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(sessionId, "sessionId must be non-null"); + Objects.requireNonNull(expiresAt, "expiresAt must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + if (maximumParts < 1 || maximumParts > 10_000) { + throw new IllegalArgumentException("maximumParts is outside the supported range"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectCapabilityRequirement.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectCapabilityRequirement.java new file mode 100644 index 0000000..4670895 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectCapabilityRequirement.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Named guarantee that a compiled destination/provider profile must satisfy. */ +public enum ObjectCapabilityRequirement { + IMMUTABLE_CREATE, + EXACT_VERSION_READ, + CONDITIONAL_RETIREMENT, + SHA_256_VERIFICATION, + SCAN_GATED_PUBLICATION, + DIRECT_UPLOAD, + DIRECT_MULTIPART, + RETENTION_HOLD, + SERVER_SIDE_ENCRYPTION, + RESPONSE_LOSS_RECONCILIATION +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectContentIdentity.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectContentIdentity.java new file mode 100644 index 0000000..64130c4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectContentIdentity.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.util.Base64; +import java.util.Objects; + +/** Exact expected byte length plus a logical full-content digest. */ +public record ObjectContentIdentity(long exactLength, ObjectDigest fullDigest) { + + public ObjectContentIdentity { + if (exactLength < 0) { + throw new IllegalArgumentException("exactLength must be non-negative"); + } + Objects.requireNonNull(fullDigest, "fullDigest must be non-null"); + } + + public static ObjectContentIdentity sha256(long exactLength, byte[] digestBytes) { + Objects.requireNonNull(digestBytes, "digestBytes must be non-null"); + return new ObjectContentIdentity( + exactLength, + ObjectDigest.of( + ObjectDigestAlgorithm.SHA_256, + Base64.getEncoder().encodeToString(digestBytes.clone()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDescriptor.java new file mode 100644 index 0000000..d76eef8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDescriptor.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Published logical object metadata with no physical provider coordinates. */ +public record ObjectDescriptor( + ObjectReference reference, + ObjectVersionToken exactVersion, + ObjectContentIdentity contentIdentity, + ObjectMediaType declaredMediaType, + Optional detectedMediaType, + ObjectPublicationState publicationState, + ObjectScanState scanState, + ObjectEncryptionRequirement encryption, + ObjectRetentionRequirement retention, + Instant createdAt, + Optional publishedAt, + int schemaVersion) { + + public ObjectDescriptor { + Objects.requireNonNull(reference, "reference must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + Objects.requireNonNull(declaredMediaType, "declaredMediaType must be non-null"); + Objects.requireNonNull(detectedMediaType, "detectedMediaType must be non-null"); + Objects.requireNonNull(publicationState, "publicationState must be non-null"); + Objects.requireNonNull(scanState, "scanState must be non-null"); + Objects.requireNonNull(encryption, "encryption must be non-null"); + Objects.requireNonNull(retention, "retention must be non-null"); + Objects.requireNonNull(createdAt, "createdAt must be non-null"); + Objects.requireNonNull(publishedAt, "publishedAt must be non-null"); + if (schemaVersion < 1) { + throw new IllegalArgumentException("schemaVersion must be positive"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigest.java new file mode 100644 index 0000000..8bb5d2d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigest.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Objects; + +/** Canonical Base64-encoded logical full-content digest. */ +public record ObjectDigest(ObjectDigestAlgorithm algorithm, String base64Value) { + + public ObjectDigest { + Objects.requireNonNull(algorithm, "algorithm must be non-null"); + if (base64Value == null || base64Value.isBlank() || base64Value.length() > 128) { + throw new IllegalArgumentException("digest value is not canonical"); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(base64Value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("digest value is not canonical", exception); + } + if (!Base64.getEncoder().encodeToString(decoded).equals(base64Value) + || (algorithm == ObjectDigestAlgorithm.SHA_256 && decoded.length != 32)) { + throw new IllegalArgumentException("digest value is not canonical"); + } + } + + public static ObjectDigest of(ObjectDigestAlgorithm algorithm, String base64Value) { + return new ObjectDigest(algorithm, base64Value); + } + + public static ObjectDigest sha256(byte[] content) { + Objects.requireNonNull(content, "content must be non-null"); + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(content); + return new ObjectDigest( + ObjectDigestAlgorithm.SHA_256, Base64.getEncoder().encodeToString(digest)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + public byte[] decodedValue() { + return Base64.getDecoder().decode(base64Value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigestAlgorithm.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigestAlgorithm.java new file mode 100644 index 0000000..abee02c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigestAlgorithm.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Logical full-content digest algorithms; provider ETags and composite checksums are excluded. */ +public enum ObjectDigestAlgorithm { + SHA_256 +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigestVerification.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigestVerification.java new file mode 100644 index 0000000..1bf2049 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectDigestVerification.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Requested logical integrity verification for a read. */ +public enum ObjectDigestVerification { + NONE, + FULL_CONTENT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectEncryptionRequirement.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectEncryptionRequirement.java new file mode 100644 index 0000000..6554556 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectEncryptionRequirement.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.util.Objects; + +/** Minimum provider-neutral encryption profile strength. */ +public enum ObjectEncryptionRequirement { + PROVIDER_MANAGED, + CUSTOMER_MANAGED; + + public ObjectEncryptionRequirement requireAtLeast(ObjectEncryptionRequirement minimum) { + Objects.requireNonNull(minimum, "minimum must be non-null"); + return this == CUSTOMER_MANAGED || minimum == PROVIDER_MANAGED ? this : CUSTOMER_MANAGED; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectHandoffReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectHandoffReceipt.java new file mode 100644 index 0000000..77a8e60 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectHandoffReceipt.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import java.time.Instant; +import java.util.Objects; + +/** Monotonic application/publication handoff fence. */ +public record ObjectHandoffReceipt( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + long fence, + Instant leaseExpiresAt, + ObjectMutationOutcome outcome) { + + public ObjectHandoffReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(stageHandle, "stageHandle must be non-null"); + Objects.requireNonNull(leaseExpiresAt, "leaseExpiresAt must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + if (fence < 1) { + throw new IllegalArgumentException("fence must be positive"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMediaType.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMediaType.java new file mode 100644 index 0000000..ee68153 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMediaType.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.util.Locale; + +/** Canonical bounded declared media type without parameters or control characters. */ +public record ObjectMediaType(String canonicalText) { + + public ObjectMediaType { + if (canonicalText == null || canonicalText.length() > 127) { + throw new IllegalArgumentException("media type is not canonical"); + } + canonicalText = canonicalText.toLowerCase(Locale.ROOT); + String token = "[a-z0-9!#$&^_.+-]+"; + if (!canonicalText.matches(token + "/" + token)) { + throw new IllegalArgumentException("media type is not canonical"); + } + } + + public static ObjectMediaType of(String value) { + return new ObjectMediaType(value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectModelSupport.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectModelSupport.java new file mode 100644 index 0000000..6c764de --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectModelSupport.java @@ -0,0 +1,44 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +final class ObjectModelSupport { + + private ObjectModelSupport() {} + + static String requireBoundedToken(String label, String value, int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength + || value.chars().anyMatch(character -> character < 0x20 || character == 0x7f)) { + throw new IllegalArgumentException(label + " is invalid"); + } + return value; + } + + static Map immutableHeaders(Map headers) { + if (headers == null || headers.size() > 32) { + throw new IllegalArgumentException("signed headers are invalid"); + } + Map copy = new LinkedHashMap<>(); + headers.forEach( + (name, value) -> { + String checkedName = requireBoundedToken("signed header name", name, 128); + String checkedValue = requireBoundedToken("signed header value", value, 1024); + if (!checkedName.equals(checkedName.toLowerCase(java.util.Locale.ROOT))) { + throw new IllegalArgumentException("signed header name is invalid"); + } + copy.put(checkedName, checkedValue); + }); + return Map.copyOf(copy); + } + + static Instant requireExpiry(Instant expiresAt) { + if (expiresAt == null) { + throw new IllegalArgumentException("grant expiry is invalid"); + } + return expiresAt; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMutationOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMutationOutcome.java new file mode 100644 index 0000000..6af0792 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMutationOutcome.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Provider-neutral certainty/result of a logical object mutation. */ +public enum ObjectMutationOutcome { + APPLIED, + REPLAYED, + NO_CHANGE, + HELD, + INDETERMINATE, + REJECTED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMutationReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMutationReceipt.java new file mode 100644 index 0000000..e6c8efd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectMutationReceipt.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import java.time.Instant; +import java.util.Objects; + +/** Provider-neutral result for retirement, purge, abort, verdict, or handoff mutation. */ +public record ObjectMutationReceipt( + ObjectOperationKey operationKey, + ObjectMutationOutcome outcome, + ObjectOperationError error, + Instant appliedAt) { + + public ObjectMutationReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + Objects.requireNonNull(error, "error must be non-null"); + Objects.requireNonNull(appliedAt, "appliedAt must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectOperationError.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectOperationError.java new file mode 100644 index 0000000..1e2c4a5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectOperationError.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Bounded provider-neutral operation error classification. */ +public enum ObjectOperationError { + NONE, + INVALID_REQUEST, + NOT_FOUND, + CONFLICT, + UNSUPPORTED_CAPABILITY, + PROVIDER_UNAVAILABLE, + CORRUPT_EVIDENCE, + OPERATION_EPOCH_NOT_ACTIVE, + OPERATION_EXPIRED, + CANCELLED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectOperationResolution.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectOperationResolution.java new file mode 100644 index 0000000..25d18ca --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectOperationResolution.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import java.util.Objects; +import java.util.Optional; + +/** Read-only recovery view for an exact operation key. */ +public record ObjectOperationResolution( + ObjectOperationKey operationKey, + ObjectPublicationState state, + ObjectMutationOutcome outcome, + ObjectOperationError error, + Optional publishedReference) { + + public ObjectOperationResolution { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(state, "state must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + Objects.requireNonNull(error, "error must be non-null"); + Objects.requireNonNull(publishedReference, "publishedReference must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublicationRequirement.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublicationRequirement.java new file mode 100644 index 0000000..4950407 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublicationRequirement.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.util.Objects; + +/** Minimum evidence required before a reference may become published. */ +public enum ObjectPublicationRequirement { + INTEGRITY_VERIFIED, + SCAN_CLEAN; + + public ObjectPublicationRequirement requireAtLeast(ObjectPublicationRequirement minimum) { + Objects.requireNonNull(minimum, "minimum must be non-null"); + return this == SCAN_CLEAN || minimum == INTEGRITY_VERIFIED ? this : SCAN_CLEAN; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublicationState.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublicationState.java new file mode 100644 index 0000000..b5ba176 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublicationState.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Logical visibility state; only PUBLISHED is available through public read ports. */ +public enum ObjectPublicationState { + STAGED, + VERIFIED, + PUBLISHED, + RETIRED, + ABORTED, + QUARANTINED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublishReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublishReceipt.java new file mode 100644 index 0000000..ca3fdeb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectPublishReceipt.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.time.Instant; +import java.util.Objects; + +/** Durable terminal publication receipt containing only semantic identities and evidence. */ +public record ObjectPublishReceipt( + ObjectOperationKey operationKey, + RequestFingerprint requestFingerprint, + ObjectReference reference, + ObjectVersionToken exactVersion, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType, + ObjectMutationOutcome outcome, + Instant appliedAt, + String capabilityRevision) { + + public ObjectPublishReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(requestFingerprint, "requestFingerprint must be non-null"); + Objects.requireNonNull(reference, "reference must be non-null"); + 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(outcome, "outcome must be non-null"); + Objects.requireNonNull(appliedAt, "appliedAt must be non-null"); + capabilityRevision = + ObjectModelSupport.requireBoundedToken("capabilityRevision", capabilityRevision, 128); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectReadRange.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectReadRange.java new file mode 100644 index 0000000..9b3d544 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectReadRange.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.objectstorage.model; + +/** One contiguous zero-based read range. */ +public record ObjectReadRange(long offset, long length) { + + public ObjectReadRange { + if (offset < 0 || length < 1) { + throw new IllegalArgumentException("range offset/length are outside the supported range"); + } + try { + Math.addExact(offset, length); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("range end overflows", exception); + } + } + + public static ObjectReadRange of(long offset, long length) { + return new ObjectReadRange(offset, length); + } + + public long endExclusive() { + return Math.addExact(offset, length); + } + + public ObjectReadRange requireMaximumDeliveredBytes(long maximumDeliveredBytes) { + if (maximumDeliveredBytes < 1 || length > maximumDeliveredBytes) { + throw new IllegalArgumentException("range exceeds maximum delivered bytes"); + } + return this; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectReadReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectReadReceipt.java new file mode 100644 index 0000000..6b32bbb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectReadReceipt.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.util.Objects; + +/** Completed bounded read facts; it is not proof that a partial range verified the full digest. */ +public record ObjectReadReceipt( + ObjectReference reference, + ObjectVersionToken exactVersion, + ObjectReadRange deliveredRange, + long deliveredBytes, + ObjectDigestVerification digestVerification) { + + public ObjectReadReceipt { + Objects.requireNonNull(reference, "reference must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(deliveredRange, "deliveredRange must be non-null"); + Objects.requireNonNull(digestVerification, "digestVerification must be non-null"); + if (deliveredBytes < 0 || deliveredBytes > deliveredRange.length()) { + throw new IllegalArgumentException("deliveredBytes is outside the requested range"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectRetentionRequirement.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectRetentionRequirement.java new file mode 100644 index 0000000..cbe679b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectRetentionRequirement.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.objectstorage.model; + +import java.util.Objects; + +/** Minimum logical retention strength selected by a destination and optionally strengthened. */ +public enum ObjectRetentionRequirement { + NONE, + RETAIN_UNTIL_POLICY, + LEGAL_HOLD; + + public ObjectRetentionRequirement requireAtLeast(ObjectRetentionRequirement minimum) { + Objects.requireNonNull(minimum, "minimum must be non-null"); + if (this == LEGAL_HOLD || minimum == NONE) { + return this; + } + if (minimum == LEGAL_HOLD) { + return LEGAL_HOLD; + } + return this == NONE ? RETAIN_UNTIL_POLICY : this; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectScanReadReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectScanReadReceipt.java new file mode 100644 index 0000000..a2bcdc1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectScanReadReceipt.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.util.Objects; + +/** Bounded unpublished scanner read receipt; deliberately cannot carry a published reference. */ +public record ObjectScanReadReceipt( + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectReadRange deliveredRange, + long deliveredBytes, + ObjectDigestVerification digestVerification) { + + public ObjectScanReadReceipt { + Objects.requireNonNull(stageHandle, "stageHandle must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(deliveredRange, "deliveredRange must be non-null"); + Objects.requireNonNull(digestVerification, "digestVerification must be non-null"); + if (deliveredBytes < 0 || deliveredBytes > deliveredRange.length()) { + throw new IllegalArgumentException("deliveredBytes is outside the requested range"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectScanState.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectScanState.java new file mode 100644 index 0000000..6c20148 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectScanState.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.objectstorage.model; + +/** Scanner verdict bound to an exact unpublished version and policy revision. */ +public enum ObjectScanState { + NOT_REQUIRED, + PENDING, + CLEAN, + MALICIOUS, + INDETERMINATE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectStageReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectStageReceipt.java new file mode 100644 index 0000000..076ee0c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectStageReceipt.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.util.Objects; + +/** Unpublished stage receipt; deliberately contains no published reference. */ +public record ObjectStageReceipt( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectContentIdentity contentIdentity, + ObjectMutationOutcome outcome) { + + public ObjectStageReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(stageHandle, "stageHandle must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectVerificationReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectVerificationReceipt.java new file mode 100644 index 0000000..cbc8eba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/ObjectVerificationReceipt.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.util.Objects; + +/** Exact-version integrity verification result for an unpublished stage. */ +public record ObjectVerificationReceipt( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectContentIdentity verifiedContent, + ObjectMutationOutcome outcome) { + + public ObjectVerificationReceipt { + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(stageHandle, "stageHandle must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(verifiedContent, "verifiedContent must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/PartUploadGrant.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/PartUploadGrant.java new file mode 100644 index 0000000..c72eca9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/PartUploadGrant.java @@ -0,0 +1,73 @@ +package dev.caskeleton.application.objectstorage.model; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** Transient multipart-part bearer grant with redacted rendering. */ +public final class PartUploadGrant { + + private final DirectTransferSessionId sessionId; + private final MultipartPartNumber partNumber; + private final URI requestUri; + private final Map signedHeaders; + private final Instant expiresAt; + + public PartUploadGrant( + DirectTransferSessionId sessionId, + MultipartPartNumber partNumber, + URI requestUri, + Map signedHeaders, + Instant expiresAt) { + this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null"); + this.partNumber = Objects.requireNonNull(partNumber, "partNumber must be non-null"); + this.requestUri = requireGrantUri(requestUri); + this.signedHeaders = ObjectModelSupport.immutableHeaders(signedHeaders); + this.expiresAt = ObjectModelSupport.requireExpiry(expiresAt); + } + + public DirectTransferSessionId sessionId() { + return sessionId; + } + + public MultipartPartNumber partNumber() { + return partNumber; + } + + public URI requestUri() { + return requestUri; + } + + public Map signedHeaders() { + return signedHeaders; + } + + public Instant expiresAt() { + return expiresAt; + } + + private static URI requireGrantUri(URI uri) { + if (uri == null + || uri.toASCIIString().length() > 4096 + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getFragment() != null + || !("https".equalsIgnoreCase(uri.getScheme()) + || "http".equalsIgnoreCase(uri.getScheme()))) { + throw new IllegalArgumentException("grant URI is invalid"); + } + return uri; + } + + @Override + public String toString() { + return "PartUploadGrant[session=" + + sessionId.redactedLogToken() + + ", part=" + + partNumber.value() + + ", redacted]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectMultipartUploadPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectMultipartUploadPort.java new file mode 100644 index 0000000..e82eacd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectMultipartUploadPort.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.objectstorage.port; + +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.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.model.PartUploadGrant; +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; + +/** Adapter-owned multipart session, part-ledger, completion, and abort boundary. */ +public interface DirectMultipartUploadPort { + + MultipartSession startMultipart(MultipartStartRequest request); + + PartUploadGrant createPartGrant(PartUploadGrantRequest request); + + PartReceiptToken acknowledgePart(MultipartPartAcknowledgement request); + + MultipartReceipt completeMultipart(MultipartCompleteRequest request); + + ObjectMutationReceipt abortMultipart(MultipartAbortRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectObjectDownloadGrantPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectObjectDownloadGrantPort.java new file mode 100644 index 0000000..37dcd58 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectObjectDownloadGrantPort.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.model.DirectDownloadGrant; +import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest; + +/** Issues a bounded exact-version download grant after application authorization. */ +public interface DirectObjectDownloadGrantPort { + + DirectDownloadGrant createDownloadGrant(DirectDownloadGrantRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectObjectUploadPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectObjectUploadPort.java new file mode 100644 index 0000000..8fe87e8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/DirectObjectUploadPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.model.DirectUploadCompletionReceipt; +import dev.caskeleton.application.objectstorage.model.DirectUploadGrant; +import dev.caskeleton.application.objectstorage.request.DirectUploadCompletionRequest; +import dev.caskeleton.application.objectstorage.request.DirectUploadGrantRequest; + +/** Direct single-object grant issuance and server-side completion verification. */ +public interface DirectObjectUploadPort { + + DirectUploadGrant createUploadGrant(DirectUploadGrantRequest request); + + DirectUploadCompletionReceipt completeUpload(DirectUploadCompletionRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ManagedObjectPublicationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ManagedObjectPublicationPort.java new file mode 100644 index 0000000..8708c68 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ManagedObjectPublicationPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest; + +/** Managed single-call publication for destinations whose compiled policy does not require scan. */ +public interface ManagedObjectPublicationPort { + + ObjectPublishReceipt publish(ObjectPublishRequest request, ObjectContentProducer producer); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectInspectionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectInspectionPort.java new file mode 100644 index 0000000..cd73dbd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectInspectionPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.model.ObjectDescriptor; +import java.util.Optional; + +/** Inspects only published opaque references. */ +public interface ObjectInspectionPort { + + Optional inspect(ObjectReference reference); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectOperationResolutionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectOperationResolutionPort.java new file mode 100644 index 0000000..854352d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectOperationResolutionPort.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectOperationResolution; + +/** Read-only recovery lookup for an exact retained operation key. */ +public interface ObjectOperationResolutionPort { + + ObjectOperationResolution resolve(ObjectOperationKey operationKey); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectPublicationHandoffPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectPublicationHandoffPort.java new file mode 100644 index 0000000..1bdd217 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectPublicationHandoffPort.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.model.ObjectHandoffReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectAbortAuthorization; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffReleaseRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffRenewRequest; + +/** Fenced handoff seam between application intent and storage publication/cleanup. */ +public interface ObjectPublicationHandoffPort { + + ObjectHandoffReceipt claimForPublication(ObjectHandoffClaimRequest request); + + ObjectHandoffReceipt renewClaim(ObjectHandoffRenewRequest request); + + ObjectMutationReceipt releaseClaim(ObjectHandoffReleaseRequest request); + + ObjectMutationReceipt authorizeAbort(ObjectAbortAuthorization authorization); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectPurgeMaintenancePort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectPurgeMaintenancePort.java new file mode 100644 index 0000000..25987f9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectPurgeMaintenancePort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectPurgeRequest; + +/** + * Privileged exact-version physical purge boundary. It must be assembled only in a separate + * maintenance composition with stronger credentials and must never be injected into normal business + * use cases. + */ +public interface ObjectPurgeMaintenancePort { + + ObjectMutationReceipt purge(ObjectPurgeRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectRetirementPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectRetirementPort.java new file mode 100644 index 0000000..f878138 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectRetirementPort.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest; + +/** Logical business retirement; it does not grant physical purge authority. */ +public interface ObjectRetirementPort { + + ObjectMutationReceipt retire(ObjectRetireRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectScanMaintenancePort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectScanMaintenancePort.java new file mode 100644 index 0000000..07356d4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectScanMaintenancePort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer; +import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectScanReadReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectScanReadRequest; +import dev.caskeleton.application.objectstorage.request.ObjectScanVerdictRequest; + +/** + * Narrow scanner-workflow composition boundary for unpublished exact-version transfer and verdict + * recording. It must not share a normal publication router or privileged purge router. + */ +public interface ObjectScanMaintenancePort { + + ObjectScanReadReceipt transferForScan( + ObjectScanReadRequest request, ObjectContentConsumer consumer); + + ObjectMutationReceipt recordScanVerdict(ObjectScanVerdictRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectTransferPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectTransferPort.java new file mode 100644 index 0000000..1cbb4f3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/ObjectTransferPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer; +import dev.caskeleton.application.objectstorage.model.ObjectReadReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectReadRequest; + +/** Bounded server-mediated transfer of an authorized published reference. */ +public interface ObjectTransferPort { + + ObjectReadReceipt transfer(ObjectReadRequest request, ObjectContentConsumer consumer); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/StagedObjectPublicationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/StagedObjectPublicationPort.java new file mode 100644 index 0000000..073eedd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/StagedObjectPublicationPort.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.objectstorage.port; + +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectStageReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt; +import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest; +import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest; +import dev.caskeleton.application.objectstorage.request.ObjectStageRequest; +import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest; + +/** Normal staged publication; scanner unpublished-read/verdict authority is deliberately absent. */ +public interface StagedObjectPublicationPort { + + ObjectStageReceipt stage(ObjectStageRequest request, ObjectContentProducer producer); + + ObjectVerificationReceipt verifyIntegrity(ObjectVerifyRequest request); + + ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request); + + ObjectMutationReceipt abort(ObjectAbortRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectDownloadGrantRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectDownloadGrantRequest.java new file mode 100644 index 0000000..68e7959 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectDownloadGrantRequest.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** Request for a short-lived exact-version download grant after application authorization. */ +public record DirectDownloadGrantRequest( + ObjectOperationKey operationKey, + ObjectReference reference, + Optional expectedVersion, + Duration requestedTtl, + CallBudget budget, + CancellationView cancellation) { + + public DirectDownloadGrantRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(reference, "reference"); + Objects.requireNonNull(expectedVersion, "expectedVersion must be non-null"); + requestedTtl = ObjectRequestSupport.grantTtl(requestedTtl); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectUploadCompletionRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectUploadCompletionRequest.java new file mode 100644 index 0000000..862a4ec --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectUploadCompletionRequest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.outbound.CallBudget; + +/** Server verification request for a direct upload; it contains no trusted client-success flag. */ +public record DirectUploadCompletionRequest( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + ObjectContentIdentity expectedContent, + CallBudget budget, + CancellationView cancellation) { + + public DirectUploadCompletionRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(sessionId, "sessionId"); + ObjectRequestSupport.required(expectedContent, "expectedContent"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectUploadGrantRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectUploadGrantRequest.java new file mode 100644 index 0000000..9a36f5f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/DirectUploadGrantRequest.java @@ -0,0 +1,30 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +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.outbound.CallBudget; +import java.time.Duration; + +/** Request for a bounded direct-upload bearer grant. */ +public record DirectUploadGrantRequest( + ObjectOperationKey operationKey, + ObjectMediaType declaredMediaType, + ObjectContentIdentity contentIdentity, + ObjectPublicationRequirement publicationRequirement, + Duration requestedTtl, + CallBudget budget, + CancellationView cancellation) { + + public DirectUploadGrantRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(declaredMediaType, "declaredMediaType"); + ObjectRequestSupport.required(contentIdentity, "contentIdentity"); + ObjectRequestSupport.required(publicationRequirement, "publicationRequirement"); + requestedTtl = ObjectRequestSupport.grantTtl(requestedTtl); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartAbortRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartAbortRequest.java new file mode 100644 index 0000000..7e4faa6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartAbortRequest.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.outbound.CallBudget; + +/** Conditional abort of one exact multipart session. */ +public record MultipartAbortRequest( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + CallBudget budget, + CancellationView cancellation) { + + public MultipartAbortRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(sessionId, "sessionId"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartCompleteRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartCompleteRequest.java new file mode 100644 index 0000000..a7bacf5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartCompleteRequest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.PartReceiptToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.outbound.CallBudget; +import java.util.List; +import java.util.Objects; + +/** Completes multipart using only server-issued part tokens and an exact full-content identity. */ +public record MultipartCompleteRequest( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + List partTokens, + ObjectContentIdentity expectedContent, + CallBudget budget, + CancellationView cancellation) { + + public MultipartCompleteRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(sessionId, "sessionId"); + ObjectRequestSupport.required(partTokens, "partTokens"); + if (partTokens.isEmpty() + || partTokens.size() > 10_000 + || partTokens.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("partTokens are outside the supported range"); + } + partTokens = List.copyOf(partTokens); + ObjectRequestSupport.required(expectedContent, "expectedContent"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartPartAcknowledgement.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartPartAcknowledgement.java new file mode 100644 index 0000000..87fd559 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartPartAcknowledgement.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; + +/** Bounded client completion claim that the server verifies before issuing an opaque part token. */ +public record MultipartPartAcknowledgement( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + MultipartPartNumber partNumber, + long observedLength, + ObjectDigest observedDigest, + String clientCompletionClaim) { + + public MultipartPartAcknowledgement { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(sessionId, "sessionId"); + ObjectRequestSupport.required(partNumber, "partNumber"); + ObjectRequestSupport.positive(observedLength, "observedLength"); + ObjectRequestSupport.required(observedDigest, "observedDigest"); + clientCompletionClaim = + ObjectRequestSupport.boundedToken(clientCompletionClaim, "clientCompletionClaim", 1024); + } + + @Override + public String toString() { + return "MultipartPartAcknowledgement[operation=" + + operationKey.operationId().value() + + ", session=" + + sessionId.redactedLogToken() + + ", part=" + + partNumber.value() + + ", completionClaim=redacted]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartStartRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartStartRequest.java new file mode 100644 index 0000000..a983bef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/MultipartStartRequest.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +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.outbound.CallBudget; +import java.time.Duration; + +/** Starts one adapter-owned multipart session for an exact content identity. */ +public record MultipartStartRequest( + ObjectOperationKey operationKey, + ObjectMediaType declaredMediaType, + ObjectContentIdentity contentIdentity, + ObjectPublicationRequirement publicationRequirement, + int maximumParts, + Duration sessionTtl, + CallBudget budget, + CancellationView cancellation) { + + public MultipartStartRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(declaredMediaType, "declaredMediaType"); + ObjectRequestSupport.required(contentIdentity, "contentIdentity"); + ObjectRequestSupport.required(publicationRequirement, "publicationRequirement"); + if (maximumParts < 1 || maximumParts > 10_000) { + throw new IllegalArgumentException("maximumParts is outside the supported range"); + } + sessionTtl = ObjectRequestSupport.grantTtl(sessionTtl); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectAbortAuthorization.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectAbortAuthorization.java new file mode 100644 index 0000000..7d90bbb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectAbortAuthorization.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import java.time.Instant; + +/** Application-issued exact authorization to destructively abort one unpublished stage. */ +public record ObjectAbortAuthorization( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + long handoffFence, + Instant authorizedAt) { + + public ObjectAbortAuthorization { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.positive(handoffFence, "handoffFence"); + ObjectRequestSupport.required(authorizedAt, "authorizedAt"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectAbortRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectAbortRequest.java new file mode 100644 index 0000000..f45ed33 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectAbortRequest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.outbound.CallBudget; + +/** Abort request that must carry the exact application-issued authorization. */ +public record ObjectAbortRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectAbortAuthorization authorization, + CallBudget budget, + CancellationView cancellation) { + + public ObjectAbortRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(authorization, "authorization"); + if (!operationKey.equals(authorization.operationKey()) + || !stageHandle.equals(authorization.stageHandle())) { + throw new IllegalArgumentException("abort authorization binding does not match request"); + } + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectFinalizeRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectFinalizeRequest.java new file mode 100644 index 0000000..c5f7d98 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectFinalizeRequest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +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.ObjectPublicationRequirement; +import dev.caskeleton.application.outbound.CallBudget; + +/** Finalize is the sole staged operation allowed to mint a published reference. */ +public record ObjectFinalizeRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectPublicationRequirement publicationRequirement, + CallBudget budget, + CancellationView cancellation) { + + public ObjectFinalizeRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.required(publicationRequirement, "publicationRequirement"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffClaimRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffClaimRequest.java new file mode 100644 index 0000000..c19ce4a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffClaimRequest.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +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.outbound.CallBudget; +import java.time.Duration; + +/** Claim request that starts a monotonically fenced publication handoff lease. */ +public record ObjectHandoffClaimRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + String claimant, + Duration leaseDuration, + CallBudget budget, + CancellationView cancellation) { + + public ObjectHandoffClaimRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + claimant = ObjectRequestSupport.boundedToken(claimant, "claimant", 128); + leaseDuration = ObjectRequestSupport.grantTtl(leaseDuration); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffReleaseRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffReleaseRequest.java new file mode 100644 index 0000000..081ad0c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffReleaseRequest.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.outbound.CallBudget; + +/** Conditional release of the exact claimant/fence pair. */ +public record ObjectHandoffReleaseRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + long fence, + String claimant, + CallBudget budget, + CancellationView cancellation) { + + public ObjectHandoffReleaseRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.positive(fence, "fence"); + claimant = ObjectRequestSupport.boundedToken(claimant, "claimant", 128); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffRenewRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffRenewRequest.java new file mode 100644 index 0000000..4a2117c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectHandoffRenewRequest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; + +/** Renewal request for the same claimant and exact monotonic handoff fence. */ +public record ObjectHandoffRenewRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + long fence, + String claimant, + Duration leaseDuration, + CallBudget budget, + CancellationView cancellation) { + + public ObjectHandoffRenewRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.positive(fence, "fence"); + claimant = ObjectRequestSupport.boundedToken(claimant, "claimant", 128); + leaseDuration = ObjectRequestSupport.grantTtl(leaseDuration); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectPublishRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectPublishRequest.java new file mode 100644 index 0000000..c72bf9d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectPublishRequest.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; +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 dev.caskeleton.application.outbound.CallBudget; +import java.util.Set; + +/** Immutable intent for a managed scan-free publication. */ +public record ObjectPublishRequest( + ObjectOperationKey operationKey, + ObjectMediaType declaredMediaType, + ObjectContentIdentity contentIdentity, + ObjectPublicationRequirement publicationRequirement, + ObjectRetentionRequirement retentionRequirement, + ObjectEncryptionRequirement encryptionRequirement, + Set capabilityRequirements, + CallBudget budget, + CancellationView cancellation) { + + public ObjectPublishRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(declaredMediaType, "declaredMediaType"); + ObjectRequestSupport.required(contentIdentity, "contentIdentity"); + ObjectRequestSupport.required(publicationRequirement, "publicationRequirement"); + ObjectRequestSupport.required(retentionRequirement, "retentionRequirement"); + ObjectRequestSupport.required(encryptionRequirement, "encryptionRequirement"); + capabilityRequirements = ObjectRequestSupport.immutableCapabilities(capabilityRequirements); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectPurgeRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectPurgeRequest.java new file mode 100644 index 0000000..9a71139 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectPurgeRequest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.outbound.CallBudget; + +/** Privileged physical purge request with an exact immutable version precondition. */ +public record ObjectPurgeRequest( + ObjectOperationKey operationKey, + ObjectReference reference, + ObjectVersionToken exactVersion, + CallBudget budget, + CancellationView cancellation) { + + public ObjectPurgeRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(reference, "reference"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectReadRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectReadRequest.java new file mode 100644 index 0000000..88dd140 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectReadRequest.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectDigestVerification; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; +import java.util.Optional; + +/** Exact published-reference read request with one bounded range. */ +public record ObjectReadRequest( + ObjectReference reference, + Optional expectedVersion, + ObjectReadRange range, + ObjectDigestVerification digestVerification, + long maximumDeliveredBytes, + CallBudget budget, + CancellationView cancellation) { + + public ObjectReadRequest { + ObjectRequestSupport.required(reference, "reference"); + Objects.requireNonNull(expectedVersion, "expectedVersion must be non-null"); + ObjectRequestSupport.required(range, "range") + .requireMaximumDeliveredBytes(maximumDeliveredBytes); + ObjectRequestSupport.required(digestVerification, "digestVerification"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectRequestSupport.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectRequestSupport.java new file mode 100644 index 0000000..ccb111f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectRequestSupport.java @@ -0,0 +1,53 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +final class ObjectRequestSupport { + + private static final Duration MAXIMUM_GRANT_TTL = Duration.ofHours(24); + + private ObjectRequestSupport() {} + + static T required(T value, String label) { + return Objects.requireNonNull(value, label + " must be non-null"); + } + + static String boundedToken(String value, String label, int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength + || value.chars().anyMatch(character -> character < 0x20 || character == 0x7f)) { + throw new IllegalArgumentException(label + " is invalid"); + } + return value; + } + + static Set immutableCapabilities( + Set capabilities) { + required(capabilities, "capabilities"); + if (capabilities.contains(null)) { + throw new IllegalArgumentException("capabilities must not contain null"); + } + return Set.copyOf(capabilities); + } + + static Duration grantTtl(Duration requestedTtl) { + required(requestedTtl, "requestedTtl"); + if (requestedTtl.isZero() + || requestedTtl.isNegative() + || requestedTtl.compareTo(MAXIMUM_GRANT_TTL) > 0) { + throw new IllegalArgumentException("requestedTtl is outside the supported range"); + } + return requestedTtl; + } + + static long positive(long value, String label) { + if (value < 1) { + throw new IllegalArgumentException(label + " must be positive"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectRetireRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectRetireRequest.java new file mode 100644 index 0000000..adc4847 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectRetireRequest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.identity.ObjectReference; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.outbound.CallBudget; + +/** Logical business retirement of one exact published reference/version. */ +public record ObjectRetireRequest( + ObjectOperationKey operationKey, + ObjectReference reference, + ObjectVersionToken exactVersion, + CallBudget budget, + CancellationView cancellation) { + + public ObjectRetireRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(reference, "reference"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectScanReadRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectScanReadRequest.java new file mode 100644 index 0000000..36b1866 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectScanReadRequest.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +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.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; + +/** Narrow maintenance read of one exact unpublished version for a scanner workflow. */ +public record ObjectScanReadRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectReadRange range, + long maximumDeliveredBytes, + CallBudget budget, + CancellationView cancellation) { + + public ObjectScanReadRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.required(range, "range") + .requireMaximumDeliveredBytes(maximumDeliveredBytes); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectScanVerdictRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectScanVerdictRequest.java new file mode 100644 index 0000000..4a1c8ea --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectScanVerdictRequest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.objectstorage.request; + +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.ObjectScanState; +import java.time.Instant; + +/** Scanner verdict fenced to an exact stage/version, scan operation, and policy revision. */ +public record ObjectScanVerdictRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectOperationId scanOperationId, + String scannerPolicyRevision, + ObjectScanState verdict, + Instant observedAt) { + + public ObjectScanVerdictRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.required(scanOperationId, "scanOperationId"); + scannerPolicyRevision = + ObjectRequestSupport.boundedToken(scannerPolicyRevision, "scannerPolicyRevision", 128); + ObjectRequestSupport.required(verdict, "verdict"); + if (verdict != ObjectScanState.CLEAN + && verdict != ObjectScanState.MALICIOUS + && verdict != ObjectScanState.INDETERMINATE) { + throw new IllegalArgumentException("verdict is not a terminal scanner verdict"); + } + ObjectRequestSupport.required(observedAt, "observedAt"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectStageRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectStageRequest.java new file mode 100644 index 0000000..6a257c3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectStageRequest.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; +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 dev.caskeleton.application.outbound.CallBudget; +import java.util.Set; + +/** Immutable unpublished-stage intent. */ +public record ObjectStageRequest( + ObjectOperationKey operationKey, + ObjectMediaType declaredMediaType, + ObjectContentIdentity contentIdentity, + ObjectPublicationRequirement publicationRequirement, + ObjectRetentionRequirement retentionRequirement, + ObjectEncryptionRequirement encryptionRequirement, + Set capabilityRequirements, + CallBudget budget, + CancellationView cancellation) { + + public ObjectStageRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(declaredMediaType, "declaredMediaType"); + ObjectRequestSupport.required(contentIdentity, "contentIdentity"); + ObjectRequestSupport.required(publicationRequirement, "publicationRequirement"); + ObjectRequestSupport.required(retentionRequirement, "retentionRequirement"); + ObjectRequestSupport.required(encryptionRequirement, "encryptionRequirement"); + capabilityRequirements = ObjectRequestSupport.immutableCapabilities(capabilityRequirements); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectVerifyRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectVerifyRequest.java new file mode 100644 index 0000000..eda56c8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/ObjectVerifyRequest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +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.outbound.CallBudget; + +/** Full-content integrity verification request for an exact unpublished stage/version. */ +public record ObjectVerifyRequest( + ObjectOperationKey operationKey, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + ObjectContentIdentity expectedContent, + CallBudget budget, + CancellationView cancellation) { + + public ObjectVerifyRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(stageHandle, "stageHandle"); + ObjectRequestSupport.required(exactVersion, "exactVersion"); + ObjectRequestSupport.required(expectedContent, "expectedContent"); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/PartUploadGrantRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/PartUploadGrantRequest.java new file mode 100644 index 0000000..f49b357 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/PartUploadGrantRequest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.objectstorage.request; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; + +/** Creates one bounded part grant tied to exact expected length and digest. */ +public record PartUploadGrantRequest( + ObjectOperationKey operationKey, + DirectTransferSessionId sessionId, + MultipartPartNumber partNumber, + long exactPartLength, + ObjectDigest expectedPartDigest, + Duration requestedTtl, + CallBudget budget, + CancellationView cancellation) { + + public PartUploadGrantRequest { + ObjectRequestSupport.required(operationKey, "operationKey"); + ObjectRequestSupport.required(sessionId, "sessionId"); + ObjectRequestSupport.required(partNumber, "partNumber"); + ObjectRequestSupport.positive(exactPartLength, "exactPartLength"); + ObjectRequestSupport.required(expectedPartDigest, "expectedPartDigest"); + requestedTtl = ObjectRequestSupport.grantTtl(requestedTtl); + ObjectRequestSupport.required(budget, "budget"); + ObjectRequestSupport.required(cancellation, "cancellation"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java index c89f6e1..980801a 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java @@ -9,12 +9,23 @@ import java.util.Optional; * ca-skeleton.objectstorage.backend}); see the {@code adapter:outbound:objectstorage} README for * the backend matrix and the key-mapping contract. * + *

Legacy compatibility only. This contract preserves caller-keyed overwrite and + * whole-{@code byte[]} materialization semantics in a separate legacy namespace while stored data + * and the sample endpoint migrate. New code must use the semantic ports under {@code + * dev.caskeleton.application.objectstorage}. Production activation must be removed after the + * additive API/data migration, dual-read observation, external-consumer approval, and zero-usage + * gates pass; no calendar removal date is implied here. + * *

Keys are backend-relative, caller-supplied, opaque strings (e.g. {@code * "posters/2026/cover.png"}). Implementations MUST reject a key that escapes the backend's * namespace (path traversal) with {@link IllegalArgumentException}. Content is passed and returned * as raw bytes; this port intentionally exposes no streaming/presigned-URL surface — a fork adds * those when a concrete feature needs them. + * + * @deprecated use the semantic object publication/read/retirement ports; do not adapt them back to + * raw keys */ +@Deprecated(forRemoval = true) public interface ObjectStoragePort { /** diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java index c75955f..39c8e26 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java @@ -4,15 +4,19 @@ import java.net.URI; import java.util.Objects; /** - * Immutable receipt for a blob stored through {@link ObjectStoragePort}. Framework-neutral value - * object (no Spring / AWS types) so the application layer stays decoupled from the storage backend. + * Immutable legacy receipt for a blob stored through {@link ObjectStoragePort}. It exposes a raw + * caller key and backend locator and therefore must remain confined to the separately activated + * compatibility namespace until API/data migration and zero-usage evidence permit removal. New code + * uses opaque semantic references and must not translate them into this lossy shape. * * @param key the object key the blob was stored under (backend-relative, never null/blank) * @param size the stored content length in bytes (never negative) * @param contentType the MIME type the blob was stored with (never null/blank) * @param location a backend-specific locator — a {@code file://} URI for the filesystem backend, an * {@code s3://bucket/key} URI for the S3/MinIO backend (never null) + * @deprecated use semantic receipts under {@code dev.caskeleton.application.objectstorage.model} */ +@Deprecated(forRemoval = true) public record StoredObject(String key, long size, String contentType, URI location) { public StoredObject { diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionApproval.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionApproval.java new file mode 100644 index 0000000..3b71ff1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionApproval.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.storage.migration; + +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import java.time.Instant; + +/** Verified, expiry-bounded two-approver authorization for one exact adoption manifest. */ +@Deprecated(forRemoval = true) +public record LegacyObjectAdoptionApproval( + ObjectOperationKey operationKey, + String manifestSha256, + String legacyNamespaceDigest, + ObjectDestinationId targetDestination, + String targetNamespaceDigest, + Instant notBefore, + Instant expiresAt, + String nonce, + String approvalDigest) { + + public LegacyObjectAdoptionApproval { + if (operationKey == null + || !hex64(manifestSha256) + || !hex64(legacyNamespaceDigest) + || targetDestination == null + || !hex64(targetNamespaceDigest) + || notBefore == null + || expiresAt == null + || !expiresAt.isAfter(notBefore) + || nonce == null + || !nonce.matches("[A-Za-z0-9_-]{16,128}") + || !hex64(approvalDigest)) { + throw new IllegalArgumentException("legacy adoption approval is invalid"); + } + } + + private static boolean hex64(String value) { + return value != null && value.matches("[0-9a-f]{64}"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionApprovalVerifierPort.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionApprovalVerifierPort.java new file mode 100644 index 0000000..51b7620 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionApprovalVerifierPort.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.storage.migration; + +/** Verifies a detached canonical approval before any adoption mutation. */ +@Deprecated(forRemoval = true) +public interface LegacyObjectAdoptionApprovalVerifierPort { + + LegacyObjectAdoptionApproval verify( + byte[] canonicalApprovalDocument, LegacyObjectAdoptionRequest expectedRequest); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionPort.java new file mode 100644 index 0000000..3db5ab3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.storage.migration; + +/** Deprecated administrative migration seam; unavailable to normal business composition. */ +@Deprecated(forRemoval = true) +public interface LegacyObjectAdoptionPort { + + LegacyObjectAdoptionReceipt adopt(LegacyObjectAdoptionRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionReceipt.java new file mode 100644 index 0000000..d04073d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionReceipt.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.storage.migration; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +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.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; +import java.time.Instant; + +/** Locator-free evidence from a report-only inspection or reviewed adoption apply. */ +@Deprecated(forRemoval = true) +public record LegacyObjectAdoptionReceipt( + ObjectOperationKey operationKey, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType, + ObjectVersionToken inspectedLegacyVersion, + ObjectReference adoptedReference, + ObjectVersionToken adoptedVersion, + ObjectMutationOutcome outcome, + Instant recordedAt) { + + public LegacyObjectAdoptionReceipt { + if (operationKey == null + || contentIdentity == null + || mediaType == null + || inspectedLegacyVersion == null + || outcome == null + || recordedAt == null + || (adoptedReference == null) != (adoptedVersion == null)) { + throw new IllegalArgumentException("legacy adoption receipt is invalid"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionRequest.java new file mode 100644 index 0000000..7842e70 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectAdoptionRequest.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.storage.migration; + +import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest; + +/** Exact report/apply request for one reviewed legacy locator. */ +@Deprecated(forRemoval = true) +public record LegacyObjectAdoptionRequest( + LegacyObjectLocator locator, + ObjectPublishRequest publicationRequest, + String manifestSha256, + String legacyNamespaceDigest, + String targetNamespaceDigest, + Mode mode, + LegacyObjectAdoptionApproval approval) { + + public LegacyObjectAdoptionRequest { + if (locator == null + || publicationRequest == null + || !hex64(manifestSha256) + || !hex64(legacyNamespaceDigest) + || !hex64(targetNamespaceDigest) + || mode == null + || (mode == Mode.REPORT_ONLY && approval != null)) { + throw new IllegalArgumentException("legacy object adoption request is invalid"); + } + } + + public enum Mode { + REPORT_ONLY, + APPLY + } + + private static boolean hex64(String value) { + return value != null && value.matches("[0-9a-f]{64}"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectLocator.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectLocator.java new file mode 100644 index 0000000..94da6b8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/migration/LegacyObjectLocator.java @@ -0,0 +1,54 @@ +package dev.caskeleton.application.storage.migration; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Deprecated administrative raw-locator exception used only by the isolated adoption workflow. + * + *

The value is deliberately redacted from {@link #toString()} and exception messages. + */ +@Deprecated(forRemoval = true) +public final class LegacyObjectLocator { + + private static final int MAXIMUM_UTF8_BYTES = 1024; + private final String value; + + private LegacyObjectLocator(String value) { + this.value = value; + } + + public static LegacyObjectLocator of(String value) { + if (value == null + || value.isBlank() + || value.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_UTF8_BYTES + || value.codePoints().anyMatch(LegacyObjectLocator::isControl)) { + throw new IllegalArgumentException("legacy object locator is invalid"); + } + return new LegacyObjectLocator(value); + } + + /** Available only to the named migration adapter/use case; never log or persist this value. */ + public String migrationValue() { + return value; + } + + @Override + public String toString() { + return "LegacyObjectLocator[redacted]"; + } + + @Override + public boolean equals(Object other) { + return other instanceof LegacyObjectLocator locator && value.equals(locator.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + private static boolean isControl(int codePoint) { + return Character.isISOControl(codePoint); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectContentContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectContentContractTest.java new file mode 100644 index 0000000..b97fa10 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectContentContractTest.java @@ -0,0 +1,105 @@ +package dev.caskeleton.application.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +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.ObjectContentProductionContext; +import dev.caskeleton.application.objectstorage.content.ObjectContentReadContext; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ObjectContentContractTest { + + private static final String ROUTE = "0123456789ab"; + private static final String OBJECT = "0123456789abcdefghjkmnpqrs"; + + @Test + void boundedSinkValidatesRangesChunkLimitAndCallbackLifetime() throws Exception { + ObjectContentProductionContext context = + ObjectContentProductionContext.open( + CallBudget.after(100, Duration.ofSeconds(1)), CancellationView.never(), 4); + AtomicInteger delivered = new AtomicInteger(); + ObjectChunkSink sink = + ObjectChunkSink.scoped(context, (bytes, offset, length) -> delivered.addAndGet(length)); + + sink.write(new byte[] {1, 2, 3, 4}, 1, 3); + + assertThat(delivered).hasValue(3); + assertThatThrownBy(() -> sink.write(new byte[5], 0, 5)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> sink.write(new byte[2], 1, 2)) + .isInstanceOf(IndexOutOfBoundsException.class); + context.invalidate(); + assertThatThrownBy(() -> sink.write(new byte[] {1}, 0, 1)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void boundedSourceUsesMinusOneForEofAndRejectsRepeatedZeroProgress() throws Exception { + ObjectContentReadContext context = readContext(); + ObjectChunkSource source = + ObjectChunkSource.scoped(context, (destination, offset, length) -> 0); + + byte[] destination = new byte[4]; + for (int i = 0; i < ObjectChunkSource.MAXIMUM_ZERO_PROGRESS_READS; i++) { + assertThat(source.read(destination, 0, 4)).isZero(); + } + assertThatThrownBy(() -> source.read(destination, 0, 4)) + .isInstanceOf(ObjectChunkReadException.class); + + ObjectChunkSource eof = ObjectChunkSource.scoped(readContext(), (bytes, offset, length) -> -1); + assertThat(eof.read(destination, 0, 4)).isEqualTo(ObjectChunkSource.EOF); + assertThat(eof.read(destination, 0, 0)).isZero(); + } + + @Test + void boundedSourceRejectsInvalidCountsAndCannotOutliveItsContext() throws Exception { + ObjectContentReadContext context = readContext(); + ObjectChunkSource tooLarge = ObjectChunkSource.scoped(context, (bytes, offset, length) -> 5); + + assertThatThrownBy(() -> tooLarge.read(new byte[4], 0, 4)) + .isInstanceOf(ObjectChunkReadException.class); + assertThatThrownBy(() -> tooLarge.read(new byte[4], -1, 1)) + .isInstanceOf(IndexOutOfBoundsException.class); + context.invalidate(); + assertThatThrownBy(() -> tooLarge.read(new byte[4], 0, 1)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void contextsCarryOnlyMonotonicBudgetCancellationAndBoundedReadFacts() { + AtomicInteger checks = new AtomicInteger(); + CancellationView cancellation = () -> checks.incrementAndGet() > 1; + CallBudget budget = CallBudget.after(100, Duration.ofSeconds(1)); + ObjectContentProductionContext production = + ObjectContentProductionContext.open(budget, cancellation, 8192); + ObjectContentReadContext read = readContext(); + + assertThat(production.budget()).isEqualTo(budget); + assertThat(production.maximumChunkBytes()).isEqualTo(8192); + assertThat(production.cancellation().isCancelled()).isFalse(); + assertThat(production.cancellation().isCancelled()).isTrue(); + assertThat(read.deliveredRange()).isEqualTo(ObjectReadRange.of(4, 8)); + assertThat(read.maximumChunkBytes()).isEqualTo(4); + assertThat(read.exactVersion().canonicalText()).startsWith("osv1."); + } + + private static ObjectContentReadContext readContext() { + return ObjectContentReadContext.open( + CallBudget.after(100, Duration.ofSeconds(1)), + CancellationView.never(), + 4, + ObjectContentIdentity.sha256(16, new byte[32]), + ObjectVersionToken.parse("osv1." + ROUTE + "." + OBJECT + ".6678c6821f"), + ObjectReadRange.of(4, 8)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java new file mode 100644 index 0000000..42772a0 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.application.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.model.DirectDownloadGrant; +import dev.caskeleton.application.objectstorage.model.DirectUploadGrant; +import dev.caskeleton.application.objectstorage.model.PartUploadGrant; +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.storage.StoredObject; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ObjectStorageArchitectureContractTest { + + private static final String CONTRACT_PACKAGE = "dev.caskeleton.application.objectstorage"; + private static final Set> URI_GRANTS = + Set.of(DirectUploadGrant.class, DirectDownloadGrant.class, PartUploadGrant.class); + private static final List FORBIDDEN_TYPE_NAMES = + List.of( + "org.springframework.", + "software.amazon.awssdk.", + "jakarta.servlet.", + "jakarta.persistence.", + "javax.persistence.", + "org.hibernate.", + "org.slf4j.", + "dev.caskeleton.adapter.", + "dev.caskeleton.bootstrap.", + "java.nio.file.Path", + "java.io.File"); + + @Test + void semanticContractIsFrameworkProviderTransportAndPersistentLocatorFree() throws Exception { + for (Class contract : topLevelClassesUnder(CONTRACT_PACKAGE)) { + assertElementTypesArePure(contract, contract); + for (Field field : contract.getDeclaredFields()) { + assertElementTypesArePure(contract, field); + assertTypeIsPure(contract, field.getGenericType()); + } + for (Constructor constructor : contract.getDeclaredConstructors()) { + assertElementTypesArePure(contract, constructor); + for (Type parameter : constructor.getGenericParameterTypes()) { + assertTypeIsPure(contract, parameter); + } + } + for (Method method : contract.getDeclaredMethods()) { + assertElementTypesArePure(contract, method); + assertTypeIsPure(contract, method.getGenericReturnType()); + for (Type parameter : method.getGenericParameterTypes()) { + assertTypeIsPure(contract, parameter); + } + for (Type exception : method.getGenericExceptionTypes()) { + assertTypeIsPure(contract, exception); + } + } + for (RecordComponent component : recordComponents(contract)) { + assertElementTypesArePure(contract, component); + assertTypeIsPure(contract, component.getGenericType()); + } + } + } + + @Test + void uriAppearsOnlyOnExplicitTransientGrantValues() throws Exception { + for (Class contract : topLevelClassesUnder(CONTRACT_PACKAGE)) { + boolean exposesUri = + ArraysSupport.allTypes(contract).stream() + .map(Type::getTypeName) + .anyMatch(name -> name.equals(URI.class.getName())); + if (exposesUri) { + assertThat(URI_GRANTS).as(contract.getName()).contains(contract); + } + } + } + + @Test + void legacyBlobPortAndReceiptAreExplicitRemovalBoundaries() { + Deprecated port = ObjectStoragePort.class.getAnnotation(Deprecated.class); + Deprecated receipt = StoredObject.class.getAnnotation(Deprecated.class); + + assertThat(port).isNotNull(); + assertThat(port.forRemoval()).isTrue(); + assertThat(receipt).isNotNull(); + assertThat(receipt.forRemoval()).isTrue(); + } + + private static void assertElementTypesArePure(Class owner, AnnotatedElement element) { + for (Annotation annotation : element.getAnnotations()) { + assertTypeNameIsPure(owner, annotation.annotationType().getName()); + } + } + + private static void assertTypeIsPure(Class owner, Type type) { + String typeName = type.getTypeName(); + assertTypeNameIsPure(owner, typeName); + if (typeName.contains(URI.class.getName())) { + assertThat(URI_GRANTS).as(owner.getName()).contains(owner); + } + } + + private static void assertTypeNameIsPure(Class owner, String typeName) { + assertThat(FORBIDDEN_TYPE_NAMES) + .as(owner.getName() + " -> " + typeName) + .noneMatch(typeName::contains); + } + + private static RecordComponent[] recordComponents(Class type) { + RecordComponent[] components = type.getRecordComponents(); + return components == null ? new RecordComponent[0] : components; + } + + private static List> topLevelClassesUnder(String packageName) + throws IOException, URISyntaxException, ClassNotFoundException { + String packagePath = packageName.replace('.', '/'); + Path classesRoot = + Path.of( + ObjectDestinationId.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Path root = classesRoot.resolve(packagePath); + List> classes = new ArrayList<>(); + try (var files = Files.walk(root)) { + for (Path classFile : + files + .filter(path -> path.toString().endsWith(".class")) + .filter(path -> !path.getFileName().toString().contains("$")) + .toList()) { + String relative = + root.relativize(classFile).toString().replace(java.io.File.separator, "."); + String className = + packageName + "." + relative.substring(0, relative.length() - ".class".length()); + classes.add(Class.forName(className)); + } + } + assertThat(classes).isNotEmpty(); + return classes; + } + + private static final class ArraysSupport { + + private ArraysSupport() {} + + static List allTypes(Class type) { + List types = new ArrayList<>(); + for (Field field : type.getDeclaredFields()) { + types.add(field.getGenericType()); + } + for (Constructor constructor : type.getDeclaredConstructors()) { + types.addAll(List.of(constructor.getGenericParameterTypes())); + } + for (Method method : type.getDeclaredMethods()) { + types.add(method.getGenericReturnType()); + types.addAll(List.of(method.getGenericParameterTypes())); + } + for (RecordComponent component : recordComponents(type)) { + types.add(component.getGenericType()); + } + return types; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java new file mode 100644 index 0000000..bbaf301 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java @@ -0,0 +1,124 @@ +package dev.caskeleton.application.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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 net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.constraints.CharRange; +import org.junit.jupiter.api.Test; + +class ObjectStorageIdentityContractTest { + + private static final String ROUTE = "0123456789ab"; + private static final String OBJECT = "0123456789abcdefghjkmnpqrs"; + + @Test + void simpleIdentitiesAreCanonicalBoundedAndComposeAnExactOperationKey() { + ObjectDestinationId destination = ObjectDestinationId.of("poster-image"); + ObjectOperationEpoch epoch = ObjectOperationEpoch.of("epoch-2026-07"); + ObjectOperationId operationId = ObjectOperationId.of("operation-01"); + + ObjectOperationKey key = new ObjectOperationKey(destination, epoch, operationId); + + assertThat(key.destination()).isEqualTo(destination); + assertThat(key.epoch()).isEqualTo(epoch); + assertThat(key.operationId()).isEqualTo(operationId); + assertThat(ObjectId.parse(OBJECT).canonicalText()).isEqualTo(OBJECT); + } + + @Test + void routedOpaqueValuesMatchFrozenGoldenVectors() { + ObjectReference reference = + ObjectReference.parse("osr1." + ROUTE + "." + OBJECT + ".d53d9429ac"); + ObjectStageHandle stage = + ObjectStageHandle.parse("osh1." + ROUTE + "." + OBJECT + ".f3226a9833"); + DirectTransferSessionId upload = + DirectTransferSessionId.parse("osu1." + ROUTE + "." + OBJECT + ".55459818cc"); + DirectTransferSessionId multipart = + DirectTransferSessionId.parse("osm1." + ROUTE + "." + OBJECT + ".13a5a3b163"); + ObjectVersionToken version = + ObjectVersionToken.parse("osv1." + ROUTE + "." + OBJECT + ".6678c6821f"); + PartReceiptToken part = PartReceiptToken.parse("osp1." + OBJECT + ".8f6f53696e"); + + assertThat(reference.canonicalText()).startsWith("osr1."); + assertThat(stage.canonicalText()).startsWith("osh1."); + assertThat(upload.canonicalText()).startsWith("osu1."); + assertThat(multipart.canonicalText()).startsWith("osm1."); + assertThat(version.canonicalText()).startsWith("osv1."); + assertThat(part.canonicalText()).startsWith("osp1."); + assertThat(reference.redactedLogToken()).doesNotContain(ROUTE).doesNotContain(OBJECT); + assertThat(reference.toString()).isEqualTo(reference.redactedLogToken()); + } + + @Test + void prefixesCannotBeSubstitutedAcrossPublishedStageAndSessionTypes() { + String reference = "osr1." + ROUTE + "." + OBJECT + ".d53d9429ac"; + String stage = "osh1." + ROUTE + "." + OBJECT + ".f3226a9833"; + + assertThatThrownBy(() -> ObjectStageHandle.parse(reference)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectReference.parse(stage)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> DirectTransferSessionId.parse(reference)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void tamperedCheckDigitRouteAndProviderLocatorAreRejected() { + assertThatThrownBy(() -> ObjectReference.parse("osr1." + ROUTE + "." + OBJECT + ".d53d9429ad")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageNotContaining(OBJECT); + assertThatThrownBy(() -> ObjectReference.parse("osr1.0123456789ai." + OBJECT + ".d53d9429ac")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectOperationId.of("s3://bucket/raw-key")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageNotContaining("bucket"); + assertThatThrownBy(() -> ObjectDestinationId.of("../filesystem")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageNotContaining("filesystem"); + } + + @Test + void multipartPartNumberIsOneBasedAndBounded() { + assertThat(MultipartPartNumber.of(1).value()).isEqualTo(1); + assertThat(MultipartPartNumber.of(10_000).value()).isEqualTo(10_000); + assertThatThrownBy(() -> MultipartPartNumber.of(0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MultipartPartNumber.of(10_001)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nullBlankAndOversizeValuesAreRejected() { + assertThatThrownBy(() -> ObjectDestinationId.of(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectOperationEpoch.of(" ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectOperationId.of("a".repeat(65))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectId.parse(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Property + void controlCharactersNeverEnterSimpleIdentities( + @ForAll @CharRange(from = 0, to = 31) char control) { + assertThatThrownBy(() -> ObjectDestinationId.of("poster" + control + "image")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectOperationEpoch.of("epoch" + control)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectOperationId.of("operation" + control)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java new file mode 100644 index 0000000..3e7bbaf --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java @@ -0,0 +1,254 @@ +package dev.caskeleton.application.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId; +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.model.DirectDownloadGrant; +import dev.caskeleton.application.objectstorage.model.DirectUploadGrant; +import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectHandoffReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectStageReceipt; +import dev.caskeleton.application.objectstorage.model.PartUploadGrant; +import dev.caskeleton.application.objectstorage.port.DirectObjectUploadPort; +import dev.caskeleton.application.objectstorage.port.ObjectInspectionPort; +import dev.caskeleton.application.objectstorage.port.ObjectPurgeMaintenancePort; +import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort; +import dev.caskeleton.application.objectstorage.port.ObjectScanMaintenancePort; +import dev.caskeleton.application.objectstorage.port.ObjectTransferPort; +import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; +import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest; +import dev.caskeleton.application.objectstorage.request.DirectUploadCompletionRequest; +import dev.caskeleton.application.objectstorage.request.DirectUploadGrantRequest; +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.ObjectAbortAuthorization; +import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest; +import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffReleaseRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffRenewRequest; +import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest; +import dev.caskeleton.application.objectstorage.request.ObjectPurgeRequest; +import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest; +import dev.caskeleton.application.objectstorage.request.ObjectScanVerdictRequest; +import dev.caskeleton.application.objectstorage.request.ObjectStageRequest; +import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest; +import dev.caskeleton.application.objectstorage.request.PartUploadGrantRequest; +import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.net.URI; +import java.time.Instant; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ObjectStoragePortContractTest { + + private static final List> MUTATION_REQUESTS = + List.of( + ObjectPublishRequest.class, + ObjectRetireRequest.class, + ObjectPurgeRequest.class, + ObjectStageRequest.class, + ObjectVerifyRequest.class, + ObjectScanVerdictRequest.class, + ObjectFinalizeRequest.class, + ObjectAbortRequest.class, + ObjectHandoffClaimRequest.class, + ObjectHandoffRenewRequest.class, + ObjectHandoffReleaseRequest.class, + ObjectAbortAuthorization.class, + DirectDownloadGrantRequest.class, + DirectUploadGrantRequest.class, + DirectUploadCompletionRequest.class, + MultipartStartRequest.class, + PartUploadGrantRequest.class, + MultipartPartAcknowledgement.class, + MultipartCompleteRequest.class, + MultipartAbortRequest.class); + + private static final List> PUBLIC_RECEIPTS = + List.of( + ObjectPublishReceipt.class, + ObjectMutationReceipt.class, + ObjectStageReceipt.class, + ObjectHandoffReceipt.class); + + @Test + void everyMutationRequestCarriesTheExactOperationKey() { + for (Class request : MUTATION_REQUESTS) { + assertThat(componentTypes(request)) + .as(request.getSimpleName()) + .contains(ObjectOperationKey.class); + } + } + + @Test + void inspectAndTransferAcceptPublishedReferencesNotStageHandles() throws Exception { + Method inspect = ObjectInspectionPort.class.getMethod("inspect", ObjectReference.class); + Method transfer = + Arrays.stream(ObjectTransferPort.class.getMethods()) + .filter(method -> method.getName().equals("transfer")) + .findFirst() + .orElseThrow(); + + assertThat(inspect.getParameterTypes()).containsExactly(ObjectReference.class); + assertThat(Arrays.asList(transfer.getParameterTypes())).doesNotContain(ObjectStageHandle.class); + } + + @Test + void purgeScannerAndBusinessRetirementAreSeparatePrivilegeSurfaces() { + assertThat(ObjectPurgeMaintenancePort.class.isAssignableFrom(ObjectRetirementPort.class)) + .isFalse(); + assertThat(ObjectScanMaintenancePort.class.isAssignableFrom(StagedObjectPublicationPort.class)) + .isFalse(); + assertThat( + Arrays.stream(StagedObjectPublicationPort.class.getMethods()) + .map(Method::getName) + .toList()) + .doesNotContain("transferForScan", "recordScanVerdict"); + assertThat( + Arrays.stream(ObjectScanMaintenancePort.class.getMethods()) + .map(Method::getName) + .toList()) + .containsExactlyInAnyOrder("transferForScan", "recordScanVerdict"); + } + + @Test + void directCompletionVerifiesExpectedContentAndNeverAcceptsAClientSuccessFlag() { + assertThat(componentTypes(DirectUploadCompletionRequest.class)) + .contains(dev.caskeleton.application.objectstorage.model.ObjectContentIdentity.class) + .doesNotContain(boolean.class, Boolean.class); + assertThat( + Arrays.stream(DirectObjectUploadPort.class.getMethods()).map(Method::getName).toList()) + .containsExactlyInAnyOrder("createUploadGrant", "completeUpload"); + } + + @Test + void multipartCompletionAcceptsOnlyServerIssuedPartTokens() { + RecordComponent tokens = + Arrays.stream(MultipartCompleteRequest.class.getRecordComponents()) + .filter(component -> component.getName().equals("partTokens")) + .findFirst() + .orElseThrow(); + + assertThat(tokens.getGenericType().getTypeName()) + .isEqualTo( + "java.util.List"); + } + + @Test + void onlyFinalizationProducesAPublishedReferenceInTheStagedFamily() { + assertThat(componentTypes(ObjectStageReceipt.class)).doesNotContain(ObjectReference.class); + assertThat(componentTypes(ObjectPublishReceipt.class)).contains(ObjectReference.class); + assertThat( + Arrays.stream(StagedObjectPublicationPort.class.getMethods()) + .filter(method -> method.getName().equals("finalizePublication")) + .map(Method::getReturnType) + .toList()) + .containsExactly(ObjectPublishReceipt.class); + } + + @Test + void scanVerdictBindsStageVersionScanOperationAndScannerPolicyRevision() { + assertThat(componentNames(ObjectScanVerdictRequest.class)) + .contains( + "operationKey", + "stageHandle", + "exactVersion", + "scanOperationId", + "scannerPolicyRevision", + "verdict"); + } + + @Test + void receiptsExposeNoPersistentProviderLocatorOrCredentialFields() { + for (Class receipt : PUBLIC_RECEIPTS) { + assertThat(componentNames(receipt)) + .as(receipt.getSimpleName()) + .noneMatch(ObjectStoragePortContractTest::looksLikeProviderLocator); + assertThat(componentTypes(receipt)).doesNotContain(URI.class); + } + } + + @Test + void requirementsCanOnlyStrengthenTheCompiledMinimum() { + assertThat( + ObjectPublicationRequirement.INTEGRITY_VERIFIED.requireAtLeast( + ObjectPublicationRequirement.SCAN_CLEAN)) + .isEqualTo(ObjectPublicationRequirement.SCAN_CLEAN); + assertThat( + ObjectRetentionRequirement.NONE.requireAtLeast(ObjectRetentionRequirement.LEGAL_HOLD)) + .isEqualTo(ObjectRetentionRequirement.LEGAL_HOLD); + assertThat( + ObjectEncryptionRequirement.PROVIDER_MANAGED.requireAtLeast( + ObjectEncryptionRequirement.CUSTOMER_MANAGED)) + .isEqualTo(ObjectEncryptionRequirement.CUSTOMER_MANAGED); + assertThat(EnumSet.allOf(ObjectCapabilityRequirement.class)).isNotEmpty(); + } + + @Test + void transientGrantToStringRedactsUriAndSignedHeaders() { + DirectTransferSessionId session = + DirectTransferSessionId.parse("osu1.0123456789ab.0123456789abcdefghjkmnpqrs.55459818cc"); + DirectUploadGrant upload = + new DirectUploadGrant( + session, + URI.create("https://storage.example/private?X-Amz-Signature=secret"), + Map.of("x-amz-checksum-sha256", "secret-checksum"), + Instant.parse("2026-07-28T12:00:00Z")); + DirectDownloadGrant download = + new DirectDownloadGrant( + session, + URI.create("https://storage.example/private?X-Amz-Signature=secret"), + Map.of("response-content-disposition", "private-name"), + Instant.parse("2026-07-28T12:00:00Z")); + PartUploadGrant part = + new PartUploadGrant( + session, + dev.caskeleton.application.objectstorage.identity.MultipartPartNumber.of(1), + URI.create("https://storage.example/private?X-Amz-Signature=secret"), + Map.of("x-amz-checksum-sha256", "secret-checksum"), + Instant.parse("2026-07-28T12:00:00Z")); + + assertThat(upload.toString()).doesNotContain("storage.example", "secret"); + assertThat(download.toString()).doesNotContain("storage.example", "private-name"); + assertThat(part.toString()).doesNotContain("storage.example", "secret-checksum"); + } + + private static List> componentTypes(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getType).toList(); + } + + private static List componentNames(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getName).toList(); + } + + private static boolean looksLikeProviderLocator(String name) { + String lower = name.toLowerCase(java.util.Locale.ROOT); + return lower.contains("bucket") + || lower.equals("key") + || lower.contains("rawkey") + || lower.contains("providerkey") + || lower.contains("objectkey") + || lower.contains("path") + || lower.contains("endpoint") + || lower.contains("etag") + || lower.contains("uploadid") + || lower.contains("credential") + || lower.contains("url") + || lower.contains("uri"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java new file mode 100644 index 0000000..10f6dbb --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java @@ -0,0 +1,68 @@ +package dev.caskeleton.application.objectstorage; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.ObjectDigestVerification; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectReadRange; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class ObjectStorageValueContractTest { + + @Test + void sha256DigestIsCanonicalFullContentIdentityNotAnEtag() { + ObjectDigest digest = ObjectDigest.sha256("abc".getBytes(UTF_8)); + ObjectContentIdentity identity = new ObjectContentIdentity(3, digest); + + assertThat(digest.algorithm()).isEqualTo(ObjectDigestAlgorithm.SHA_256); + assertThat(digest.base64Value()).isEqualTo("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="); + assertThat(identity.exactLength()).isEqualTo(3); + assertThat(identity.fullDigest()).isEqualTo(digest); + assertThat(ObjectDigestVerification.values()) + .containsExactly(ObjectDigestVerification.NONE, ObjectDigestVerification.FULL_CONTENT); + } + + @Test + void sha256RequiresExactlyThirtyTwoDigestBytesAndExactNonNegativeLength() { + String shortDigest = Base64.getEncoder().encodeToString(new byte[31]); + + assertThatThrownBy(() -> ObjectDigest.of(ObjectDigestAlgorithm.SHA_256, shortDigest)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectContentIdentity.sha256(-1, new byte[32])) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ObjectContentIdentity(1, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rangeArithmeticRejectsZeroNegativeOverflowAndDeliveryOverBudget() { + ObjectReadRange range = ObjectReadRange.of(7, 5); + + assertThat(range.endExclusive()).isEqualTo(12); + assertThat(range.requireMaximumDeliveredBytes(5)).isSameAs(range); + assertThatThrownBy(() -> ObjectReadRange.of(-1, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectReadRange.of(0, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectReadRange.of(Long.MAX_VALUE, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> range.requireMaximumDeliveredBytes(4)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void mediaTypeIsLowercaseBoundedAndControlFree() { + assertThat(ObjectMediaType.of("Image/PNG").canonicalText()).isEqualTo("image/png"); + assertThatThrownBy(() -> ObjectMediaType.of("image/png\r\nx-leak: yes")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectMediaType.of("a".repeat(120) + "/b".repeat(20))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ObjectMediaType.of("not-a-media-type")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/gradlew.bat b/src/gradlew.bat index e509b2d..c4bdd3a 100644 --- a/src/gradlew.bat +++ b/src/gradlew.bat @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle index f078688..de37785 100644 --- a/src/sample-portfolio/build.gradle +++ b/src/sample-portfolio/build.gradle @@ -2,6 +2,20 @@ // Lean standalone boot: apply the Spring Boot plugin so bootJar / bootRun are available. apply plugin: 'org.springframework.boot' +sourceSets { + posterImageMigrationTest { + java.srcDir 'src/posterImageMigrationTest/java' + resources.srcDir 'src/posterImageMigrationTest/resources' + compileClasspath += sourceSets.main.output + sourceSets.test.output + runtimeClasspath += output + compileClasspath + } +} + +configurations { + posterImageMigrationTestImplementation.extendsFrom testImplementation + posterImageMigrationTestRuntimeOnly.extendsFrom testRuntimeOnly +} + // The objectstorage adapter (runtimeOnly) pulls in software.amazon.awssdk:s3, whose version is // managed by the AWS SDK v2 BOM (NOT the Spring Boot BOM). Import that BOM at this consumer's scope // — mirroring the objectstorage module — so the transitive s3 dependency resolves on this module's @@ -84,6 +98,15 @@ tasks.register('openapiCheckSnapshot', Test) { jvmArgs '-Duser.timezone=UTC' } +tasks.register('posterImageMigrationTest', Test) { + group = 'verification' + description = 'Runs the non-skipping PostgreSQL V7 Poster image migration/rotation lane.' + testClassesDirs = sourceSets.posterImageMigrationTest.output.classesDirs + classpath = sourceSets.posterImageMigrationTest.runtimeClasspath + useJUnitPlatform() + shouldRunAfter tasks.named('test') +} + bootJar { mainClass = 'dev.caskeleton.sample.portfolio.SamplePortfolioApplication' } diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile index 61fe4e8..ce76bc1 100644 --- a/src/sample-portfolio/gradle.lockfile +++ b/src/sample-portfolio/gradle.lockfile @@ -1,335 +1,335 @@ # 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. -aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,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.f4b6a3:uuid-creator:6.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +aopalliance:aopalliance:1.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,posterImageMigrationTestCompileClasspath,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,compileClasspath,spotbugs,testCompileClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,posterImageMigrationTestCompileClasspath,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=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs 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,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,posterImageMigrationTestAnnotationProcessor,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,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath -com.zaxxer:HikariCP:7.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp-jvm:5.2.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:5.2.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.16.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.16.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.19.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,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=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -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:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-compression:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http2:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-marshalling:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-protobuf:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-classes-epoll:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +io.github.cdimascio:dotenv-java:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +io.micrometer:context-propagation:1.2.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-common:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.4.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik-api:1.9.1=testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik-engine:1.9.1=testRuntimeClasspath -net.jqwik:jqwik-time:1.9.1=testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik-web:1.9.1=testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik:1.9.1=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-api:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-engine:1.9.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +net.jqwik:jqwik-time:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-web:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath 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,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpcore:4.4.16=checkstyle,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpcore:4.4.16=checkstyle,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath -org.aspectj:aspectjweaver:1.9.25=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.aspectj:aspectjweaver:1.9.25=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.6=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.angus:angus-activation:2.0.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath -org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,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=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,posterImageMigrationTestAnnotationProcessor,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -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.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,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.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +org.postgresql:postgresql:42.7.8=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,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-security-oauth2-resource-server:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,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-testcontainers:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-orm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-testcontainers:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -software.amazon.awssdk:annotations:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:apache-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:arns:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:aws-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:aws-query-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:aws-xml-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:checksums-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:checksums:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:crt-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:endpoints-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth-aws:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-client-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:identity-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:json-utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:metrics-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:netty-nio-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:profiles:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:protocol-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:regions:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:retries-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:retries:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:s3:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:sdk-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:third-party-jackson-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.eventstream:eventstream:1.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:annotations:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:apache-client:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:arns:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:auth:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-query-protocol:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-xml-protocol:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:checksums-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:checksums:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:crt-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:endpoints-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-aws:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-client-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:identity-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:json-utils:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:metrics-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:netty-nio-client:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:profiles:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:protocol-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:regions:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:retries-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:retries:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:s3:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:sdk-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:third-party-jackson-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:utils:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.eventstream:eventstream:1.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java new file mode 100644 index 0000000..4383bdc --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java @@ -0,0 +1,50 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterWebMapper; +import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.shared.error.MappingException; +import java.io.IOException; +import java.util.Locale; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** Explicit compatibility-profile whole-byte endpoint; absent from canonical publication mode. */ +@RestController +@ConditionalOnProperty(prefix = "app.poster-image.api", name = "mode", havingValue = "legacy") +public final class LegacyPosterImageController { + + private final UploadPosterImageUseCase upload; + + public LegacyPosterImageController(UploadPosterImageUseCase upload) { + this.upload = upload; + } + + @PostMapping(value = "/posters/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public StoredObjectResponse uploadImage( + @PathVariable String id, @RequestParam("file") MultipartFile file) { + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException exception) { + throw new MappingException("failed to read uploaded file", exception); + } + String contentType = + file.getContentType() == null ? "application/octet-stream" : file.getContentType(); + StoredObject stored = upload.handle(new UploadPosterImageCommand(toId(id), bytes, contentType)); + return PosterWebMapper.toStoredObjectResponse(stored); + } + + private static PosterId toId(String raw) { + return PosterId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java index 2574c61..8cdf94f 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java @@ -4,16 +4,13 @@ import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; import dev.caskeleton.adapter.inbound.web.pagination.PageParams; import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; import dev.caskeleton.adapter.inbound.web.pagination.SortParam; -import dev.caskeleton.application.storage.StoredObject; import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreatePosterRequest; import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdatePosterRequest; import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterWebMapper; import dev.caskeleton.sample.portfolio.application.command.ArchivePosterCommand; import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; import dev.caskeleton.sample.portfolio.application.command.PublishPosterCommand; -import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; import dev.caskeleton.sample.portfolio.application.poster.ArchivePosterUseCase; import dev.caskeleton.sample.portfolio.application.poster.CreatePosterUseCase; import dev.caskeleton.sample.portfolio.application.poster.DeletePosterUseCase; @@ -21,24 +18,20 @@ import dev.caskeleton.sample.portfolio.application.poster.GetPosterUseCase; import dev.caskeleton.sample.portfolio.application.poster.ListPostersUseCase; import dev.caskeleton.sample.portfolio.application.poster.PublishPosterUseCase; import dev.caskeleton.sample.portfolio.application.poster.UpdatePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; import dev.caskeleton.sample.portfolio.application.query.GetPosterQuery; import dev.caskeleton.sample.portfolio.application.query.ListPostersQuery; import dev.caskeleton.sample.portfolio.domain.poster.PosterId; import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import dev.caskeleton.shared.error.MappingException; import dev.caskeleton.shared.response.Envelope; import dev.caskeleton.shared.response.PageMeta; import dev.caskeleton.shared.response.ResponseMeta; import jakarta.validation.Valid; -import java.io.IOException; import java.util.List; import java.util.Locale; import java.util.UUID; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -48,7 +41,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; /** * Promotional-poster endpoints. Resource naming follows AIP-122 ({@code /posters}); lifecycle @@ -64,7 +56,6 @@ public class PosterController { private final CreatePosterUseCase create; private final UpdatePosterUseCase update; - private final UploadPosterImageUseCase uploadImage; private final PublishPosterUseCase publish; private final ArchivePosterUseCase archive; private final DeletePosterUseCase delete; @@ -74,7 +65,6 @@ public class PosterController { public PosterController( CreatePosterUseCase create, UpdatePosterUseCase update, - UploadPosterImageUseCase uploadImage, PublishPosterUseCase publish, ArchivePosterUseCase archive, DeletePosterUseCase delete, @@ -82,7 +72,6 @@ public class PosterController { ListPostersUseCase list) { this.create = create; this.update = update; - this.uploadImage = uploadImage; this.publish = publish; this.archive = archive; this.delete = delete; @@ -144,26 +133,6 @@ public class PosterController { update.handle(PosterWebMapper.toUpdateCommand(toId(id), body))); } - /** - * Uploads the poster image (multipart) to object storage and records its key on the aggregate. An - * uploaded image is the precondition for {@link #publish}. - */ - @PostMapping(value = "/posters/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public StoredObjectResponse uploadImage( - @PathVariable String id, @RequestParam("file") MultipartFile file) { - byte[] bytes; - try { - bytes = file.getBytes(); - } catch (IOException e) { - throw new MappingException("failed to read uploaded file", e); - } - String contentType = - file.getContentType() == null ? "application/octet-stream" : file.getContentType(); - StoredObject stored = - uploadImage.handle(new UploadPosterImageCommand(toId(id), bytes, contentType)); - return PosterWebMapper.toStoredObjectResponse(stored); - } - /** * {@code DRAFT → PUBLISHED}. Fails with 400 {@code POSTER_IMAGE_REQUIRED} if no image is * attached. diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java new file mode 100644 index 0000000..ee0ea3e --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java @@ -0,0 +1,113 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +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.ObjectMediaType; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationStatusResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterImagePublicationWebMapper; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.objectstorage.MultipartObjectContentProducer; +import dev.caskeleton.sample.portfolio.application.command.PublishPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.poster.GetPosterImagePublicationStatusUseCase; +import dev.caskeleton.sample.portfolio.application.poster.PublishPosterImageUseCase; +import dev.caskeleton.sample.portfolio.application.query.GetPosterImagePublicationStatusQuery; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.time.Duration; +import java.util.Locale; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** Additive streaming publication/status API; no raw locator or signing surface. */ +@RestController +@ConditionalOnProperty(prefix = "app.poster-image.api", name = "mode", havingValue = "publication") +public final class PosterImagePublicationController { + + private static final Duration CALL_TIMEOUT = Duration.ofMinutes(2); + + private final PublishPosterImageUseCase publish; + private final GetPosterImagePublicationStatusUseCase status; + + public PosterImagePublicationController( + PublishPosterImageUseCase publish, GetPosterImagePublicationStatusUseCase status) { + this.publish = publish; + this.status = status; + } + + @PostMapping( + value = "/posters/{id}/imagePublications", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity publish( + @PathVariable String id, + @RequestHeader(ApiHeaders.IDEMPOTENCY_KEY) String idempotencyKey, + @RequestParam("expectedVersion") long expectedVersion, + @RequestParam("sha256") String sha256, + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal AuthenticatedPrincipal principal) { + if (idempotencyKey == null + || idempotencyKey.isBlank() + || idempotencyKey.length() > 256 + || principal == null + || principal.idpUserId() == null + || principal.idpUserId().isBlank()) { + throw new IllegalArgumentException( + "authenticated principal and Idempotency-Key are required"); + } + ObjectContentIdentity identity = + new ObjectContentIdentity( + file.getSize(), ObjectDigest.of(ObjectDigestAlgorithm.SHA_256, sha256)); + String contentType = + file.getContentType() == null ? "application/octet-stream" : file.getContentType(); + var reservation = + publish.handle( + new PublishPosterImageCommand( + toId(id), + expectedVersion, + null, + principal.idpUserId(), + idempotencyKey.trim(), + identity, + ObjectMediaType.of(contentType), + new MultipartObjectContentProducer(file, identity), + CallBudget.fromNow(CALL_TIMEOUT), + CancellationView.never())); + PosterImagePublicationResponse response = + PosterImagePublicationWebMapper.toResponse(reservation); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .header(ApiHeaders.LOCATION, response.statusPath()) + .body(response); + } + + @GetMapping("/posters/{id}/imagePublications/{operationId}") + public PosterImagePublicationStatusResponse status( + @PathVariable String id, + @PathVariable String operationId, + @AuthenticationPrincipal AuthenticatedPrincipal principal) { + if (principal == null) { + throw new IllegalArgumentException("authenticated principal is required"); + } + return PosterImagePublicationWebMapper.toResponse( + status.handle( + new GetPosterImagePublicationStatusQuery(toId(id), ObjectOperationId.of(operationId)))); + } + + private static PosterId toId(String raw) { + return PosterId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java new file mode 100644 index 0000000..7f0a034 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java @@ -0,0 +1,4 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +/** Stable accepted response containing only an opaque operation and relative status path. */ +public record PosterImagePublicationResponse(String operationId, String statusPath) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java new file mode 100644 index 0000000..628d828 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java @@ -0,0 +1,10 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +/** Locator-free publication progress/result. Reference appears only for READY. */ +public record PosterImagePublicationStatusResponse( + String operationId, + String state, + String reference, + long exactLength, + String mediaType, + String sha256) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java new file mode 100644 index 0000000..5b6b501 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java @@ -0,0 +1,33 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper; + +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationStatusResponse; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservation; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; + +public final class PosterImagePublicationWebMapper { + + private PosterImagePublicationWebMapper() {} + + public static PosterImagePublicationResponse toResponse( + PosterImagePublicationReservation reservation) { + return new PosterImagePublicationResponse( + reservation.operationId().value(), + statusPath(reservation.posterId().value(), reservation.operationId().value())); + } + + public static PosterImagePublicationStatusResponse toResponse( + PosterImagePublicationStatus status) { + return new PosterImagePublicationStatusResponse( + status.operationId().value(), + status.state().name(), + status.reference() == null ? null : status.reference().value(), + status.contentIdentity().exactLength(), + status.mediaType().canonicalText(), + status.contentIdentity().fullDigest().base64Value()); + } + + public static String statusPath(String posterId, String operationId) { + return "/posters/" + posterId + "/imagePublications/" + operationId; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java new file mode 100644 index 0000000..85936e1 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java @@ -0,0 +1,65 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.objectstorage; + +import dev.caskeleton.application.objectstorage.content.ObjectChunkSink; +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.model.ObjectContentIdentity; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.springframework.web.multipart.MultipartFile; + +/** Single-use request-bound stream bridge; it never calls {@code MultipartFile.getBytes()}. */ +public final class MultipartObjectContentProducer implements ObjectContentProducer { + + private final MultipartFile file; + private final ObjectContentIdentity expected; + private final AtomicBoolean consumed = new AtomicBoolean(); + + public MultipartObjectContentProducer(MultipartFile file, ObjectContentIdentity expected) { + if (file == null || expected == null) { + throw new IllegalArgumentException("multipart producer inputs are required"); + } + this.file = file; + this.expected = expected; + } + + @Override + public void produce(ObjectContentProductionContext context, ObjectChunkSink sink) + throws ObjectContentProductionException { + if (!consumed.compareAndSet(false, true)) { + throw new ObjectContentProductionException("multipart request content is already consumed"); + } + try (InputStream input = file.getInputStream()) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[Math.min(context.maximumChunkBytes(), 64 * 1024)]; + long delivered = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read == 0) { + continue; + } + delivered += read; + if (delivered > expected.exactLength()) { + throw new ObjectContentProductionException( + "multipart content exceeds its declared length"); + } + digest.update(buffer, 0, read); + sink.write(buffer, 0, read); + } + if (delivered != expected.exactLength() + || !MessageDigest.isEqual(digest.digest(), expected.fullDigest().decodedValue())) { + throw new ObjectContentProductionException( + "multipart content identity does not match its declaration"); + } + } catch (IOException exception) { + throw new ObjectContentProductionException( + "multipart request stream could not be read", exception); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java new file mode 100644 index 0000000..8d4c35c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java @@ -0,0 +1,128 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigesterPort; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** HMAC-SHA-256 length-prefixed scope digester with a versioned retained key ring. */ +public final class HmacPosterImageIdempotencyScopeDigester + implements PosterImageIdempotencyScopeDigesterPort { + + private static final byte[] DOMAIN = + "poster-image-idempotency-scope-v1".getBytes(StandardCharsets.UTF_8); + + private final Supplier activeVersion; + private final Map keys; + + public HmacPosterImageIdempotencyScopeDigester(String activeVersion, Map keys) { + this(() -> activeVersion, keys); + } + + public HmacPosterImageIdempotencyScopeDigester( + Supplier activeVersion, Map keys) { + this.activeVersion = Objects.requireNonNull(activeVersion, "activeVersion must be non-null"); + Objects.requireNonNull(keys, "keys must be non-null"); + Map copied = new LinkedHashMap<>(); + for (Map.Entry entry : keys.entrySet()) { + String version = requireVersion(entry.getKey()); + byte[] key = + Objects.requireNonNull(entry.getValue(), "key material must be non-null").clone(); + if (key.length < 32 || key.length > 128) { + throw new IllegalArgumentException("idempotency HMAC key length is invalid"); + } + copied.put(version, key); + } + if (!copied.containsKey(requireVersion(this.activeVersion.get()))) { + throw new IllegalArgumentException("active idempotency HMAC key is absent"); + } + this.keys = Map.copyOf(copied); + } + + @Override + public PosterImageIdempotencyScopeDigest digest( + String tenant, String principal, String useCase, String idempotencyKey) { + byte[] canonical = + canonical( + tenant, + bounded(principal, "principal", 256), + bounded(useCase, "useCase", 128), + bounded(idempotencyKey, "idempotencyKey", 256)); + String selected = requireVersion(activeVersion.get()); + return digest(selected, canonical); + } + + @Override + public List retainedDigests( + String tenant, String principal, String useCase, String idempotencyKey) { + byte[] canonical = + canonical( + tenant, + bounded(principal, "principal", 256), + bounded(useCase, "useCase", 128), + bounded(idempotencyKey, "idempotencyKey", 256)); + return keys.keySet().stream().sorted().map(version -> digest(version, canonical)).toList(); + } + + private static byte[] canonical( + String tenant, String principal, String useCase, String idempotencyKey) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + field(output, DOMAIN); + field(output, new byte[] {(byte) (tenant == null ? 0 : 1)}); + field( + output, + tenant == null + ? new byte[0] + : bounded(tenant, "tenant", 256).getBytes(StandardCharsets.UTF_8)); + field(output, principal.getBytes(StandardCharsets.UTF_8)); + field(output, useCase.getBytes(StandardCharsets.UTF_8)); + field(output, idempotencyKey.getBytes(StandardCharsets.UTF_8)); + return output.toByteArray(); + } + + private static void field(ByteArrayOutputStream output, byte[] value) { + output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array()); + output.writeBytes(value); + } + + private static String bounded(String value, String label, int maximum) { + if (value == null || value.isBlank() || value.length() > maximum) { + throw new IllegalArgumentException(label + " is invalid"); + } + return value; + } + + private static String requireVersion(String value) { + if (value == null || !value.matches("[a-z0-9][a-z0-9._-]{0,63}")) { + throw new IllegalArgumentException("idempotency key version is invalid"); + } + return value; + } + + private PosterImageIdempotencyScopeDigest digest(String version, byte[] canonical) { + byte[] key = keys.get(version); + if (key == null) { + throw new IllegalStateException( + "DB-active idempotency HMAC key is not retained by this process"); + } + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + String encoded = + Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(canonical)); + return new PosterImageIdempotencyScopeDigest(version, encoded); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("HmacSHA256 must be available", exception); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java new file mode 100644 index 0000000..3b2871c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** UUIDv7 allocator for the DB-committed Poster image operation identity. */ +@Component +public final class UuidPosterImageOperationIdFactory implements PosterImageOperationIdFactory { + + @Override + public AllocatedOperation allocate() { + UUID value = UuidCreator.getTimeOrderedEpochPlus1(); + return new AllocatedOperation(value, ObjectOperationId.of(value.toString())); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java index 0ecf1f9..1643068 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java @@ -35,6 +35,9 @@ public class PosterEntity extends AuditableEntity { @Column(name = "image_key", length = 512) private String imageKey; + @Column(name = "image_reference", length = 96) + private String imageReference; + @Enumerated(EnumType.STRING) private PosterStatus status; @@ -46,10 +49,26 @@ public class PosterEntity extends AuditableEntity { public PosterEntity( UUID id, String title, String caption, String imageKey, PosterStatus status, Long version) { + this(id, title, caption, imageKey, null, status, version); + } + + public PosterEntity( + UUID id, + String title, + String caption, + String imageKey, + String imageReference, + PosterStatus status, + Long version) { + if (imageKey != null && imageReference != null) { + throw new IllegalArgumentException( + "poster entity cannot contain both image key and image reference"); + } this.id = id; this.title = title; this.caption = caption; this.imageKey = imageKey; + this.imageReference = imageReference; this.status = status; this.version = version; } @@ -70,6 +89,10 @@ public class PosterEntity extends AuditableEntity { return imageKey; } + public String getImageReference() { + return imageReference; + } + public PosterStatus getStatus() { return status; } diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java new file mode 100644 index 0000000..9aec1a7 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java @@ -0,0 +1,58 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; + +/** Single-row DB coordination fence for publication idempotency HMAC rotation. */ +@Entity +@Table(name = "poster_image_idempotency_key_epoch") +public class PosterImageIdempotencyKeyEpochEntity { + + @Id + @Column(name = "epoch_id", nullable = false, length = 64, updatable = false) + private String epochId; + + @Column(name = "active_key_version", nullable = false, length = 64) + private String activeKeyVersion; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long revision; + + protected PosterImageIdempotencyKeyEpochEntity() {} + + public PosterImageIdempotencyKeyEpochEntity( + String epochId, String activeKeyVersion, Instant updatedAt) { + this.epochId = epochId; + this.activeKeyVersion = activeKeyVersion; + this.updatedAt = updatedAt; + } + + public void activate(String activeKeyVersion, Instant now) { + this.activeKeyVersion = activeKeyVersion; + this.updatedAt = now; + } + + public String getEpochId() { + return epochId; + } + + public String getActiveKeyVersion() { + return activeKeyVersion; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getRevision() { + return revision; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java new file mode 100644 index 0000000..25af5bb --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java @@ -0,0 +1,198 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; + +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** JPA row for exact-reference logical retirement work. */ +@Entity +@Table( + name = "poster_image_retirement_intent", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_poster_image_retirement_operation", + columnNames = "operation_id"), + @UniqueConstraint( + name = "uq_poster_image_retirement_exact", + columnNames = {"image_reference", "exact_version"}) + }) +public class PosterImageRetirementIntentEntity { + + @Id + @JdbcTypeCode(SqlTypes.UUID) + @Column(nullable = false, updatable = false) + private UUID id; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "poster_id", updatable = false) + private UUID posterId; + + @Column(name = "operation_id", nullable = false, length = 64, updatable = false) + private String operationId; + + @Column(name = "image_reference", nullable = false, length = 96, updatable = false) + private String imageReference; + + @Column(name = "exact_version", nullable = false, length = 96, updatable = false) + private String exactVersion; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private PosterImageRetirementIntentState state; + + @Column(name = "claimant_digest", length = 64) + private String claimantDigest; + + @Column(name = "claim_fence") + private Long claimFence; + + @Column(name = "claim_expires_at") + private Instant claimExpiresAt; + + @Column(name = "retain_until", nullable = false, updatable = false) + private Instant retainUntil; + + @Column(name = "retry_at") + private Instant retryAt; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + @Column(name = "last_error_code", length = 64) + private String lastErrorCode; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long revision; + + protected PosterImageRetirementIntentEntity() {} + + public PosterImageRetirementIntentEntity( + UUID id, + UUID posterId, + String operationId, + String imageReference, + String exactVersion, + PosterImageRetirementIntentState state, + String claimantDigest, + Long claimFence, + Instant claimExpiresAt, + Instant retainUntil, + Instant retryAt, + int attemptCount, + String lastErrorCode, + Instant createdAt, + Instant updatedAt, + long revision) { + this.id = id; + this.posterId = posterId; + this.operationId = operationId; + this.imageReference = imageReference; + this.exactVersion = exactVersion; + this.state = state; + this.claimantDigest = claimantDigest; + this.claimFence = claimFence; + this.claimExpiresAt = claimExpiresAt; + this.retainUntil = retainUntil; + this.retryAt = retryAt; + this.attemptCount = attemptCount; + this.lastErrorCode = lastErrorCode; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.revision = revision; + } + + public void applyMutableState(PosterImageRetirementIntentEntity source) { + if (!id.equals(source.id) || revision + 1 != source.revision) { + throw new IllegalArgumentException("retirement intent replacement revision conflicts"); + } + state = source.state; + claimantDigest = source.claimantDigest; + claimFence = source.claimFence; + claimExpiresAt = source.claimExpiresAt; + retryAt = source.retryAt; + attemptCount = source.attemptCount; + lastErrorCode = source.lastErrorCode; + updatedAt = source.updatedAt; + } + + public UUID getId() { + return id; + } + + public UUID getPosterId() { + return posterId; + } + + public String getOperationId() { + return operationId; + } + + public String getImageReference() { + return imageReference; + } + + public String getExactVersion() { + return exactVersion; + } + + public PosterImageRetirementIntentState getState() { + return state; + } + + public String getClaimantDigest() { + return claimantDigest; + } + + public Long getClaimFence() { + return claimFence; + } + + public Instant getClaimExpiresAt() { + return claimExpiresAt; + } + + public Instant getRetainUntil() { + return retainUntil; + } + + public Instant getRetryAt() { + return retryAt; + } + + public int getAttemptCount() { + return attemptCount; + } + + public String getLastErrorCode() { + return lastErrorCode; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getRevision() { + return revision; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java new file mode 100644 index 0000000..bfbee14 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java @@ -0,0 +1,311 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; + +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** JPA representation of the V7 durable publication queue; no payload or provider locator. */ +@Entity +@Table( + name = "poster_image_upload_intent", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_poster_image_upload_operation", + columnNames = {"operation_destination", "operation_epoch", "operation_id"}), + @UniqueConstraint( + name = "uq_poster_image_upload_scope", + columnNames = {"hmac_key_version", "idempotency_scope_digest"}) + }) +public class PosterImageUploadIntentEntity { + + @Id + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "poster_id", nullable = false, updatable = false) + private UUID posterId; + + @Column(name = "operation_destination", nullable = false, length = 64, updatable = false) + private String operationDestination; + + @Column(name = "operation_epoch", nullable = false, length = 64, updatable = false) + private String operationEpoch; + + @Column(name = "operation_id", nullable = false, length = 64, updatable = false) + private String operationId; + + @Column(name = "hmac_key_version", nullable = false, length = 64, updatable = false) + private String hmacKeyVersion; + + @Column(name = "idempotency_scope_digest", nullable = false, length = 43, updatable = false) + private String idempotencyScopeDigest; + + @Column(name = "request_fingerprint", nullable = false, length = 64, updatable = false) + private String requestFingerprint; + + @Column(name = "expected_poster_version", nullable = false, updatable = false) + private long expectedPosterVersion; + + @Column(name = "content_length", nullable = false, updatable = false) + private long contentLength; + + @Column(name = "content_sha256", nullable = false, length = 44, updatable = false) + private String contentSha256; + + @Column(name = "media_type", nullable = false, length = 127, updatable = false) + private String mediaType; + + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false, length = 32) + private PosterImageUploadIntentState state; + + @Column(name = "stage_handle", length = 96) + private String stageHandle; + + @Column(name = "exact_version", length = 96) + private String exactVersion; + + @Column(name = "handoff_claimant_digest", length = 64) + private String handoffClaimantDigest; + + @Column(name = "handoff_fence") + private Long handoffFence; + + @Column(name = "handoff_expires_at") + private Instant handoffExpiresAt; + + @Column(name = "published_reference", length = 96) + private String publishedReference; + + @Column(name = "published_version", length = 96) + private String publishedVersion; + + @Column(name = "replaced_reference", length = 96) + private String replacedReference; + + @Column(name = "retry_at") + private Instant retryAt; + + @Column(name = "expires_at", nullable = false, updatable = false) + private Instant expiresAt; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + @Column(name = "last_error_code", length = 64) + private String lastErrorCode; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "revision", nullable = false) + private long revision; + + protected PosterImageUploadIntentEntity() {} + + public PosterImageUploadIntentEntity( + UUID id, + UUID posterId, + String operationDestination, + String operationEpoch, + String operationId, + String hmacKeyVersion, + String idempotencyScopeDigest, + String requestFingerprint, + long expectedPosterVersion, + long contentLength, + String contentSha256, + String mediaType, + PosterImageUploadIntentState state, + String stageHandle, + String exactVersion, + String handoffClaimantDigest, + Long handoffFence, + Instant handoffExpiresAt, + String publishedReference, + String publishedVersion, + String replacedReference, + Instant retryAt, + Instant expiresAt, + int attemptCount, + String lastErrorCode, + Instant createdAt, + Instant updatedAt, + long revision) { + this.id = id; + this.posterId = posterId; + this.operationDestination = operationDestination; + this.operationEpoch = operationEpoch; + this.operationId = operationId; + this.hmacKeyVersion = hmacKeyVersion; + this.idempotencyScopeDigest = idempotencyScopeDigest; + this.requestFingerprint = requestFingerprint; + this.expectedPosterVersion = expectedPosterVersion; + this.contentLength = contentLength; + this.contentSha256 = contentSha256; + this.mediaType = mediaType; + this.state = state; + this.stageHandle = stageHandle; + this.exactVersion = exactVersion; + this.handoffClaimantDigest = handoffClaimantDigest; + this.handoffFence = handoffFence; + this.handoffExpiresAt = handoffExpiresAt; + this.publishedReference = publishedReference; + this.publishedVersion = publishedVersion; + this.replacedReference = replacedReference; + this.retryAt = retryAt; + this.expiresAt = expiresAt; + this.attemptCount = attemptCount; + this.lastErrorCode = lastErrorCode; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.revision = revision; + } + + public void applyMutableState(PosterImageUploadIntentEntity source) { + if (!id.equals(source.id) || revision + 1 != source.revision) { + throw new IllegalArgumentException("upload intent replacement revision conflicts"); + } + state = source.state; + stageHandle = source.stageHandle; + exactVersion = source.exactVersion; + handoffClaimantDigest = source.handoffClaimantDigest; + handoffFence = source.handoffFence; + handoffExpiresAt = source.handoffExpiresAt; + publishedReference = source.publishedReference; + publishedVersion = source.publishedVersion; + replacedReference = source.replacedReference; + retryAt = source.retryAt; + attemptCount = source.attemptCount; + lastErrorCode = source.lastErrorCode; + updatedAt = source.updatedAt; + } + + public UUID getId() { + return id; + } + + public UUID getPosterId() { + return posterId; + } + + public String getOperationDestination() { + return operationDestination; + } + + public String getOperationEpoch() { + return operationEpoch; + } + + public String getOperationId() { + return operationId; + } + + public String getHmacKeyVersion() { + return hmacKeyVersion; + } + + public String getIdempotencyScopeDigest() { + return idempotencyScopeDigest; + } + + public String getRequestFingerprint() { + return requestFingerprint; + } + + public long getExpectedPosterVersion() { + return expectedPosterVersion; + } + + public long getContentLength() { + return contentLength; + } + + public String getContentSha256() { + return contentSha256; + } + + public String getMediaType() { + return mediaType; + } + + public PosterImageUploadIntentState getState() { + return state; + } + + public String getStageHandle() { + return stageHandle; + } + + public String getExactVersion() { + return exactVersion; + } + + public String getHandoffClaimantDigest() { + return handoffClaimantDigest; + } + + public Long getHandoffFence() { + return handoffFence; + } + + public Instant getHandoffExpiresAt() { + return handoffExpiresAt; + } + + public String getPublishedReference() { + return publishedReference; + } + + public String getPublishedVersion() { + return publishedVersion; + } + + public String getReplacedReference() { + return replacedReference; + } + + public Instant getRetryAt() { + return retryAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public int getAttemptCount() { + return attemptCount; + } + + public String getLastErrorCode() { + return lastErrorCode; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getRevision() { + return revision; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java new file mode 100644 index 0000000..4df14ff --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java @@ -0,0 +1,54 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageRetirementIntentEntity; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; + +/** Pure V7 retirement-intent domain/JPA translation. */ +public final class PosterImageRetirementIntentPersistenceMapper { + + private PosterImageRetirementIntentPersistenceMapper() {} + + public static PosterImageRetirementIntentEntity toEntity(PosterImageRetirementIntent intent) { + return new PosterImageRetirementIntentEntity( + intent.id(), + intent.posterId() == null ? null : PosterPersistenceMapper.toUuid(intent.posterId()), + intent.operationId().value(), + intent.reference().value(), + intent.exactVersion().canonicalText(), + intent.state(), + intent.claimantDigest(), + intent.claimFence(), + intent.claimExpiresAt(), + intent.retainUntil(), + intent.retryAt(), + intent.attemptCount(), + intent.lastErrorCode(), + intent.createdAt(), + intent.updatedAt(), + intent.revision()); + } + + public static PosterImageRetirementIntent toDomain(PosterImageRetirementIntentEntity entity) { + return new PosterImageRetirementIntent( + entity.getId(), + entity.getPosterId() == null ? null : PosterId.of(entity.getPosterId().toString()), + ObjectOperationId.of(entity.getOperationId()), + PosterImageReference.of(entity.getImageReference()), + ObjectVersionToken.parse(entity.getExactVersion()), + entity.getState(), + entity.getClaimantDigest(), + entity.getClaimFence(), + entity.getClaimExpiresAt(), + entity.getRetainUntil(), + entity.getRetryAt(), + entity.getAttemptCount(), + entity.getLastErrorCode(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getRevision()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java new file mode 100644 index 0000000..22aebe2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java @@ -0,0 +1,110 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +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.ObjectMediaType; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageUploadIntentEntity; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; + +/** Pure V7 upload-intent domain/JPA translation. */ +public final class PosterImageUploadIntentPersistenceMapper { + + private PosterImageUploadIntentPersistenceMapper() {} + + public static PosterImageUploadIntentEntity toEntity(PosterImageUploadIntent intent) { + return new PosterImageUploadIntentEntity( + intent.id(), + PosterPersistenceMapper.toUuid(intent.posterId()), + intent.operationKey().destination().value(), + intent.operationKey().epoch().value(), + intent.operationKey().operationId().value(), + intent.idempotencyScope().keyVersion(), + intent.idempotencyScope().base64UrlDigest(), + intent.requestFingerprint().hex(), + intent.expectedPosterVersion(), + intent.contentIdentity().exactLength(), + intent.contentIdentity().fullDigest().base64Value(), + intent.mediaType().canonicalText(), + intent.state(), + text(intent.stageHandle()), + text(intent.exactVersion()), + intent.handoffClaimantDigest(), + intent.handoffFence(), + intent.handoffExpiresAt(), + text(intent.publishedReference()), + text(intent.publishedVersion()), + text(intent.replacedReference()), + intent.retryAt(), + intent.expiresAt(), + intent.attemptCount(), + intent.lastErrorCode(), + intent.createdAt(), + intent.updatedAt(), + intent.revision()); + } + + public static PosterImageUploadIntent toDomain(PosterImageUploadIntentEntity entity) { + return new PosterImageUploadIntent( + entity.getId(), + PosterId.of(entity.getPosterId().toString()), + new ObjectOperationKey( + ObjectDestinationId.of(entity.getOperationDestination()), + ObjectOperationEpoch.of(entity.getOperationEpoch()), + ObjectOperationId.of(entity.getOperationId())), + new PosterImageIdempotencyScopeDigest( + entity.getHmacKeyVersion(), entity.getIdempotencyScopeDigest()), + new RequestFingerprint(entity.getRequestFingerprint()), + entity.getExpectedPosterVersion(), + new ObjectContentIdentity( + entity.getContentLength(), + ObjectDigest.of(ObjectDigestAlgorithm.SHA_256, entity.getContentSha256())), + ObjectMediaType.of(entity.getMediaType()), + entity.getState(), + entity.getStageHandle() == null ? null : ObjectStageHandle.parse(entity.getStageHandle()), + entity.getExactVersion() == null + ? null + : ObjectVersionToken.parse(entity.getExactVersion()), + entity.getHandoffClaimantDigest(), + entity.getHandoffFence(), + entity.getHandoffExpiresAt(), + entity.getPublishedReference() == null + ? null + : PosterImageReference.of(entity.getPublishedReference()), + entity.getPublishedVersion() == null + ? null + : ObjectVersionToken.parse(entity.getPublishedVersion()), + entity.getReplacedReference() == null + ? null + : PosterImageReference.of(entity.getReplacedReference()), + entity.getRetryAt(), + entity.getExpiresAt(), + entity.getAttemptCount(), + entity.getLastErrorCode(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getRevision()); + } + + private static String text(ObjectStageHandle handle) { + return handle == null ? null : handle.canonicalText(); + } + + private static String text(ObjectVersionToken version) { + return version == null ? null : version.canonicalText(); + } + + private static String text(PosterImageReference reference) { + return reference == null ? null : reference.value(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java index 2316e1c..ab7110c 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java @@ -24,12 +24,27 @@ public final class PosterPersistenceMapper { public static PosterEntity toEntity(Poster p) { // Carry the domain version so JPA tells a new row (null → insert) from a tracked one. return new PosterEntity( - toUuid(p.id()), p.title(), p.caption(), p.imageKey(), p.status(), p.version()); + toUuid(p.id()), + p.title(), + p.caption(), + p.imageAttachment() + instanceof dev.caskeleton.sample.portfolio.domain.poster.LegacyPosterImageAttachment + ? p.imageKey() + : null, + p.imageReference(), + p.status(), + p.version()); } public static Poster toDomain(PosterEntity e) { PosterId id = PosterId.of(e.getId().toString()); return Poster.rehydrate( - id, e.getTitle(), e.getCaption(), e.getImageKey(), e.getStatus(), e.getVersion()); + id, + e.getTitle(), + e.getCaption(), + e.getImageKey(), + e.getImageReference(), + e.getStatus(), + e.getVersion()); } } diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java new file mode 100644 index 0000000..88c89ed --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java @@ -0,0 +1,72 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageAttachmentCompareAndSetPort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.List; +import org.springframework.stereotype.Repository; + +/** PostgreSQL conditional V7 attachment update that returns the exact replaced reference. */ +@Repository +public class PosterImageAttachmentCasRepository implements PosterImageAttachmentCompareAndSetPort { + + private static final String ATTACH_SQL = + """ + WITH candidate AS ( + SELECT id, image_reference AS replaced_reference + FROM poster + WHERE id = :posterId + AND version = :expectedVersion + AND image_key IS NULL + FOR UPDATE + ), + updated AS ( + UPDATE poster AS poster + SET image_reference = :reference, + updated_at = :updatedAt, + updated_by = :updatedBy, + version = poster.version + 1 + FROM candidate + WHERE poster.id = candidate.id + RETURNING candidate.replaced_reference + ) + SELECT TRUE AS attached, replaced_reference + FROM updated + """; + + private final EntityManager entityManager; + private final AuditContextPort auditContext; + + public PosterImageAttachmentCasRepository( + EntityManager entityManager, AuditContextPort auditContext) { + this.entityManager = entityManager; + this.auditContext = auditContext; + } + + @Override + public Result attach( + PosterId posterId, long expectedPosterVersion, PosterImageReference reference, Instant now) { + java.util.Objects.requireNonNull(now, "now must be non-null"); + @SuppressWarnings("unchecked") + List rows = + entityManager + .createNativeQuery(ATTACH_SQL) + .setParameter( + "posterId", + dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper + .PosterPersistenceMapper.toUuid(posterId)) + .setParameter("expectedVersion", expectedPosterVersion) + .setParameter("reference", reference.value()) + .setParameter("updatedAt", now) + .setParameter("updatedBy", auditContext.currentActor()) + .getResultList(); + if (rows.isEmpty()) { + return new Result(false, null); + } + String replaced = (String) rows.getFirst()[1]; + return new Result(true, replaced == null ? null : PosterImageReference.of(replaced)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java new file mode 100644 index 0000000..f91f9f6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageIdempotencyKeyEpochEntity; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PosterImageIdempotencyKeyEpochJpaRepository + extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "select epoch from PosterImageIdempotencyKeyEpochEntity epoch" + + " where epoch.epochId = :epochId") + Optional findByIdForUpdate( + @Param("epochId") String epochId); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java new file mode 100644 index 0000000..f80bc62 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java @@ -0,0 +1,48 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageIdempotencyKeyEpochEntity; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyKeyEpochPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; +import java.time.Clock; +import org.springframework.stereotype.Repository; + +@Repository +public class PosterImageIdempotencyKeyEpochRepositoryAdapter + implements PosterImageIdempotencyKeyEpochPort { + + static final String EPOCH_ID = "poster-image-publication"; + + private final PosterImageIdempotencyKeyEpochJpaRepository jpa; + private final Clock clock; + + public PosterImageIdempotencyKeyEpochRepositoryAdapter( + PosterImageIdempotencyKeyEpochJpaRepository jpa, Clock clock) { + this.jpa = jpa; + this.clock = clock; + } + + @Override + public String activeVersionForReservation() { + return jpa.findByIdForUpdate(EPOCH_ID) + .orElseThrow( + () -> + new IllegalStateException("poster image idempotency key epoch is not initialized")) + .getActiveKeyVersion(); + } + + @Override + public void activate(String expectedVersion, String newVersion) { + PosterImageIdempotencyKeyEpochEntity epoch = + jpa.findByIdForUpdate(EPOCH_ID) + .orElseGet( + () -> + new PosterImageIdempotencyKeyEpochEntity( + EPOCH_ID, expectedVersion, clock.instant())); + if (!epoch.getActiveKeyVersion().equals(expectedVersion)) { + throw new PosterImageUploadIntentConflictException( + "poster image idempotency key epoch conflicts"); + } + epoch.activate(newVersion, clock.instant()); + jpa.saveAndFlush(epoch); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java new file mode 100644 index 0000000..cf9e521 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java @@ -0,0 +1,40 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageRetirementIntentEntity; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PosterImageRetirementIntentJpaRepository + extends JpaRepository { + + Optional findByOperationId(String operationId); + + Optional findByImageReferenceAndExactVersion( + String reference, String exactVersion); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + select intent + from PosterImageRetirementIntentEntity intent + where intent.state in :states + and intent.retainUntil <= :now + and (intent.retryAt is null or intent.retryAt <= :now) + and (intent.claimExpiresAt is null or intent.claimExpiresAt <= :now) + order by intent.updatedAt, intent.id + """) + List findClaimable( + @Param("states") Collection states, + @Param("now") Instant now, + Pageable pageable); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java new file mode 100644 index 0000000..f77bebc --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java @@ -0,0 +1,95 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageRetirementIntentEntity; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.PosterImageRetirementIntentPersistenceMapper; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +public class PosterImageRetirementIntentRepositoryAdapter + implements PosterImageRetirementIntentStorePort { + + private final PosterImageRetirementIntentJpaRepository jpa; + + public PosterImageRetirementIntentRepositoryAdapter( + PosterImageRetirementIntentJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed) { + Optional existing = + jpa.findByOperationId(proposed.operationId().value()); + if (existing.isEmpty()) { + existing = + jpa.findByImageReferenceAndExactVersion( + proposed.reference().value(), proposed.exactVersion().canonicalText()); + } + if (existing.isPresent()) { + PosterImageRetirementIntent found = + PosterImageRetirementIntentPersistenceMapper.toDomain(existing.orElseThrow()); + if (!found.operationId().equals(proposed.operationId()) + || !found.reference().equals(proposed.reference()) + || !found.exactVersion().equals(proposed.exactVersion())) { + throw new PosterImageUploadIntentConflictException( + "poster image retirement operation conflicts"); + } + return found; + } + return PosterImageRetirementIntentPersistenceMapper.toDomain( + jpa.saveAndFlush(PosterImageRetirementIntentPersistenceMapper.toEntity(proposed))); + } + + @Override + public Optional findByOperation(ObjectOperationId operation) { + return jpa.findByOperationId(operation.value()) + .map(PosterImageRetirementIntentPersistenceMapper::toDomain); + } + + @Override + public PosterImageRetirementIntent compareAndSet( + long expectedRevision, PosterImageRetirementIntent replacement) { + PosterImageRetirementIntentEntity current = + jpa.findById(replacement.id()) + .orElseThrow( + () -> + new PosterImageUploadIntentConflictException( + "poster image retirement intent is absent")); + if (current.getRevision() != expectedRevision + || replacement.revision() != expectedRevision + 1) { + throw new PosterImageUploadIntentConflictException( + "poster image retirement revision conflicts"); + } + current.applyMutableState(PosterImageRetirementIntentPersistenceMapper.toEntity(replacement)); + return PosterImageRetirementIntentPersistenceMapper.toDomain(jpa.saveAndFlush(current)); + } + + @Override + public Optional claimReady( + String claimantDigest, Instant now, Duration leaseDuration) { + return jpa + .findClaimable( + List.of( + PosterImageRetirementIntentState.PENDING, + PosterImageRetirementIntentState.IN_FLIGHT), + now, + PageRequest.of(0, 1)) + .stream() + .findFirst() + .map(PosterImageRetirementIntentPersistenceMapper::toDomain) + .map( + current -> + compareAndSet( + current.revision(), + current.claim(current.revision(), claimantDigest, now, leaseDuration))); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java new file mode 100644 index 0000000..e7cb1dd --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java @@ -0,0 +1,41 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageUploadIntentEntity; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PosterImageUploadIntentJpaRepository + extends JpaRepository { + + Optional findByOperationDestinationAndOperationEpochAndOperationId( + String destination, String epoch, String operationId); + + Optional findByHmacKeyVersionAndIdempotencyScopeDigest( + String version, String digest); + + Optional findByPublishedReference(String reference); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + select intent + from PosterImageUploadIntentEntity intent + where intent.state = :state + and (intent.retryAt is null or intent.retryAt <= :now) + and (intent.handoffExpiresAt is null or intent.handoffExpiresAt <= :now) + order by intent.updatedAt, intent.id + """) + List findClaimable( + @Param("state") PosterImageUploadIntentState state, + @Param("now") Instant now, + Pageable pageable); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java new file mode 100644 index 0000000..ee87cc6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java @@ -0,0 +1,119 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageUploadIntentEntity; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.PosterImageUploadIntentPersistenceMapper; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentClaim; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Optional; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +public class PosterImageUploadIntentRepositoryAdapter implements PosterImageUploadIntentStorePort { + + private final PosterImageUploadIntentJpaRepository jpa; + + public PosterImageUploadIntentRepositoryAdapter(PosterImageUploadIntentJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public PosterImageUploadIntent createOrRead(PosterImageUploadIntent proposed) { + Optional existing = + findByAnyScopeDigest(java.util.List.of(proposed.idempotencyScope())); + if (existing.isEmpty()) { + existing = findByOperation(proposed.operationKey()); + } + if (existing.isPresent()) { + PosterImageUploadIntent found = existing.orElseThrow(); + if (!found.operationKey().equals(proposed.operationKey()) + || !found.requestFingerprint().equals(proposed.requestFingerprint()) + || !found.posterId().equals(proposed.posterId())) { + throw new PosterImageUploadIntentConflictException( + "poster image idempotency scope is already bound to different work"); + } + return found; + } + if (proposed.state() != PosterImageUploadIntentState.RESERVED || proposed.revision() != 0) { + throw new IllegalArgumentException("only a fresh RESERVED intent can be created"); + } + return PosterImageUploadIntentPersistenceMapper.toDomain( + jpa.saveAndFlush(PosterImageUploadIntentPersistenceMapper.toEntity(proposed))); + } + + @Override + public Optional findByOperation(ObjectOperationKey operation) { + return jpa.findByOperationDestinationAndOperationEpochAndOperationId( + operation.destination().value(), + operation.epoch().value(), + operation.operationId().value()) + .map(PosterImageUploadIntentPersistenceMapper::toDomain); + } + + @Override + public Optional findByAnyScopeDigest( + Collection retainedDigests) { + for (PosterImageIdempotencyScopeDigest digest : retainedDigests) { + Optional found = + jpa.findByHmacKeyVersionAndIdempotencyScopeDigest( + digest.keyVersion(), digest.base64UrlDigest()); + if (found.isPresent()) { + return found.map(PosterImageUploadIntentPersistenceMapper::toDomain); + } + } + return Optional.empty(); + } + + @Override + public Optional findReadyByPublishedReference( + PosterImageReference reference) { + return jpa.findByPublishedReference(reference.value()) + .map(PosterImageUploadIntentPersistenceMapper::toDomain) + .filter(intent -> intent.state() == PosterImageUploadIntentState.READY); + } + + @Override + public PosterImageUploadIntent compareAndSet( + long expectedRevision, PosterImageUploadIntent replacement) { + PosterImageUploadIntentEntity current = + jpa.findById(replacement.id()) + .orElseThrow( + () -> + new PosterImageUploadIntentConflictException( + "poster image upload intent is absent")); + if (current.getRevision() != expectedRevision + || replacement.revision() != expectedRevision + 1) { + throw new PosterImageUploadIntentConflictException( + "poster image upload intent revision conflicts"); + } + current.applyMutableState(PosterImageUploadIntentPersistenceMapper.toEntity(replacement)); + return PosterImageUploadIntentPersistenceMapper.toDomain(jpa.saveAndFlush(current)); + } + + @Override + public Optional claimPending( + String claimantDigest, Instant now, Duration leaseDuration) { + return jpa + .findClaimable(PosterImageUploadIntentState.PENDING, now, PageRequest.of(0, 1)) + .stream() + .findFirst() + .map(PosterImageUploadIntentPersistenceMapper::toDomain) + .map( + current -> { + PosterImageUploadIntent replacement = + current.claim(current.revision(), claimantDigest, now, leaseDuration); + PosterImageUploadIntent saved = compareAndSet(current.revision(), replacement); + return new PosterImageUploadIntentClaim( + saved, saved.handoffFence(), saved.handoffExpiresAt()); + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java new file mode 100644 index 0000000..82123ab --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +public record AbortPosterImageUploadCommand( + PosterId posterId, + ObjectOperationId operationId, + CallBudget budget, + CancellationView cancellation) + implements Command { + + public AbortPosterImageUploadCommand { + if (posterId == null || operationId == null || budget == null || cancellation == null) { + throw new IllegalArgumentException("Poster image abort command is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java new file mode 100644 index 0000000..8607ae4 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java @@ -0,0 +1,51 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Arrays; + +/** Explicit administrative report/apply command; never exposed through a normal endpoint. */ +@SuppressWarnings("removal") +public final class AdoptLegacyPosterImageCommand implements Command { + + private final PosterId posterId; + private final long expectedPosterVersion; + private final LegacyObjectAdoptionRequest request; + private final byte[] canonicalApprovalDocument; + + public AdoptLegacyPosterImageCommand( + PosterId posterId, + long expectedPosterVersion, + LegacyObjectAdoptionRequest request, + byte[] canonicalApprovalDocument) { + if (posterId == null || expectedPosterVersion < 0 || request == null) { + throw new IllegalArgumentException("legacy Poster image adoption command is invalid"); + } + this.posterId = posterId; + this.expectedPosterVersion = expectedPosterVersion; + this.request = request; + this.canonicalApprovalDocument = + canonicalApprovalDocument == null + ? null + : Arrays.copyOf(canonicalApprovalDocument, canonicalApprovalDocument.length); + } + + public PosterId posterId() { + return posterId; + } + + public long expectedPosterVersion() { + return expectedPosterVersion; + } + + public LegacyObjectAdoptionRequest request() { + return request; + } + + public byte[] canonicalApprovalDocument() { + return canonicalApprovalDocument == null + ? null + : Arrays.copyOf(canonicalApprovalDocument, canonicalApprovalDocument.length); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java new file mode 100644 index 0000000..fe49ea2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java @@ -0,0 +1,39 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Objects; + +/** Framework-free request-memory publication command; no MultipartFile or payload array. */ +public record PublishPosterImageCommand( + PosterId posterId, + long expectedPosterVersion, + String tenant, + String principal, + String idempotencyKey, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType, + ObjectContentProducer producer, + CallBudget budget, + CancellationView cancellation) + implements Command { + + public PublishPosterImageCommand { + Objects.requireNonNull(posterId, "posterId must be non-null"); + Objects.requireNonNull(principal, "principal must be non-null"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must be non-null"); + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + Objects.requireNonNull(mediaType, "mediaType must be non-null"); + Objects.requireNonNull(producer, "producer must be non-null"); + Objects.requireNonNull(budget, "budget must be non-null"); + Objects.requireNonNull(cancellation, "cancellation must be non-null"); + if (expectedPosterVersion < 0) { + throw new IllegalArgumentException("expected Poster version is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java new file mode 100644 index 0000000..184a947 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java @@ -0,0 +1,31 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; + +/** One bounded worker claim/reconcile attempt. */ +public record ReconcilePosterImageRetirementCommand( + String claimantDigest, + Duration leaseDuration, + Duration retryBackoff, + CallBudget budget, + CancellationView cancellation) + implements Command { + + public ReconcilePosterImageRetirementCommand { + if (claimantDigest == null + || !claimantDigest.matches("[0-9a-f]{64}") + || leaseDuration == null + || leaseDuration.isZero() + || leaseDuration.isNegative() + || retryBackoff == null + || retryBackoff.isNegative() + || budget == null + || cancellation == null) { + throw new IllegalArgumentException( + "Poster image retirement reconciliation command is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java new file mode 100644 index 0000000..e836631 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +public record ReconcilePosterImageUploadCommand( + PosterId posterId, + ObjectOperationId operationId, + CallBudget budget, + CancellationView cancellation) + implements Command { + + public ReconcilePosterImageUploadCommand { + if (posterId == null || operationId == null || budget == null || cancellation == null) { + throw new IllegalArgumentException("Poster image reconciliation command is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java new file mode 100644 index 0000000..423143a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java @@ -0,0 +1,24 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Instant; + +/** Durable enqueue request for logical retirement of one exact published image version. */ +public record RetirePosterImageCommand( + PosterId posterId, + ObjectOperationId operationId, + PosterImageReference reference, + ObjectVersionToken exactVersion, + Instant retainUntil) + implements Command { + + public RetirePosterImageCommand { + if (operationId == null || reference == null || exactVersion == null || retainUntil == null) { + throw new IllegalArgumentException("Poster image retirement command is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java new file mode 100644 index 0000000..382c237 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +/** Versioned locator-free notification committed with PENDING. */ +public record PosterImageAttachmentPrepared( + PosterId posterId, + ObjectOperationId operationId, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java new file mode 100644 index 0000000..1225b14 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java @@ -0,0 +1,15 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; + +/** New V2 ready event; the legacy image-attached/imageKey event remains unchanged. */ +public record PosterImageAttachmentReadyV2( + PosterId posterId, + ObjectOperationId operationId, + PosterImageReference reference, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java new file mode 100644 index 0000000..d3b3a56 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java @@ -0,0 +1,81 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import dev.caskeleton.application.observability.CorrelationIdPort; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import java.time.Clock; +import org.springframework.stereotype.Component; + +/** Transactional locator-free publication integration notifications. */ +@Component +public final class PosterImagePublicationEventPublisher { + + private final OutboxAppendPort outbox; + private final OutboxEventIdFactory ids; + private final CorrelationIdPort correlations; + private final Clock clock; + + public PosterImagePublicationEventPublisher( + OutboxAppendPort outbox, + OutboxEventIdFactory ids, + CorrelationIdPort correlations, + Clock clock) { + this.outbox = outbox; + this.ids = ids; + this.correlations = correlations; + this.clock = clock; + } + + public void publishPrepared(PosterImageAttachmentPrepared event) { + append( + "poster.image-attachment-prepared.v1", + event.posterId().value(), + "{" + + field("posterId", event.posterId().value()) + + "," + + field("operationId", event.operationId().value()) + + "," + + field("mediaType", event.mediaType().canonicalText()) + + "," + + number("exactLength", event.contentIdentity().exactLength()) + + "}"); + } + + public void publishReady(PosterImageAttachmentReadyV2 event) { + append( + "poster.image-attachment-ready.v2", + event.posterId().value(), + "{" + + field("posterId", event.posterId().value()) + + "," + + field("operationId", event.operationId().value()) + + "," + + field("reference", event.reference().value()) + + "," + + field("mediaType", event.mediaType().canonicalText()) + + "," + + number("exactLength", event.contentIdentity().exactLength()) + + "}"); + } + + private void append(String type, String aggregate, String payload) { + String id = ids.newEventId(); + String correlation = + correlations.currentCorrelationId().filter(value -> !value.isBlank()).orElse(id); + outbox.append( + new NewOutboxEvent(id, type, aggregate, payload, clock.instant(), correlation, id)); + } + + private static String field(String name, String value) { + return "\"" + name + "\":\"" + escape(value) + "\""; + } + + private static String number(String name, long value) { + return "\"" + name + "\":" + value; + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java new file mode 100644 index 0000000..0854c23 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java @@ -0,0 +1,111 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; +import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; +import dev.caskeleton.application.objectstorage.request.ObjectAbortAuthorization; +import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.AbortPosterImageUploadCommand; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import java.time.Clock; + +/** Explicit application-first abort authorization; lease expiry alone is never destructive. */ +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +public final class AbortPosterImageUploadUseCase + implements CommandUseCase { + + private final PosterImageUploadIntentStorePort intents; + private final TransactionPort transactions; + private final ObjectPublicationHandoffPort handoffs; + private final StagedObjectPublicationPort publications; + private final ObjectDestinationId destination; + private final ObjectOperationEpoch epoch; + private final Clock clock; + + public AbortPosterImageUploadUseCase( + PosterImageUploadIntentStorePort intents, + TransactionPort transactions, + ObjectPublicationHandoffPort handoffs, + StagedObjectPublicationPort publications, + ObjectDestinationId destination, + ObjectOperationEpoch epoch, + Clock clock) { + this.intents = intents; + this.transactions = transactions; + this.handoffs = handoffs; + this.publications = publications; + this.destination = destination; + this.epoch = epoch; + this.clock = clock; + } + + @Override + public PosterImagePublicationStatus handle(AbortPosterImageUploadCommand command) { + ObjectOperationKey key = new ObjectOperationKey(destination, epoch, command.operationId()); + PosterImageUploadIntent authorized = + transactions.inWrite( + () -> { + PosterImageUploadIntent current = + intents + .findByOperation(key) + .filter(found -> found.posterId().equals(command.posterId())) + .orElseThrow( + () -> + new IllegalArgumentException( + "Poster image publication operation was not found")); + if (current.state().terminal() + || current.state() == PosterImageUploadIntentState.ABORT_AUTHORIZED) { + return current; + } + PosterImageUploadIntent replacement = + current.authorizeAbort(current.revision(), clock.instant()); + return intents.compareAndSet(current.revision(), replacement); + }); + if (authorized.state().terminal()) { + return PosterImagePublicationStatus.from(authorized); + } + ObjectAbortAuthorization authorization = + new ObjectAbortAuthorization( + key, + authorized.stageHandle(), + authorized.exactVersion(), + authorized.handoffFence(), + clock.instant()); + handoffs.authorizeAbort(authorization); + publications.abort( + new ObjectAbortRequest( + key, + authorized.stageHandle(), + authorization, + command.budget(), + command.cancellation())); + PosterImageUploadIntent aborted = + transactions.inWrite( + () -> { + PosterImageUploadIntent current = intents.findByOperation(key).orElseThrow(); + if (current.state() == PosterImageUploadIntentState.ABORTED) { + return current; + } + return intents.compareAndSet( + current.revision(), current.aborted(current.revision(), clock.instant())); + }); + return PosterImagePublicationStatus.from(aborted); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java index dd24480..ee60d11 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java @@ -10,8 +10,12 @@ import dev.caskeleton.application.usecase.CommandUseCase; import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; import dev.caskeleton.sample.portfolio.domain.poster.PosterDeleted; import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import java.time.Duration; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** @@ -30,20 +34,52 @@ public class DeletePosterUseCase implements CommandUseCase retireImages; + private final Duration retirementRetention; public DeletePosterUseCase( PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { + this(repository, events, tx, Optional.empty(), Duration.ZERO); + } + + @Autowired + public DeletePosterUseCase( + PosterRepository repository, + PosterEventPublisher events, + TransactionPort tx, + Optional retireImages) { + this(repository, events, tx, retireImages, Duration.ZERO); + } + + public DeletePosterUseCase( + PosterRepository repository, + PosterEventPublisher events, + TransactionPort tx, + Optional retireImages, + Duration retirementRetention) { this.repository = repository; this.events = events; this.tx = tx; + this.retireImages = retireImages; + this.retirementRetention = retirementRetention; } @Override public Void handle(DeletePosterCommand cmd) { return tx.inWrite( () -> { - if (!repository.existsById(cmd.id())) { - throw new PosterNotFoundException(cmd.id()); + if (retireImages.isEmpty()) { + if (!repository.existsById(cmd.id())) { + throw new PosterNotFoundException(cmd.id()); + } + } else { + Poster poster = + repository + .findById(cmd.id()) + .orElseThrow(() -> new PosterNotFoundException(cmd.id())); + retireImages + .orElseThrow() + .enqueueAttachedInCurrentTransaction(poster, retirementRetention); } events.publishDeleted(new PosterDeleted(cmd.id())); repository.deleteById(cmd.id()); diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java new file mode 100644 index 0000000..7b6da5a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java @@ -0,0 +1,57 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import dev.caskeleton.sample.portfolio.application.query.GetPosterImagePublicationStatusQuery; + +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPosterImagePublicationStatusUseCase + implements QueryUseCase { + + private final PosterImageUploadIntentStorePort intents; + private final TransactionPort transactions; + private final ObjectDestinationId destination; + private final ObjectOperationEpoch epoch; + + public GetPosterImagePublicationStatusUseCase( + PosterImageUploadIntentStorePort intents, + TransactionPort transactions, + ObjectDestinationId destination, + ObjectOperationEpoch epoch) { + this.intents = intents; + this.transactions = transactions; + this.destination = destination; + this.epoch = epoch; + } + + @Override + public PosterImagePublicationStatus handle(GetPosterImagePublicationStatusQuery query) { + return transactions.inRead( + () -> { + PosterImageUploadIntent intent = + intents + .findByOperation(new ObjectOperationKey(destination, epoch, query.operationId())) + .filter(found -> found.posterId().equals(query.posterId())) + .orElseThrow( + () -> + new IllegalArgumentException( + "Poster image publication operation was not found")); + return PosterImagePublicationStatus.from(intent); + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java new file mode 100644 index 0000000..d0b0247 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java @@ -0,0 +1,266 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +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 dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; +import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest; +import dev.caskeleton.application.objectstorage.request.ObjectStageRequest; +import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.PublishPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterImageAttachmentPrepared; +import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationFingerprint; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationPolicy; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservation; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservationCodec; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.util.EnumSet; +import java.util.HexFormat; + +/** TX1 reservation followed by request-bound staging and a fenced TX2 PENDING handoff. */ +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + sensitiveRead = true) +public final class PublishPosterImageUseCase + implements CommandUseCase { + + private static final PosterImagePublicationReservationCodec RESERVATION_CODEC = + new PosterImagePublicationReservationCodec(); + + private final PosterImageUploadIntentStorePort intents; + private final PosterImageSanitizedIdempotencyContextFactory sanitizedContexts; + private final PosterImageOperationIdFactory ids; + private final IdempotencyExecutor idempotency; + private final TransactionPort transactions; + private final StagedObjectPublicationPort publications; + private final ObjectPublicationHandoffPort handoffs; + private final PosterImagePublicationEventPublisher events; + private final PosterImagePublicationPolicy policy; + private final ObjectDestinationId destination; + private final ObjectOperationEpoch epoch; + private final Duration intentTtl; + private final Duration handoffLease; + private final Clock clock; + + public PublishPosterImageUseCase( + PosterImageUploadIntentStorePort intents, + PosterImageSanitizedIdempotencyContextFactory sanitizedContexts, + PosterImageOperationIdFactory ids, + IdempotencyExecutor idempotency, + TransactionPort transactions, + StagedObjectPublicationPort publications, + ObjectPublicationHandoffPort handoffs, + PosterImagePublicationEventPublisher events, + PosterImagePublicationPolicy policy, + ObjectDestinationId destination, + ObjectOperationEpoch epoch, + Duration intentTtl, + Duration handoffLease, + Clock clock) { + this.intents = intents; + this.sanitizedContexts = sanitizedContexts; + this.ids = ids; + this.idempotency = idempotency; + this.transactions = transactions; + this.publications = publications; + this.handoffs = handoffs; + this.events = events; + this.policy = policy; + this.destination = destination; + this.epoch = epoch; + this.intentTtl = intentTtl; + this.handoffLease = handoffLease; + this.clock = clock; + } + + @Override + public PosterImagePublicationReservation handle(PublishPosterImageCommand command) { + policy.requireAllowed(command.contentIdentity(), command.mediaType()); + RequestFingerprint fingerprint = + PosterImagePublicationFingerprint.create( + command.posterId(), + command.expectedPosterVersion(), + destination, + command.mediaType(), + command.contentIdentity()); + PosterImageSanitizedIdempotencyContextFactory.SanitizedContext sanitized = + sanitizedContexts.create( + command.tenant(), command.principal(), command.idempotencyKey(), fingerprint); + + PosterImagePublicationReservation reservation = + transactions.inWrite( + () -> + idempotency.execute( + sanitized.generic(), + () -> reserve(command, fingerprint, sanitized), + RESERVATION_CODEC)); + + PosterImageUploadIntent intent = + transactions.inRead( + () -> + intents + .findByOperation(operation(reservation.operationId())) + .orElseThrow( + () -> + new IllegalStateException( + "committed publication reservation has no intent"))); + if (intent.state() == PosterImageUploadIntentState.RESERVED) { + stageAndPrepare(command, intent); + } + return reservation; + } + + private PosterImagePublicationReservation reserve( + PublishPosterImageCommand command, + RequestFingerprint fingerprint, + PosterImageSanitizedIdempotencyContextFactory.SanitizedContext sanitized) { + java.util.Optional retained = + intents.findByAnyScopeDigest(sanitized.retainedScopes()); + if (retained.isPresent()) { + PosterImageUploadIntent existing = retained.orElseThrow(); + if (!existing.requestFingerprint().equals(fingerprint) + || !existing.posterId().equals(command.posterId())) { + throw new dev.caskeleton.sample.portfolio.application.posterimage + .PosterImageUploadIntentConflictException( + "Poster image idempotency scope is already bound to different work"); + } + return reservation(existing); + } + PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); + PosterImageUploadIntent created = + intents.createOrRead( + PosterImageUploadIntent.reserve( + allocated.intentId(), + command.posterId(), + operation(allocated.operationId()), + sanitized.activeScope(), + fingerprint, + command.expectedPosterVersion(), + command.contentIdentity(), + command.mediaType(), + clock.instant(), + clock.instant().plus(intentTtl))); + return reservation(created); + } + + private void stageAndPrepare( + PublishPosterImageCommand command, PosterImageUploadIntent reserved) { + var stage = + publications.stage( + new ObjectStageRequest( + reserved.operationKey(), + command.mediaType(), + command.contentIdentity(), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + ObjectRetentionRequirement.NONE, + ObjectEncryptionRequirement.PROVIDER_MANAGED, + EnumSet.of( + ObjectCapabilityRequirement.IMMUTABLE_CREATE, + ObjectCapabilityRequirement.EXACT_VERSION_READ, + ObjectCapabilityRequirement.CONDITIONAL_RETIREMENT, + ObjectCapabilityRequirement.SHA_256_VERIFICATION, + ObjectCapabilityRequirement.RESPONSE_LOSS_RECONCILIATION), + command.budget(), + command.cancellation()), + command.producer()); + var verified = + publications.verifyIntegrity( + new ObjectVerifyRequest( + reserved.operationKey(), + stage.stageHandle(), + stage.exactVersion(), + reserved.contentIdentity(), + command.budget(), + command.cancellation())); + if (!verified.verifiedContent().equals(reserved.contentIdentity())) { + throw new IllegalStateException("verified Poster image identity conflicts"); + } + String claimant = "poster-image-" + reserved.operationKey().operationId().value(); + var handoff = + handoffs.claimForPublication( + new ObjectHandoffClaimRequest( + reserved.operationKey(), + stage.stageHandle(), + stage.exactVersion(), + claimant, + handoffLease, + command.budget(), + command.cancellation())); + + transactions.inWrite( + () -> { + PosterImageUploadIntent current = + intents + .findByOperation(reserved.operationKey()) + .orElseThrow( + () -> new IllegalStateException("reserved publication intent is absent")); + if (current.state() == PosterImageUploadIntentState.PENDING) { + return; + } + PosterImageUploadIntent pending = + current.pending( + current.revision(), + stage.stageHandle(), + stage.exactVersion(), + sha256Hex(claimant), + handoff.fence(), + handoff.leaseExpiresAt(), + clock.instant()); + intents.compareAndSet(current.revision(), pending); + events.publishPrepared( + new PosterImageAttachmentPrepared( + current.posterId(), + current.operationKey().operationId(), + current.contentIdentity(), + current.mediaType())); + }); + } + + private ObjectOperationKey operation( + dev.caskeleton.application.objectstorage.identity.ObjectOperationId operationId) { + return new ObjectOperationKey(destination, epoch, operationId); + } + + private static PosterImagePublicationReservation reservation(PosterImageUploadIntent intent) { + return new PosterImagePublicationReservation( + intent.posterId(), intent.operationKey().operationId()); + } + + private 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); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java new file mode 100644 index 0000000..d46d390 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java @@ -0,0 +1,116 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.port.ObjectRetirementPort; +import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageRetirementCommand; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementResult; +import java.time.Clock; + +/** Fenced worker: claim in DB, retire outside DB, persist the exact outcome in a short TX. */ +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +public final class ReconcilePosterImageRetirementUseCase + implements CommandUseCase { + + private final PosterImageRetirementIntentStorePort retirements; + private final TransactionPort transactions; + private final ObjectRetirementPort objects; + private final ObjectDestinationId destination; + private final ObjectOperationEpoch epoch; + private final Clock clock; + + public ReconcilePosterImageRetirementUseCase( + PosterImageRetirementIntentStorePort retirements, + TransactionPort transactions, + ObjectRetirementPort objects, + ObjectDestinationId destination, + ObjectOperationEpoch epoch, + Clock clock) { + this.retirements = retirements; + this.transactions = transactions; + this.objects = objects; + this.destination = destination; + this.epoch = epoch; + this.clock = clock; + } + + @Override + public PosterImageRetirementResult handle(ReconcilePosterImageRetirementCommand command) { + PosterImageRetirementIntent claimed = + transactions + .inWrite( + () -> + retirements.claimReady( + command.claimantDigest(), clock.instant(), command.leaseDuration())) + .orElse(null); + if (claimed == null) { + return PosterImageRetirementResult.none(); + } + + try { + var receipt = + objects.retire( + new ObjectRetireRequest( + new ObjectOperationKey(destination, epoch, claimed.operationId()), + ObjectReference.parse(claimed.reference().value()), + claimed.exactVersion(), + command.budget(), + command.cancellation())); + PosterImageRetirementIntent terminal = + switch (receipt.outcome()) { + case APPLIED, REPLAYED, NO_CHANGE -> + claimed.retired(claimed.revision(), claimed.claimFence(), clock.instant()); + case HELD -> + claimed.held( + claimed.revision(), claimed.claimFence(), "RETENTION_HELD", clock.instant()); + case INDETERMINATE -> + claimed.retry( + claimed.revision(), + claimed.claimFence(), + receipt.error().name(), + clock.instant().plus(command.retryBackoff()), + clock.instant()); + case REJECTED -> + claimed.failed( + claimed.revision(), + claimed.claimFence(), + receipt.error().name(), + clock.instant()); + }; + return persist(claimed, terminal); + } catch (RuntimeException failure) { + PosterImageRetirementIntent retry = + claimed.retry( + claimed.revision(), + claimed.claimFence(), + "PROVIDER_UNAVAILABLE", + clock.instant().plus(command.retryBackoff()), + clock.instant()); + return persist(claimed, retry); + } + } + + private PosterImageRetirementResult persist( + PosterImageRetirementIntent claimed, PosterImageRetirementIntent replacement) { + PosterImageRetirementIntent stored = + transactions.inWrite(() -> retirements.compareAndSet(claimed.revision(), replacement)); + return PosterImageRetirementResult.from(stored); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java new file mode 100644 index 0000000..045e504 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java @@ -0,0 +1,213 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; +import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; +import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest; +import dev.caskeleton.application.objectstorage.request.ObjectHandoffReleaseRequest; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageUploadCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterImageAttachmentReadyV2; +import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageAttachmentCompareAndSetPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Clock; +import java.time.Duration; + +/** Worker boundary: finalize outside DB, then commit one deterministic READY/SUPERSEDED TX3. */ +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +public final class ReconcilePosterImageUploadUseCase + implements CommandUseCase { + + private final PosterImageUploadIntentStorePort uploads; + private final PosterImageRetirementIntentStorePort retirements; + private final PosterImageAttachmentCompareAndSetPort attachments; + private final PosterImageOperationIdFactory ids; + private final PosterImagePublicationEventPublisher events; + private final TransactionPort transactions; + private final StagedObjectPublicationPort publications; + private final ObjectPublicationHandoffPort handoffs; + private final ObjectDestinationId destination; + private final ObjectOperationEpoch epoch; + private final Duration retirementRetention; + private final Clock clock; + + public ReconcilePosterImageUploadUseCase( + PosterImageUploadIntentStorePort uploads, + PosterImageRetirementIntentStorePort retirements, + PosterImageAttachmentCompareAndSetPort attachments, + PosterImageOperationIdFactory ids, + PosterImagePublicationEventPublisher events, + TransactionPort transactions, + StagedObjectPublicationPort publications, + ObjectPublicationHandoffPort handoffs, + ObjectDestinationId destination, + ObjectOperationEpoch epoch, + Duration retirementRetention, + Clock clock) { + this.uploads = uploads; + this.retirements = retirements; + this.attachments = attachments; + this.ids = ids; + this.events = events; + this.transactions = transactions; + this.publications = publications; + this.handoffs = handoffs; + this.destination = destination; + this.epoch = epoch; + this.retirementRetention = retirementRetention; + this.clock = clock; + } + + @Override + public PosterImagePublicationStatus handle(ReconcilePosterImageUploadCommand command) { + ObjectOperationKey key = new ObjectOperationKey(destination, epoch, command.operationId()); + PosterImageUploadIntent intent = + transactions.inRead( + () -> + uploads + .findByOperation(key) + .filter(found -> found.posterId().equals(command.posterId())) + .orElseThrow( + () -> + new IllegalArgumentException( + "Poster image publication operation was not found"))); + if (intent.state().terminal()) { + releaseIfPublished(intent, command); + return PosterImagePublicationStatus.from(intent); + } + if (intent.state() != PosterImageUploadIntentState.PENDING) { + throw new IllegalStateException("Poster image publication is not ready to reconcile"); + } + + var published = + publications.finalizePublication( + new ObjectFinalizeRequest( + key, + intent.stageHandle(), + intent.exactVersion(), + ObjectPublicationRequirement.INTEGRITY_VERIFIED, + command.budget(), + command.cancellation())); + PosterImageReference reference = PosterImageReference.of(published.reference().canonicalText()); + + PosterImageUploadIntent terminal = + transactions.inWrite(() -> commitTerminal(intent, reference, published.exactVersion())); + + releaseIfPublished(terminal, command); + return PosterImagePublicationStatus.from(terminal); + } + + private void releaseIfPublished( + PosterImageUploadIntent intent, ReconcilePosterImageUploadCommand command) { + if ((intent.state() != PosterImageUploadIntentState.READY + && intent.state() != PosterImageUploadIntentState.SUPERSEDED) + || intent.handoffFence() == null) { + return; + } + handoffs.releaseClaim( + new ObjectHandoffReleaseRequest( + intent.operationKey(), + intent.stageHandle(), + intent.handoffFence(), + "poster-image-" + intent.operationKey().operationId().value(), + command.budget(), + command.cancellation())); + } + + private PosterImageUploadIntent commitTerminal( + PosterImageUploadIntent observed, + PosterImageReference reference, + dev.caskeleton.application.objectstorage.identity.ObjectVersionToken exactVersion) { + PosterImageUploadIntent current = + uploads + .findByOperation(observed.operationKey()) + .orElseThrow(() -> new IllegalStateException("publication intent disappeared")); + if (current.state().terminal()) { + return current; + } + if (current.state() != PosterImageUploadIntentState.PENDING + || current.handoffFence() == null + || !current.stageHandle().equals(observed.stageHandle())) { + throw new IllegalStateException("publication intent handoff changed before TX3"); + } + + PosterImageAttachmentCompareAndSetPort.Result attached = + attachments.attach( + current.posterId(), current.expectedPosterVersion(), reference, clock.instant()); + PosterImageUploadIntent replacement; + if (attached.attached()) { + replacement = + current.ready( + current.revision(), + reference, + exactVersion, + attached.replacedReference(), + clock.instant()); + if (attached.replacedReference() != null) { + enqueueRetirement( + current, + attached.replacedReference(), + uploads + .findReadyByPublishedReference(attached.replacedReference()) + .map(PosterImageUploadIntent::publishedVersion) + .orElseThrow( + () -> + new IllegalStateException( + "replaced Poster image has no exact READY evidence")), + "-replace-retire"); + } + events.publishReady( + new PosterImageAttachmentReadyV2( + current.posterId(), + current.operationKey().operationId(), + reference, + current.contentIdentity(), + current.mediaType())); + } else { + replacement = + current.superseded(current.revision(), reference, exactVersion, clock.instant()); + enqueueRetirement(current, reference, exactVersion, "-loser-retire"); + } + return uploads.compareAndSet(current.revision(), replacement); + } + + private void enqueueRetirement( + PosterImageUploadIntent upload, + PosterImageReference reference, + dev.caskeleton.application.objectstorage.identity.ObjectVersionToken version, + String suffix) { + PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); + retirements.createOrRead( + PosterImageRetirementIntent.pending( + allocated.intentId(), + upload.posterId(), + dev.caskeleton.application.objectstorage.identity.ObjectOperationId.of( + upload.operationKey().operationId().value() + suffix), + reference, + version, + clock.instant().plus(retirementRetention), + clock.instant())); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java new file mode 100644 index 0000000..7fca322 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java @@ -0,0 +1,91 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.RetirePosterImageCommand; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementResult; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PublishedPosterImageAttachment; +import java.time.Clock; +import java.time.Duration; + +/** Enqueues exact logical retirement without calling object storage in the database transaction. */ +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public final class RetirePosterImageUseCase + implements CommandUseCase { + + private final PosterImageRetirementIntentStorePort retirements; + private final PosterImageUploadIntentStorePort uploads; + private final PosterImageOperationIdFactory ids; + private final TransactionPort transactions; + private final Clock clock; + + public RetirePosterImageUseCase( + PosterImageRetirementIntentStorePort retirements, + PosterImageUploadIntentStorePort uploads, + PosterImageOperationIdFactory ids, + TransactionPort transactions, + Clock clock) { + this.retirements = retirements; + this.uploads = uploads; + this.ids = ids; + this.transactions = transactions; + this.clock = clock; + } + + @Override + public PosterImageRetirementResult handle(RetirePosterImageCommand command) { + return transactions.inWrite(() -> enqueueInCurrentTransaction(command)); + } + + public PosterImageRetirementResult enqueueInCurrentTransaction(RetirePosterImageCommand command) { + PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); + PosterImageRetirementIntent stored = + retirements.createOrRead( + PosterImageRetirementIntent.pending( + allocated.intentId(), + command.posterId(), + command.operationId(), + command.reference(), + command.exactVersion(), + command.retainUntil(), + clock.instant())); + return PosterImageRetirementResult.from(stored); + } + + public PosterImageRetirementResult enqueueAttachedInCurrentTransaction( + Poster poster, Duration retention) { + if (!(poster.imageAttachment() instanceof PublishedPosterImageAttachment published)) { + return PosterImageRetirementResult.none(); + } + PosterImageUploadIntent ready = + uploads + .findReadyByPublishedReference(published.reference()) + .orElseThrow( + () -> + new IllegalStateException( + "published Poster image has no exact READY evidence")); + PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); + return enqueueInCurrentTransaction( + new RetirePosterImageCommand( + poster.id(), + allocated.operationId(), + published.reference(), + ready.publishedVersion(), + clock.instant().plus(retention))); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java index ed5f5b6..0b47746 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java @@ -15,7 +15,6 @@ import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundExcep import dev.caskeleton.sample.portfolio.domain.poster.Poster; import dev.caskeleton.sample.portfolio.domain.poster.PosterImageAttached; import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; /** * Uploads a poster's image to object storage AND records its key on the aggregate — the single @@ -23,7 +22,6 @@ import org.springframework.stereotype.Service; * published). Stores the bytes under {@code posters/{id}/image}, then {@code attachImage}, save, * and a {@code poster.image-attached} outbox event, all in one write transaction. */ -@Service @RequiresPermission("poster:write") @UseCaseCapability( transactionMode = TransactionMode.WRITE, diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java new file mode 100644 index 0000000..4360a5b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java @@ -0,0 +1,101 @@ +package dev.caskeleton.sample.portfolio.application.poster.migration; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionApproval; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionApprovalVerifierPort; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionPort; +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.AdoptLegacyPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.application.posterimage.LegacyPosterImageAdoptionAuthorizationPolicy; +import dev.caskeleton.sample.portfolio.application.posterimage.LegacyPosterImageAdoptionExecutionIdentityPort; +import dev.caskeleton.sample.portfolio.application.posterimage.LegacyPosterImageAdoptionResult; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; + +/** Isolated administrative legacy exception; performs no remote I/O inside a DB transaction. */ +@Deprecated(forRemoval = true) +@SuppressWarnings("removal") +@RequiresPermission("poster:image-adopt") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +public final class AdoptLegacyPosterImageUseCase + implements CommandUseCase { + + private final LegacyObjectAdoptionPort adoptions; + private final LegacyObjectAdoptionApprovalVerifierPort approvals; + private final LegacyPosterImageAdoptionExecutionIdentityPort identity; + private final LegacyPosterImageAdoptionAuthorizationPolicy authorization; + private final PosterRepository posters; + private final TransactionPort transactions; + + public AdoptLegacyPosterImageUseCase( + LegacyObjectAdoptionPort adoptions, + LegacyObjectAdoptionApprovalVerifierPort approvals, + LegacyPosterImageAdoptionExecutionIdentityPort identity, + LegacyPosterImageAdoptionAuthorizationPolicy authorization, + PosterRepository posters, + TransactionPort transactions) { + this.adoptions = adoptions; + this.approvals = approvals; + this.identity = identity; + this.authorization = authorization; + this.posters = posters; + this.transactions = transactions; + } + + @Override + public LegacyPosterImageAdoptionResult handle(AdoptLegacyPosterImageCommand command) { + authorization.authorize(identity.current()); + LegacyObjectAdoptionRequest request = authorizeApply(command); + var receipt = adoptions.adopt(request); + if (request.mode() == LegacyObjectAdoptionRequest.Mode.REPORT_ONLY) { + return new LegacyPosterImageAdoptionResult(receipt, false); + } + transactions.inWrite( + () -> { + Poster poster = + posters + .findById(command.posterId()) + .orElseThrow(() -> new PosterNotFoundException(command.posterId())); + if (poster.version() == null || poster.version() != command.expectedPosterVersion()) { + throw new IllegalStateException("Poster changed during legacy image adoption"); + } + poster.attachPublishedImage( + PosterImageReference.of(receipt.adoptedReference().canonicalText())); + posters.save(poster); + return null; + }); + return new LegacyPosterImageAdoptionResult(receipt, true); + } + + private LegacyObjectAdoptionRequest authorizeApply(AdoptLegacyPosterImageCommand command) { + LegacyObjectAdoptionRequest request = command.request(); + if (request.mode() == LegacyObjectAdoptionRequest.Mode.REPORT_ONLY) { + return request; + } + byte[] document = command.canonicalApprovalDocument(); + if (document == null) { + throw new SecurityException("legacy adoption APPLY approval is required"); + } + LegacyObjectAdoptionApproval approval = approvals.verify(document, request); + return new LegacyObjectAdoptionRequest( + request.locator(), + request.publicationRequest(), + request.manifestSha256(), + request.legacyNamespaceDigest(), + request.targetNamespaceDigest(), + request.mode(), + approval); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java new file mode 100644 index 0000000..3bc22e5 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java @@ -0,0 +1,24 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +/** Exact maintenance identity/capability policy checked before reading approval or legacy data. */ +public final class LegacyPosterImageAdoptionAuthorizationPolicy { + + public static final String REQUIRED_CAPABILITY = "poster:image-adopt"; + + private final String expectedIdentityDigest; + + public LegacyPosterImageAdoptionAuthorizationPolicy(String expectedIdentityDigest) { + if (expectedIdentityDigest == null || !expectedIdentityDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("legacy adoption expected identity is invalid"); + } + this.expectedIdentityDigest = expectedIdentityDigest; + } + + public void authorize(LegacyPosterImageAdoptionExecutionIdentity identity) { + if (identity == null + || !expectedIdentityDigest.equals(identity.identityDigest()) + || !identity.capabilities().contains(REQUIRED_CAPABILITY)) { + throw new SecurityException("legacy Poster image adoption is not authorized"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java new file mode 100644 index 0000000..e8b2627 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import java.util.Set; + +/** Explicit non-web maintenance identity for administrative legacy adoption. */ +public record LegacyPosterImageAdoptionExecutionIdentity( + String identityDigest, Set capabilities) { + + public LegacyPosterImageAdoptionExecutionIdentity { + if (identityDigest == null + || !identityDigest.matches("[0-9a-f]{64}") + || capabilities == null + || capabilities.contains(null)) { + throw new IllegalArgumentException("legacy adoption execution identity is invalid"); + } + capabilities = Set.copyOf(capabilities); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java new file mode 100644 index 0000000..89c58d9 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java @@ -0,0 +1,7 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +/** Supplies only the isolated maintenance workload identity, never a web security context. */ +public interface LegacyPosterImageAdoptionExecutionIdentityPort { + + LegacyPosterImageAdoptionExecutionIdentity current(); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java new file mode 100644 index 0000000..365aa05 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java @@ -0,0 +1,15 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionReceipt; + +/** Administrative adoption result; the underlying receipt is locator-free. */ +@SuppressWarnings("removal") +public record LegacyPosterImageAdoptionResult( + LegacyObjectAdoptionReceipt receipt, boolean attachedToPoster) { + + public LegacyPosterImageAdoptionResult { + if (receipt == null) { + throw new IllegalArgumentException("legacy Poster image adoption result is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java new file mode 100644 index 0000000..50ecc04 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Instant; + +/** Atomic V7 canonical attachment write; legacy-key rows must be adopted first. */ +public interface PosterImageAttachmentCompareAndSetPort { + + Result attach( + PosterId posterId, long expectedPosterVersion, PosterImageReference reference, Instant now); + + record Result(boolean attached, PosterImageReference replacedReference) { + + public Result { + if (!attached && replacedReference != null) { + throw new IllegalArgumentException("CAS loser cannot report a replaced reference"); + } + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java new file mode 100644 index 0000000..8ee3165 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java @@ -0,0 +1,9 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +/** DB-coordinated active HMAC key version used by every publication-reservation pod. */ +public interface PosterImageIdempotencyKeyEpochPort { + + String activeVersionForReservation(); + + void activate(String expectedVersion, String newVersion); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java new file mode 100644 index 0000000..8b2c157 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import java.util.Base64; + +/** Versioned non-reversible idempotency lookup digest. */ +public record PosterImageIdempotencyScopeDigest(String keyVersion, String base64UrlDigest) { + + public PosterImageIdempotencyScopeDigest { + if (keyVersion == null + || !keyVersion.matches("[a-z0-9][a-z0-9._-]{0,63}") + || base64UrlDigest == null + || base64UrlDigest.length() != 43) { + throw new IllegalArgumentException("poster image idempotency digest is invalid"); + } + try { + if (Base64.getUrlDecoder().decode(base64UrlDigest).length != 32) { + throw new IllegalArgumentException("poster image idempotency digest is invalid"); + } + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("poster image idempotency digest is invalid", exception); + } + } + + @Override + public String toString() { + return "PosterImageIdempotencyScopeDigest[keyVersion=" + keyVersion + ", digest=]"; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java new file mode 100644 index 0000000..47ad1f5 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import java.util.List; + +/** HMAC boundary for the request-memory-only tenant/principal/client-key scope. */ +public interface PosterImageIdempotencyScopeDigesterPort { + + PosterImageIdempotencyScopeDigest digest( + String tenant, String principal, String useCase, String idempotencyKey); + + /** Digests under every retained version for overlap-first rotation lookup. */ + default List retainedDigests( + String tenant, String principal, String useCase, String idempotencyKey) { + return List.of(digest(tenant, principal, useCase, idempotencyKey)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java new file mode 100644 index 0000000..df4a729 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java @@ -0,0 +1,19 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import java.util.UUID; + +/** Identifier boundary for one stable durable publication reservation. */ +public interface PosterImageOperationIdFactory { + + AllocatedOperation allocate(); + + record AllocatedOperation(UUID intentId, ObjectOperationId operationId) { + + public AllocatedOperation { + if (intentId == null || operationId == null) { + throw new IllegalArgumentException("allocated publication operation is invalid"); + } + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java new file mode 100644 index 0000000..8296b70 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java @@ -0,0 +1,44 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +/** Frozen transport-independent semantic request fingerprint. */ +public final class PosterImagePublicationFingerprint { + + private static final byte[] SCHEMA = + "poster-image-publication-fingerprint-v1".getBytes(StandardCharsets.UTF_8); + + private PosterImagePublicationFingerprint() {} + + public static RequestFingerprint create( + PosterId posterId, + long expectedPosterVersion, + ObjectDestinationId destination, + ObjectMediaType mediaType, + ObjectContentIdentity identity) { + if (expectedPosterVersion < 0) { + throw new IllegalArgumentException("expected Poster version is invalid"); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + field(output, SCHEMA); + field(output, posterId.value().getBytes(StandardCharsets.UTF_8)); + field(output, ByteBuffer.allocate(Long.BYTES).putLong(expectedPosterVersion).array()); + field(output, destination.value().getBytes(StandardCharsets.UTF_8)); + field(output, mediaType.canonicalText().getBytes(StandardCharsets.UTF_8)); + field(output, ByteBuffer.allocate(Long.BYTES).putLong(identity.exactLength()).array()); + field(output, identity.fullDigest().decodedValue()); + return RequestFingerprint.ofSha256(output.toByteArray()); + } + + private static void field(ByteArrayOutputStream output, byte[] value) { + output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array()); + output.writeBytes(value); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java new file mode 100644 index 0000000..7e1e98e --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java @@ -0,0 +1,34 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import java.util.Set; + +/** Sample business policy for accepted Poster image semantics. */ +public final class PosterImagePublicationPolicy { + + private static final Set ALLOWED_MEDIA_TYPES = + Set.of("image/jpeg", "image/png", "image/webp"); + + private final long maximumBytes; + + public PosterImagePublicationPolicy(long maximumBytes) { + if (maximumBytes < 1 || maximumBytes > 100L * 1024 * 1024) { + throw new IllegalArgumentException("Poster image maximum bytes is invalid"); + } + this.maximumBytes = maximumBytes; + } + + public void requireAllowed(ObjectContentIdentity identity, ObjectMediaType mediaType) { + if (identity.exactLength() < 1 || identity.exactLength() > maximumBytes) { + throw new IllegalArgumentException("Poster image logical size is not allowed"); + } + if (identity.fullDigest().algorithm() != ObjectDigestAlgorithm.SHA_256) { + throw new IllegalArgumentException("Poster image requires a full SHA-256 digest"); + } + if (!ALLOWED_MEDIA_TYPES.contains(mediaType.canonicalText())) { + throw new IllegalArgumentException("Poster image media type is not allowed"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java new file mode 100644 index 0000000..3778130 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +/** Stable bounded response committed in the generic idempotency reservation. */ +public record PosterImagePublicationReservation(PosterId posterId, ObjectOperationId operationId) { + + public PosterImagePublicationReservation { + if (posterId == null || operationId == null) { + throw new IllegalArgumentException("Poster image publication reservation is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java new file mode 100644 index 0000000..414d439 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java @@ -0,0 +1,28 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.idempotency.IdempotentResponseCodec; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +/** Bounded internal replay encoding for the stable 202 reservation. */ +public final class PosterImagePublicationReservationCodec + implements IdempotentResponseCodec { + + @Override + public String serialize(PosterImagePublicationReservation result) { + return result.posterId().value() + "|" + result.operationId().value(); + } + + @Override + public PosterImagePublicationReservation deserialize(String payload) { + if (payload == null || payload.length() > 160) { + throw new IllegalArgumentException("publication reservation replay payload is invalid"); + } + String[] parts = payload.split("\\|", -1); + if (parts.length != 2) { + throw new IllegalArgumentException("publication reservation replay payload is invalid"); + } + return new PosterImagePublicationReservation( + PosterId.of(parts[0]), ObjectOperationId.of(parts[1])); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java new file mode 100644 index 0000000..70f9a50 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java @@ -0,0 +1,38 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; + +/** Authorized locator-free status projection. */ +public record PosterImagePublicationStatus( + PosterId posterId, + ObjectOperationId operationId, + PosterImageUploadIntentState state, + PosterImageReference reference, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType) { + + public PosterImagePublicationStatus { + if (posterId == null + || operationId == null + || state == null + || contentIdentity == null + || mediaType == null + || (state == PosterImageUploadIntentState.READY) != (reference != null)) { + throw new IllegalArgumentException("Poster image publication status is invalid"); + } + } + + public static PosterImagePublicationStatus from(PosterImageUploadIntent intent) { + return new PosterImagePublicationStatus( + intent.posterId(), + intent.operationKey().operationId(), + intent.state(), + intent.state() == PosterImageUploadIntentState.READY ? intent.publishedReference() : null, + intent.contentIdentity(), + intent.mediaType()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java new file mode 100644 index 0000000..e09bdc1 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java @@ -0,0 +1,213 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Exact immutable publication version queued independently for logical retirement. */ +public record PosterImageRetirementIntent( + UUID id, + PosterId posterId, + ObjectOperationId operationId, + PosterImageReference reference, + ObjectVersionToken exactVersion, + PosterImageRetirementIntentState state, + String claimantDigest, + Long claimFence, + Instant claimExpiresAt, + Instant retainUntil, + Instant retryAt, + int attemptCount, + String lastErrorCode, + Instant createdAt, + Instant updatedAt, + long revision) { + + public PosterImageRetirementIntent { + Objects.requireNonNull(id, "id must be non-null"); + Objects.requireNonNull(operationId, "operationId must be non-null"); + Objects.requireNonNull(reference, "reference must be non-null"); + Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); + Objects.requireNonNull(state, "state must be non-null"); + Objects.requireNonNull(retainUntil, "retainUntil must be non-null"); + Objects.requireNonNull(createdAt, "createdAt must be non-null"); + Objects.requireNonNull(updatedAt, "updatedAt must be non-null"); + if (attemptCount < 0 || revision < 0) { + throw new IllegalArgumentException("retirement counters are invalid"); + } + if ((claimantDigest == null) != (claimFence == null) + || (claimFence == null) != (claimExpiresAt == null)) { + throw new IllegalArgumentException("retirement claim evidence is incomplete"); + } + } + + public static PosterImageRetirementIntent pending( + UUID id, + PosterId posterId, + ObjectOperationId operationId, + PosterImageReference reference, + ObjectVersionToken exactVersion, + Instant retainUntil, + Instant now) { + return new PosterImageRetirementIntent( + id, + posterId, + operationId, + reference, + exactVersion, + PosterImageRetirementIntentState.PENDING, + null, + null, + null, + retainUntil, + null, + 0, + null, + now, + now, + 0); + } + + public PosterImageRetirementIntent claim( + long expectedRevision, String claimant, Instant now, Duration duration) { + if (revision != expectedRevision + || !claimableAt(now) + || retainUntil.isAfter(now) + || duration == null + || duration.isZero() + || duration.isNegative() + || claimant == null + || !claimant.matches("[0-9a-f]{64}")) { + throw new PosterImageUploadIntentConflictException( + "poster image retirement intent cannot be claimed"); + } + return copy( + PosterImageRetirementIntentState.IN_FLIGHT, + claimant, + claimFence == null ? 1 : claimFence + 1, + now.plus(duration), + retryAt, + attemptCount, + lastErrorCode, + now); + } + + public PosterImageRetirementIntent retired( + long expectedRevision, long expectedFence, Instant now) { + requireClaim(expectedRevision, expectedFence); + return copy( + PosterImageRetirementIntentState.RETIRED, + claimantDigest, + claimFence, + claimExpiresAt, + null, + attemptCount, + null, + now); + } + + public PosterImageRetirementIntent retry( + long expectedRevision, long expectedFence, String errorCode, Instant retryAt, Instant now) { + requireClaim(expectedRevision, expectedFence); + if (errorCode == null + || errorCode.isBlank() + || errorCode.length() > 64 + || retryAt == null + || retryAt.isBefore(now)) { + throw new PosterImageUploadIntentConflictException( + "poster image retirement retry evidence is invalid"); + } + return copy( + PosterImageRetirementIntentState.PENDING, + claimantDigest, + claimFence, + now, + retryAt, + attemptCount + 1, + errorCode, + now); + } + + public PosterImageRetirementIntent held( + long expectedRevision, long expectedFence, String evidenceCode, Instant now) { + return terminal( + PosterImageRetirementIntentState.HELD, evidenceCode, expectedRevision, expectedFence, now); + } + + public PosterImageRetirementIntent failed( + long expectedRevision, long expectedFence, String errorCode, Instant now) { + return terminal( + PosterImageRetirementIntentState.FAILED, errorCode, expectedRevision, expectedFence, now); + } + + private PosterImageRetirementIntent terminal( + PosterImageRetirementIntentState terminalState, + String evidenceCode, + long expectedRevision, + long expectedFence, + Instant now) { + requireClaim(expectedRevision, expectedFence); + if (evidenceCode == null || evidenceCode.isBlank() || evidenceCode.length() > 64) { + throw new PosterImageUploadIntentConflictException( + "poster image retirement terminal evidence is invalid"); + } + return copy( + terminalState, + claimantDigest, + claimFence, + claimExpiresAt, + null, + attemptCount, + evidenceCode, + now); + } + + private PosterImageRetirementIntent copy( + PosterImageRetirementIntentState next, + String nextClaimant, + Long nextFence, + Instant nextClaimExpiry, + Instant nextRetry, + int nextAttemptCount, + String nextError, + Instant now) { + return new PosterImageRetirementIntent( + id, + posterId, + operationId, + reference, + exactVersion, + next, + nextClaimant, + nextFence, + nextClaimExpiry, + retainUntil, + nextRetry, + nextAttemptCount, + nextError, + createdAt, + now, + revision + 1); + } + + private void requireClaim(long expectedRevision, long expectedFence) { + if (revision != expectedRevision + || state != PosterImageRetirementIntentState.IN_FLIGHT + || claimFence == null + || claimFence != expectedFence) { + throw new PosterImageUploadIntentConflictException("poster image retirement fence conflicts"); + } + } + + private boolean claimableAt(Instant now) { + return state == PosterImageRetirementIntentState.PENDING + || (state == PosterImageRetirementIntentState.IN_FLIGHT + && claimExpiresAt != null + && !claimExpiresAt.isAfter(now)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java new file mode 100644 index 0000000..a8c5ca6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +/** Separate exact-reference retirement work state. */ +public enum PosterImageRetirementIntentState { + PENDING, + IN_FLIGHT, + RETIRED, + HELD, + FAILED; + + public boolean terminal() { + return this == RETIRED || this == HELD || this == FAILED; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java new file mode 100644 index 0000000..704e81f --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +/** Persistence boundary for exact-reference retirement work that survives Poster deletion. */ +public interface PosterImageRetirementIntentStorePort { + + PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed); + + Optional findByOperation(ObjectOperationId operation); + + PosterImageRetirementIntent compareAndSet( + long expectedRevision, PosterImageRetirementIntent replacement); + + Optional claimReady( + String claimantDigest, Instant now, Duration leaseDuration); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java new file mode 100644 index 0000000..c82687b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java @@ -0,0 +1,25 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; + +/** Locator-free result from enqueueing or reconciling exact logical retirement work. */ +public record PosterImageRetirementResult( + ObjectOperationId operationId, PosterImageRetirementIntentState state, boolean workFound) { + + public PosterImageRetirementResult { + if (workFound && (operationId == null || state == null)) { + throw new IllegalArgumentException("retirement result evidence is incomplete"); + } + if (!workFound && (operationId != null || state != null)) { + throw new IllegalArgumentException("empty retirement result cannot contain evidence"); + } + } + + public static PosterImageRetirementResult none() { + return new PosterImageRetirementResult(null, null, false); + } + + public static PosterImageRetirementResult from(PosterImageRetirementIntent intent) { + return new PosterImageRetirementResult(intent.operationId(), intent.state(), true); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java new file mode 100644 index 0000000..b9c5865 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java @@ -0,0 +1,63 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.idempotency.IdempotencyContext; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.util.Objects; + +/** Replaces every raw caller dimension before the generic idempotency store sees it. */ +public final class PosterImageSanitizedIdempotencyContextFactory { + + public static final String RESERVATION_USE_CASE = "poster-image-publication-reservation-v1"; + + private final PosterImageIdempotencyScopeDigesterPort digester; + + public PosterImageSanitizedIdempotencyContextFactory( + PosterImageIdempotencyScopeDigesterPort digester) { + this.digester = Objects.requireNonNull(digester, "digester must be non-null"); + } + + public SanitizedContext create( + String tenant, String principal, String idempotencyKey, RequestFingerprint fingerprint) { + PosterImageIdempotencyScopeDigest full = + digester.digest(tenant, principal, RESERVATION_USE_CASE, idempotencyKey); + PosterImageIdempotencyScopeDigest principalAlias = + digester.digest( + tenant, principal, "poster-image-principal-alias-v1", "principal-dimension"); + String tenantAlias = + tenant == null + ? null + : encoded( + digester.digest( + tenant, + "tenant-dimension", + "poster-image-tenant-alias-v1", + "tenant-dimension")); + IdempotencyScope sanitized = + IdempotencyScope.of( + tenantAlias, encoded(principalAlias), encoded(full), RESERVATION_USE_CASE); + return new SanitizedContext( + IdempotencyContext.of(sanitized, fingerprint), + full, + digester.retainedDigests(tenant, principal, RESERVATION_USE_CASE, idempotencyKey)); + } + + private static String encoded(PosterImageIdempotencyScopeDigest digest) { + return digest.keyVersion() + "." + digest.base64UrlDigest(); + } + + public record SanitizedContext( + IdempotencyContext generic, + PosterImageIdempotencyScopeDigest activeScope, + java.util.List retainedScopes) { + + public SanitizedContext { + Objects.requireNonNull(generic, "generic must be non-null"); + Objects.requireNonNull(activeScope, "activeScope must be non-null"); + retainedScopes = java.util.List.copyOf(retainedScopes); + if (retainedScopes.isEmpty()) { + throw new IllegalArgumentException("retained scopes must be non-empty"); + } + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java new file mode 100644 index 0000000..e244d9d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java @@ -0,0 +1,333 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +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.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Durable application-owned publication intent and crash-recovery queue item. */ +public record PosterImageUploadIntent( + UUID id, + PosterId posterId, + ObjectOperationKey operationKey, + PosterImageIdempotencyScopeDigest idempotencyScope, + RequestFingerprint requestFingerprint, + long expectedPosterVersion, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType, + PosterImageUploadIntentState state, + ObjectStageHandle stageHandle, + ObjectVersionToken exactVersion, + String handoffClaimantDigest, + Long handoffFence, + Instant handoffExpiresAt, + PosterImageReference publishedReference, + ObjectVersionToken publishedVersion, + PosterImageReference replacedReference, + Instant retryAt, + Instant expiresAt, + int attemptCount, + String lastErrorCode, + Instant createdAt, + Instant updatedAt, + long revision) { + + public PosterImageUploadIntent { + Objects.requireNonNull(id, "id must be non-null"); + Objects.requireNonNull(posterId, "posterId must be non-null"); + Objects.requireNonNull(operationKey, "operationKey must be non-null"); + Objects.requireNonNull(idempotencyScope, "idempotencyScope must be non-null"); + Objects.requireNonNull(requestFingerprint, "requestFingerprint must be non-null"); + Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); + Objects.requireNonNull(mediaType, "mediaType must be non-null"); + Objects.requireNonNull(state, "state must be non-null"); + Objects.requireNonNull(expiresAt, "expiresAt must be non-null"); + Objects.requireNonNull(createdAt, "createdAt must be non-null"); + Objects.requireNonNull(updatedAt, "updatedAt must be non-null"); + if (expectedPosterVersion < 0 || attemptCount < 0 || revision < 0) { + throw new IllegalArgumentException("poster image upload counters are invalid"); + } + if ((stageHandle == null) != (exactVersion == null)) { + throw new IllegalArgumentException("stage handle and version must be present together"); + } + if ((handoffClaimantDigest == null) != (handoffFence == null) + || (handoffFence == null) != (handoffExpiresAt == null)) { + throw new IllegalArgumentException("handoff evidence is incomplete"); + } + if (handoffFence != null + && (handoffFence < 1 + || handoffClaimantDigest.length() != 64 + || !handoffClaimantDigest.matches("[0-9a-f]{64}"))) { + throw new IllegalArgumentException("handoff evidence is invalid"); + } + if ((publishedReference == null) != (publishedVersion == null)) { + throw new IllegalArgumentException( + "published reference and version must be present together"); + } + if ((state == PosterImageUploadIntentState.PENDING + || state == PosterImageUploadIntentState.ABORT_AUTHORIZED) + && (stageHandle == null || handoffFence == null)) { + throw new IllegalArgumentException("pending intent requires exact staged handoff evidence"); + } + if ((state == PosterImageUploadIntentState.READY + || state == PosterImageUploadIntentState.SUPERSEDED) + && publishedReference == null) { + throw new IllegalArgumentException("published terminal intent requires exact reference"); + } + if (state == PosterImageUploadIntentState.RESERVED + && (stageHandle != null || publishedReference != null)) { + throw new IllegalArgumentException( + "reserved intent cannot contain staged or published state"); + } + if (lastErrorCode != null + && (lastErrorCode.isBlank() + || lastErrorCode.length() > 64 + || !lastErrorCode.matches("[A-Z0-9_]+"))) { + throw new IllegalArgumentException("last error code is invalid"); + } + } + + public static PosterImageUploadIntent reserve( + UUID id, + PosterId posterId, + ObjectOperationKey operation, + PosterImageIdempotencyScopeDigest scope, + RequestFingerprint fingerprint, + long expectedPosterVersion, + ObjectContentIdentity contentIdentity, + ObjectMediaType mediaType, + Instant now, + Instant expiresAt) { + if (!expiresAt.isAfter(now)) { + throw new IllegalArgumentException("intent expiry must be in the future"); + } + return new PosterImageUploadIntent( + id, + posterId, + operation, + scope, + fingerprint, + expectedPosterVersion, + contentIdentity, + mediaType, + PosterImageUploadIntentState.RESERVED, + null, + null, + null, + null, + null, + null, + null, + null, + null, + expiresAt, + 0, + null, + now, + now, + 0); + } + + public PosterImageUploadIntent pending( + long expectedRevision, + ObjectStageHandle stage, + ObjectVersionToken version, + String claimantDigest, + long fence, + Instant claimExpiry, + Instant now) { + require(expectedRevision, PosterImageUploadIntentState.RESERVED); + return copy( + PosterImageUploadIntentState.PENDING, + stage, + version, + claimantDigest, + fence, + claimExpiry, + null, + null, + null, + null, + attemptCount, + null, + now); + } + + public PosterImageUploadIntent ready( + long expectedRevision, + PosterImageReference reference, + ObjectVersionToken version, + PosterImageReference replaced, + Instant now) { + require(expectedRevision, PosterImageUploadIntentState.PENDING); + return copy( + PosterImageUploadIntentState.READY, + stageHandle, + exactVersion, + handoffClaimantDigest, + handoffFence, + handoffExpiresAt, + reference, + version, + replaced, + null, + attemptCount, + null, + now); + } + + public PosterImageUploadIntent claim( + long expectedRevision, String claimantDigest, Instant now, java.time.Duration leaseDuration) { + require(expectedRevision, PosterImageUploadIntentState.PENDING); + if (leaseDuration == null || leaseDuration.isZero() || leaseDuration.isNegative()) { + throw new IllegalArgumentException("upload intent claim duration is invalid"); + } + if (handoffExpiresAt != null + && handoffExpiresAt.isAfter(now) + && !handoffClaimantDigest.equals(claimantDigest)) { + throw conflict(); + } + long nextFence = handoffFence == null ? 1 : handoffFence + 1; + return copy( + state, + stageHandle, + exactVersion, + claimantDigest, + nextFence, + now.plus(leaseDuration), + publishedReference, + publishedVersion, + replacedReference, + retryAt, + attemptCount, + lastErrorCode, + now); + } + + public PosterImageUploadIntent superseded( + long expectedRevision, + PosterImageReference losingReference, + ObjectVersionToken losingVersion, + Instant now) { + require(expectedRevision, PosterImageUploadIntentState.PENDING); + return copy( + PosterImageUploadIntentState.SUPERSEDED, + stageHandle, + exactVersion, + handoffClaimantDigest, + handoffFence, + handoffExpiresAt, + losingReference, + losingVersion, + replacedReference, + null, + attemptCount, + null, + now); + } + + public PosterImageUploadIntent authorizeAbort(long expectedRevision, Instant now) { + if (revision != expectedRevision + || (state != PosterImageUploadIntentState.PENDING + && state != PosterImageUploadIntentState.ABORT_AUTHORIZED) + || (state == PosterImageUploadIntentState.PENDING + && handoffExpiresAt != null + && handoffExpiresAt.isAfter(now))) { + throw conflict(); + } + if (state == PosterImageUploadIntentState.ABORT_AUTHORIZED) { + return this; + } + return copy( + PosterImageUploadIntentState.ABORT_AUTHORIZED, + stageHandle, + exactVersion, + handoffClaimantDigest, + handoffFence, + handoffExpiresAt, + null, + null, + replacedReference, + null, + attemptCount, + null, + now); + } + + public PosterImageUploadIntent aborted(long expectedRevision, Instant now) { + require(expectedRevision, PosterImageUploadIntentState.ABORT_AUTHORIZED); + return copy( + PosterImageUploadIntentState.ABORTED, + stageHandle, + exactVersion, + handoffClaimantDigest, + handoffFence, + handoffExpiresAt, + null, + null, + replacedReference, + null, + attemptCount, + null, + now); + } + + private PosterImageUploadIntent copy( + PosterImageUploadIntentState next, + ObjectStageHandle nextStage, + ObjectVersionToken nextExactVersion, + String nextClaimant, + Long nextFence, + Instant nextClaimExpiry, + PosterImageReference nextPublished, + ObjectVersionToken nextPublishedVersion, + PosterImageReference nextReplaced, + Instant nextRetry, + int nextAttempts, + String nextError, + Instant now) { + return new PosterImageUploadIntent( + id, + posterId, + operationKey, + idempotencyScope, + requestFingerprint, + expectedPosterVersion, + contentIdentity, + mediaType, + next, + nextStage, + nextExactVersion, + nextClaimant, + nextFence, + nextClaimExpiry, + nextPublished, + nextPublishedVersion, + nextReplaced, + nextRetry, + expiresAt, + nextAttempts, + nextError, + createdAt, + now, + revision + 1); + } + + private void require(long expectedRevision, PosterImageUploadIntentState expectedState) { + if (revision != expectedRevision || state != expectedState) { + throw conflict(); + } + } + + private PosterImageUploadIntentConflictException conflict() { + return new PosterImageUploadIntentConflictException( + "poster image upload intent revision or state conflicts"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java new file mode 100644 index 0000000..8f0fb80 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import java.time.Instant; + +/** Bounded worker lease returned from an upload-intent CAS claim. */ +public record PosterImageUploadIntentClaim( + PosterImageUploadIntent intent, long fence, Instant expiresAt) { + + public PosterImageUploadIntentClaim { + if (intent == null || fence < 1 || expiresAt == null) { + throw new IllegalArgumentException("poster image upload claim is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java new file mode 100644 index 0000000..14779e7 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java @@ -0,0 +1,9 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +/** Optimistic revision/state/fence conflict for a durable Poster image operation. */ +public final class PosterImageUploadIntentConflictException extends RuntimeException { + + public PosterImageUploadIntentConflictException(String message) { + super(message); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java new file mode 100644 index 0000000..77e0f7d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +/** Durable publication queue state; READY/SUPERSEDED/ABORTED/FAILED are terminal. */ +public enum PosterImageUploadIntentState { + RESERVED, + PENDING, + READY, + SUPERSEDED, + ABORT_AUTHORIZED, + ABORTED, + FAILED; + + public boolean terminal() { + return this == READY || this == SUPERSEDED || this == ABORTED || this == FAILED; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java new file mode 100644 index 0000000..d74d767 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java @@ -0,0 +1,26 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Optional; + +/** Persistence boundary for the canonical durable publication queue. */ +public interface PosterImageUploadIntentStorePort { + + PosterImageUploadIntent createOrRead(PosterImageUploadIntent proposed); + + Optional findByOperation(ObjectOperationKey operation); + + Optional findByAnyScopeDigest( + Collection retainedDigests); + + Optional findReadyByPublishedReference(PosterImageReference reference); + + PosterImageUploadIntent compareAndSet(long expectedRevision, PosterImageUploadIntent replacement); + + Optional claimPending( + String claimantDigest, Instant now, Duration leaseDuration); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java new file mode 100644 index 0000000..b98ee97 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java @@ -0,0 +1,15 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.query.Query; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +public record GetPosterImagePublicationStatusQuery(PosterId posterId, ObjectOperationId operationId) + implements Query { + + public GetPosterImagePublicationStatusQuery { + if (posterId == null || operationId == null) { + throw new IllegalArgumentException("Poster image status query is invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java new file mode 100644 index 0000000..6e5cbe3 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java @@ -0,0 +1,27 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +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 legacy-use-case assembly; endpoint controllers are mode-conditional components. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PosterImageApiSettings.class) +public class PosterImageApiConfig { + + @Bean + @ConditionalOnProperty(prefix = "app.poster-image.api", name = "mode", havingValue = "legacy") + public UploadPosterImageUseCase legacyUploadPosterImageUseCase( + PosterRepository repository, + ObjectStoragePort objectStorage, + PosterEventPublisher events, + TransactionPort transactions) { + return new UploadPosterImageUseCase(repository, objectStorage, events, transactions); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java new file mode 100644 index 0000000..47e2134 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Mutually exclusive image endpoint mode. */ +@ConfigurationProperties(prefix = "app.poster-image.api") +public record PosterImageApiSettings(Mode mode) { + + public PosterImageApiSettings { + mode = mode == null ? Mode.DISABLED : mode; + } + + public enum Mode { + DISABLED, + LEGACY, + PUBLICATION + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java new file mode 100644 index 0000000..8759692 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java @@ -0,0 +1,110 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyKeyEpochPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigesterPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +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 external-file key-ring composition for the canonical publication profile. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PosterImageIdempotencyKeyRingSettings.class) +public class PosterImageIdempotencyConfig { + + private static final long MAXIMUM_KEY_RING_BYTES = 16 * 1024; + + @Bean + @ConditionalOnProperty( + prefix = "app.poster-image.idempotency", + name = "enabled", + havingValue = "true") + public PosterImageIdempotencyScopeDigesterPort posterImageIdempotencyScopeDigester( + PosterImageIdempotencyKeyRingSettings settings, PosterImageIdempotencyKeyEpochPort epoch) { + Map keys = load(settings.keyRingPath()); + if (!keys.containsKey(settings.bootstrapActiveKeyVersion())) { + throw new IllegalArgumentException("bootstrap-active key is absent from key ring"); + } + return new HmacPosterImageIdempotencyScopeDigester(epoch::activeVersionForReservation, keys); + } + + @Bean + @ConditionalOnProperty( + prefix = "app.poster-image.idempotency", + name = "enabled", + havingValue = "true") + public PosterImageSanitizedIdempotencyContextFactory + posterImageSanitizedIdempotencyContextFactory( + PosterImageIdempotencyScopeDigesterPort digester) { + return new PosterImageSanitizedIdempotencyContextFactory(digester); + } + + private static Map load(Path configured) { + Path path = configured.toAbsolutePath().normalize(); + try { + if (Files.isSymbolicLink(path) + || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) + || Files.size(path) > MAXIMUM_KEY_RING_BYTES) { + throw invalid(); + } + rejectBroadPermissions(path); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + properties.load(input); + } + Map keys = new LinkedHashMap<>(); + for (String name : properties.stringPropertyNames()) { + if (!name.startsWith("key.")) { + throw invalid(); + } + String version = name.substring("key.".length()); + if (!version.matches("[a-z0-9][a-z0-9._-]{0,63}")) { + throw invalid(); + } + byte[] decoded = Base64.getDecoder().decode(properties.getProperty(name)); + if (decoded.length < 32 || decoded.length > 128) { + throw invalid(); + } + keys.put(version, decoded); + } + if (keys.isEmpty()) { + throw invalid(); + } + return Map.copyOf(keys); + } catch (IOException | IllegalArgumentException exception) { + throw new IllegalStateException( + "Poster image idempotency key ring could not be loaded safely", exception); + } + } + + private static void rejectBroadPermissions(Path path) throws IOException { + try { + Set permissions = Files.getPosixFilePermissions(path); + if (permissions.contains(PosixFilePermission.GROUP_WRITE) + || permissions.contains(PosixFilePermission.OTHERS_WRITE) + || permissions.contains(PosixFilePermission.OTHERS_READ) + || permissions.contains(PosixFilePermission.OTHERS_EXECUTE)) { + throw invalid(); + } + } catch (UnsupportedOperationException ignored) { + // Non-POSIX platforms still receive no-follow, regular-file, and size checks. + } + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("Poster image idempotency key ring is invalid"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java new file mode 100644 index 0000000..5fdb29a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import java.nio.file.Path; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Disabled-by-default external key-ring location; key material is never an inline property. */ +@ConfigurationProperties(prefix = "app.poster-image.idempotency") +public record PosterImageIdempotencyKeyRingSettings( + boolean enabled, Path keyRingPath, String bootstrapActiveKeyVersion) { + + public PosterImageIdempotencyKeyRingSettings { + if (enabled + && (keyRingPath == null + || bootstrapActiveKeyVersion == null + || !bootstrapActiveKeyVersion.matches("[a-z0-9][a-z0-9._-]{0,63}"))) { + throw new IllegalArgumentException( + "enabled Poster image idempotency requires key-ring path and active version"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java new file mode 100644 index 0000000..95524c5 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java @@ -0,0 +1,156 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; +import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort; +import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; +import dev.caskeleton.sample.portfolio.application.poster.AbortPosterImageUploadUseCase; +import dev.caskeleton.sample.portfolio.application.poster.GetPosterImagePublicationStatusUseCase; +import dev.caskeleton.sample.portfolio.application.poster.PublishPosterImageUseCase; +import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; +import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageUploadUseCase; +import dev.caskeleton.sample.portfolio.application.poster.RetirePosterImageUseCase; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageAttachmentCompareAndSetPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationPolicy; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +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 canonical sample composition; absent from disabled and legacy-only profiles. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PosterImagePublicationSettings.class) +@ConditionalOnProperty( + prefix = "app.poster-image.publication", + name = "enabled", + havingValue = "true") +public class PosterImageObjectStorageConfig { + + @Bean + public PosterImagePublicationPolicy posterImagePublicationPolicy( + PosterImagePublicationSettings settings) { + return new PosterImagePublicationPolicy(settings.maximumBytes()); + } + + @Bean + public PublishPosterImageUseCase publishPosterImageUseCase( + PosterImageUploadIntentStorePort intents, + PosterImageSanitizedIdempotencyContextFactory sanitized, + PosterImageOperationIdFactory ids, + IdempotencyExecutor idempotency, + TransactionPort transactions, + StagedObjectPublicationPort publications, + ObjectPublicationHandoffPort handoffs, + PosterImagePublicationEventPublisher events, + PosterImagePublicationPolicy policy, + PosterImagePublicationSettings settings, + Clock clock) { + return new PublishPosterImageUseCase( + intents, + sanitized, + ids, + idempotency, + transactions, + publications, + handoffs, + events, + policy, + ObjectDestinationId.of(settings.destination()), + ObjectOperationEpoch.of(settings.operationEpoch()), + settings.intentTtl(), + settings.handoffLease(), + clock); + } + + @Bean + public GetPosterImagePublicationStatusUseCase getPosterImagePublicationStatusUseCase( + PosterImageUploadIntentStorePort intents, + TransactionPort transactions, + PosterImagePublicationSettings settings) { + return new GetPosterImagePublicationStatusUseCase( + intents, + transactions, + ObjectDestinationId.of(settings.destination()), + ObjectOperationEpoch.of(settings.operationEpoch())); + } + + @Bean + public ReconcilePosterImageUploadUseCase reconcilePosterImageUploadUseCase( + PosterImageUploadIntentStorePort uploads, + PosterImageRetirementIntentStorePort retirements, + PosterImageAttachmentCompareAndSetPort attachments, + PosterImageOperationIdFactory ids, + PosterImagePublicationEventPublisher events, + TransactionPort transactions, + StagedObjectPublicationPort publications, + ObjectPublicationHandoffPort handoffs, + PosterImagePublicationSettings settings, + Clock clock) { + return new ReconcilePosterImageUploadUseCase( + uploads, + retirements, + attachments, + ids, + events, + transactions, + publications, + handoffs, + ObjectDestinationId.of(settings.destination()), + ObjectOperationEpoch.of(settings.operationEpoch()), + settings.retirementRetention(), + clock); + } + + @Bean + public AbortPosterImageUploadUseCase abortPosterImageUploadUseCase( + PosterImageUploadIntentStorePort intents, + TransactionPort transactions, + ObjectPublicationHandoffPort handoffs, + StagedObjectPublicationPort publications, + PosterImagePublicationSettings settings, + Clock clock) { + return new AbortPosterImageUploadUseCase( + intents, + transactions, + handoffs, + publications, + ObjectDestinationId.of(settings.destination()), + ObjectOperationEpoch.of(settings.operationEpoch()), + clock); + } + + @Bean + public RetirePosterImageUseCase retirePosterImageUseCase( + PosterImageRetirementIntentStorePort retirements, + PosterImageUploadIntentStorePort uploads, + PosterImageOperationIdFactory ids, + TransactionPort transactions, + Clock clock) { + return new RetirePosterImageUseCase(retirements, uploads, ids, transactions, clock); + } + + @Bean + public ReconcilePosterImageRetirementUseCase reconcilePosterImageRetirementUseCase( + PosterImageRetirementIntentStorePort retirements, + TransactionPort transactions, + ObjectRetirementPort objects, + PosterImagePublicationSettings settings, + Clock clock) { + return new ReconcilePosterImageRetirementUseCase( + retirements, + transactions, + objects, + ObjectDestinationId.of(settings.destination()), + ObjectOperationEpoch.of(settings.operationEpoch()), + clock); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java new file mode 100644 index 0000000..ec99f77 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java @@ -0,0 +1,36 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Bounded canonical publication and reconciliation settings. */ +@ConfigurationProperties(prefix = "app.poster-image.publication") +public record PosterImagePublicationSettings( + boolean enabled, + String destination, + String operationEpoch, + long maximumBytes, + Duration intentTtl, + Duration handoffLease, + Duration retirementRetention) { + + public PosterImagePublicationSettings { + destination = destination == null || destination.isBlank() ? "poster" : destination; + operationEpoch = + operationEpoch == null || operationEpoch.isBlank() ? "poster-image-v1" : operationEpoch; + maximumBytes = maximumBytes == 0 ? 10L * 1024 * 1024 : maximumBytes; + intentTtl = intentTtl == null ? Duration.ofHours(24) : intentTtl; + handoffLease = handoffLease == null ? Duration.ofMinutes(2) : handoffLease; + retirementRetention = retirementRetention == null ? Duration.ofDays(7) : retirementRetention; + if (maximumBytes < 1 + || intentTtl.isZero() + || intentTtl.isNegative() + || intentTtl.compareTo(Duration.ofHours(72)) > 0 + || handoffLease.isZero() + || handoffLease.isNegative() + || handoffLease.compareTo(Duration.ofHours(1)) > 0 + || retirementRetention.isNegative()) { + throw new IllegalArgumentException("Poster image publication settings are invalid"); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java new file mode 100644 index 0000000..3de564b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java @@ -0,0 +1,23 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; +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 worker composition; disabled mode creates no job, thread, or provider call. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PosterImageRetirementSettings.class) +@ConditionalOnProperty( + prefix = "app.poster-image.retirement", + name = "enabled", + havingValue = "true") +public class PosterImageRetirementConfig { + + @Bean + public PosterImageRetirementJob posterImageRetirementJob( + ReconcilePosterImageRetirementUseCase useCase, PosterImageRetirementSettings settings) { + return new PosterImageRetirementJob(useCase, settings); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java new file mode 100644 index 0000000..e23d00a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java @@ -0,0 +1,42 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageRetirementCommand; +import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; +import org.springframework.scheduling.annotation.Scheduled; + +/** Bounded scheduler adapter; all DB/object behavior remains behind the application use case. */ +public final class PosterImageRetirementJob { + + private final ReconcilePosterImageRetirementUseCase useCase; + private final PosterImageRetirementSettings settings; + + public PosterImageRetirementJob( + ReconcilePosterImageRetirementUseCase useCase, PosterImageRetirementSettings settings) { + this.useCase = useCase; + this.settings = settings; + } + + @Scheduled( + fixedDelayString = "${app.poster-image.retirement.fixed-delay:30s}", + initialDelayString = "${app.poster-image.retirement.initial-delay:30s}") + public int runOnce() { + int handled = 0; + for (int index = 0; index < settings.batchSize(); index++) { + var result = + useCase.handle( + new ReconcilePosterImageRetirementCommand( + settings.claimantDigest(), + settings.claimLease(), + settings.retryBackoff(), + CallBudget.fromNow(settings.operationTimeout()), + CancellationView.never())); + if (!result.workFound()) { + break; + } + handled++; + } + return handled; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java new file mode 100644 index 0000000..2fd3716 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java @@ -0,0 +1,49 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Bounded, disabled-by-default logical retirement worker settings. */ +@ConfigurationProperties(prefix = "app.poster-image.retirement") +public record PosterImageRetirementSettings( + boolean enabled, + String claimantDigest, + int batchSize, + Duration claimLease, + Duration retryBackoff, + Duration operationTimeout, + Duration fixedDelay, + Duration initialDelay, + Duration shutdownGrace) { + + public PosterImageRetirementSettings { + claimantDigest = claimantDigest == null ? "" : claimantDigest; + batchSize = batchSize == 0 ? 25 : batchSize; + claimLease = claimLease == null ? Duration.ofMinutes(2) : claimLease; + retryBackoff = retryBackoff == null ? Duration.ofMinutes(1) : retryBackoff; + operationTimeout = operationTimeout == null ? Duration.ofSeconds(30) : operationTimeout; + fixedDelay = fixedDelay == null ? Duration.ofSeconds(30) : fixedDelay; + initialDelay = initialDelay == null ? Duration.ofSeconds(30) : initialDelay; + shutdownGrace = shutdownGrace == null ? Duration.ofSeconds(30) : shutdownGrace; + if (enabled && !claimantDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "enabled Poster image retirement requires a claimant digest"); + } + if (batchSize < 1 + || batchSize > 500 + || invalid(claimLease, Duration.ofHours(1)) + || retryBackoff.isNegative() + || retryBackoff.compareTo(Duration.ofDays(1)) > 0 + || invalid(operationTimeout, Duration.ofMinutes(5)) + || invalid(fixedDelay, Duration.ofHours(1)) + || initialDelay.isNegative() + || initialDelay.compareTo(Duration.ofHours(1)) > 0 + || invalid(shutdownGrace, Duration.ofMinutes(5))) { + throw new IllegalArgumentException("Poster image retirement settings are invalid"); + } + } + + private static boolean invalid(Duration value, Duration maximum) { + return value.isZero() || value.isNegative() || value.compareTo(maximum) > 0; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java index fde89e9..b75de62 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java @@ -2,7 +2,9 @@ package dev.caskeleton.sample.portfolio.bootstrap.persistence; import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository; import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository; import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; import jakarta.persistence.EntityManager; @@ -39,6 +41,11 @@ public class SamplePostgreSqlPersistenceConfig { return new PostgreSqlOutboxClaimRepository(entityManager); } + @Bean + public IdempotencyClaimRepository idempotencyClaimRepository(EntityManager entityManager) { + return new PostgreSqlIdempotencyClaimRepository(entityManager); + } + @Bean public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() { return new PostgreSqlSqlStateErrorMapping(); diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java new file mode 100644 index 0000000..a819a0b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +/** Existing raw-key attachment retained only for the approved dual-read window. */ +public record LegacyPosterImageAttachment(String key) implements PosterImageAttachment { + + public LegacyPosterImageAttachment { + if (key == null || key.isBlank() || key.length() > 512) { + throw new IllegalArgumentException("legacy poster image key is invalid"); + } + } + + @Override + public String compatibilityValue() { + return key; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java index 13c7c93..5779839 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java @@ -20,7 +20,7 @@ public class Poster { private final PosterId id; private String title; private String caption; - private String imageKey; // object-storage key; null until an image is uploaded + private PosterImageAttachment imageAttachment; private PosterStatus status; /** Optimistic-lock version; {@code null} until the store persists it. */ @@ -30,13 +30,13 @@ public class Poster { PosterId id, String title, String caption, - String imageKey, + PosterImageAttachment imageAttachment, PosterStatus status, Long version) { this.id = Objects.requireNonNull(id, "id is required"); this.title = requireValidTitle(title); this.caption = caption == null ? "" : caption; - this.imageKey = imageKey; // nullable by design + this.imageAttachment = imageAttachment; this.status = Objects.requireNonNull(status, "status is required"); this.version = version; } @@ -57,7 +57,36 @@ public class Poster { String imageKey, PosterStatus status, Long version) { - return new Poster(id, title, caption, imageKey, status, version); + return new Poster( + id, + title, + caption, + imageKey == null ? null : new LegacyPosterImageAttachment(imageKey), + status, + version); + } + + /** + * Additive V7 dual read. Exactly one compatibility column may be populated; pending work never + * enters the aggregate. + */ + public static Poster rehydrate( + PosterId id, + String title, + String caption, + String imageKey, + String imageReference, + PosterStatus status, + Long version) { + if (imageKey != null && imageReference != null) { + throw new IllegalArgumentException( + "poster cannot contain both legacy key and published reference"); + } + PosterImageAttachment attachment = + imageReference != null + ? new PublishedPosterImageAttachment(PosterImageReference.of(imageReference)) + : imageKey == null ? null : new LegacyPosterImageAttachment(imageKey); + return new Poster(id, title, caption, attachment, status, version); } public void rename(String newTitle) { @@ -70,15 +99,17 @@ public class Poster { /** Records the object-storage key of the uploaded image. */ public void attachImage(String key) { - if (key == null || key.isBlank()) { - throw new IllegalArgumentException("imageKey is required"); - } - this.imageKey = key; + this.imageAttachment = new LegacyPosterImageAttachment(key); + } + + /** Canonical V7 writer: only a publication-finalized opaque reference can be attached. */ + public void attachPublishedImage(PosterImageReference reference) { + this.imageAttachment = new PublishedPosterImageAttachment(reference); } /** {@code DRAFT → PUBLISHED}. Requires an attached image (the domain invariant). */ public void publish() { - if (imageKey == null || imageKey.isBlank()) { + if (imageAttachment == null) { throw new PosterInvariantException(PosterInvariantException.Reason.IMAGE_REQUIRED); } transitionTo(PosterStatus.PUBLISHED); @@ -127,11 +158,21 @@ public class Poster { } public String imageKey() { - return imageKey; + return imageAttachment == null ? null : imageAttachment.compatibilityValue(); + } + + public String imageReference() { + return imageAttachment instanceof PublishedPosterImageAttachment published + ? published.reference().value() + : null; + } + + public PosterImageAttachment imageAttachment() { + return imageAttachment; } public boolean hasImage() { - return imageKey != null && !imageKey.isBlank(); + return imageAttachment != null; } public PosterStatus status() { diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java new file mode 100644 index 0000000..3134d5c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java @@ -0,0 +1,8 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +/** Explicit compatibility-window image attachment; pending work never enters the aggregate. */ +public sealed interface PosterImageAttachment + permits LegacyPosterImageAttachment, PublishedPosterImageAttachment { + + String compatibilityValue(); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java new file mode 100644 index 0000000..9c668cb --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java @@ -0,0 +1,46 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** Bounded opaque object-storage published reference with its canonical checksum. */ +public record PosterImageReference(String value) { + + public PosterImageReference { + if (value == null || value.length() > 96) { + throw invalid(); + } + String[] parts = value.split("\\.", -1); + if (parts.length != 4 + || !"osr1".equals(parts[0]) + || !parts[1].matches("[0-9a-hjkmnp-tv-z]{12}") + || !parts[2].matches("[0-9a-hjkmnp-tv-z]{26}") + || !parts[3].matches("[0-9a-f]{10}")) { + throw invalid(); + } + String payload = parts[0] + "." + parts[1] + "." + parts[2]; + if (!sha256Hex(payload).substring(0, 10).equals(parts[3])) { + throw invalid(); + } + } + + public static PosterImageReference of(String value) { + return new PosterImageReference(value); + } + + private 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); + } + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("poster image reference is invalid"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java new file mode 100644 index 0000000..2eb1b3f --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java @@ -0,0 +1,17 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import java.util.Objects; + +/** Canonical READY attachment containing only an opaque published reference. */ +public record PublishedPosterImageAttachment(PosterImageReference reference) + implements PosterImageAttachment { + + public PublishedPosterImageAttachment { + Objects.requireNonNull(reference, "reference must be non-null"); + } + + @Override + public String compatibilityValue() { + return reference.value(); + } +} diff --git a/src/sample-portfolio/src/main/resources/application.yml b/src/sample-portfolio/src/main/resources/application.yml index 8910d12..4c8eec1 100644 --- a/src/sample-portfolio/src/main/resources/application.yml +++ b/src/sample-portfolio/src/main/resources/application.yml @@ -215,6 +215,13 @@ ca-skeleton: # Optional integration adapter toggles (all disabled for lean demo). # --------------------------------------------------------------------------- app: + poster-image: + api: + mode: disabled + idempotency: + enabled: false + publication: + enabled: false cache: redis: enabled: ${APP_CACHE_REDIS_ENABLED:false} @@ -260,3 +267,22 @@ app: wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s} permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10} response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB} + +--- +spring: + config: + activate: + on-profile: local + +# Temporary sample-only compatibility seam. The production capability stays disabled until the +# sample consumer migrates from the deprecated byte[] contract. +app: + poster-image: + api: + mode: legacy + object-storage: + enabled: false + legacy: + enabled: true + backend: filesystem + base-path: ./.data/objectstorage diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql new file mode 100644 index 0000000..0f11eac --- /dev/null +++ b/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql @@ -0,0 +1,128 @@ +-- Additive, forward-only Poster image publication state. +-- +-- image_key remains for the bounded legacy dual-read window. New READY writes use only the +-- opaque image_reference. Intent tables deliberately have no cascading Poster foreign key: +-- publication and retirement evidence must survive aggregate deletion and process restarts. + +ALTER TABLE poster + ADD COLUMN image_reference varchar(96); + +ALTER TABLE poster + ADD CONSTRAINT ck_poster_single_image_locator + CHECK (image_key IS NULL OR image_reference IS NULL); + +CREATE TABLE poster_image_upload_intent ( + id uuid NOT NULL, + poster_id uuid NOT NULL, + operation_destination varchar(64) NOT NULL, + operation_epoch varchar(64) NOT NULL, + operation_id varchar(64) NOT NULL, + hmac_key_version varchar(64) NOT NULL, + idempotency_scope_digest varchar(43) NOT NULL, + request_fingerprint varchar(64) NOT NULL, + expected_poster_version bigint NOT NULL, + content_length bigint NOT NULL, + content_sha256 varchar(44) NOT NULL, + media_type varchar(127) NOT NULL, + state varchar(32) NOT NULL, + stage_handle varchar(96), + exact_version varchar(96), + handoff_claimant_digest varchar(64), + handoff_fence bigint, + handoff_expires_at timestamptz, + published_reference varchar(96), + published_version varchar(96), + replaced_reference varchar(96), + retry_at timestamptz, + expires_at timestamptz NOT NULL, + attempt_count integer NOT NULL DEFAULT 0, + last_error_code varchar(64), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + revision bigint NOT NULL DEFAULT 0, + CONSTRAINT pk_poster_image_upload_intent PRIMARY KEY (id), + CONSTRAINT uq_poster_image_upload_operation + UNIQUE (operation_destination, operation_epoch, operation_id), + CONSTRAINT uq_poster_image_upload_scope + UNIQUE (hmac_key_version, idempotency_scope_digest), + CONSTRAINT ck_poster_image_upload_content_length CHECK (content_length >= 0), + CONSTRAINT ck_poster_image_upload_expected_version CHECK (expected_poster_version >= 0), + CONSTRAINT ck_poster_image_upload_attempt_count CHECK (attempt_count >= 0), + CONSTRAINT ck_poster_image_upload_revision CHECK (revision >= 0), + CONSTRAINT ck_poster_image_upload_handoff + CHECK ( + (handoff_claimant_digest IS NULL + AND handoff_fence IS NULL + AND handoff_expires_at IS NULL) + OR + (handoff_claimant_digest IS NOT NULL + AND handoff_fence > 0 + AND handoff_expires_at IS NOT NULL) + ), + CONSTRAINT ck_poster_image_upload_stage + CHECK ( + (stage_handle IS NULL AND exact_version IS NULL) + OR + (stage_handle IS NOT NULL AND exact_version IS NOT NULL) + ), + CONSTRAINT ck_poster_image_upload_publication + CHECK ( + (published_reference IS NULL AND published_version IS NULL) + OR + (published_reference IS NOT NULL AND published_version IS NOT NULL) + ) +); + +CREATE INDEX ix_poster_image_upload_claim + ON poster_image_upload_intent (state, retry_at, handoff_expires_at); +CREATE INDEX ix_poster_image_upload_expiry + ON poster_image_upload_intent (expires_at); +CREATE INDEX ix_poster_image_upload_poster + ON poster_image_upload_intent (poster_id, created_at); + +CREATE TABLE poster_image_retirement_intent ( + id uuid NOT NULL, + poster_id uuid, + operation_id varchar(64) NOT NULL, + image_reference varchar(96) NOT NULL, + exact_version varchar(96) NOT NULL, + state varchar(32) NOT NULL, + claimant_digest varchar(64), + claim_fence bigint, + claim_expires_at timestamptz, + retain_until timestamptz NOT NULL, + retry_at timestamptz, + attempt_count integer NOT NULL DEFAULT 0, + last_error_code varchar(64), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + revision bigint NOT NULL DEFAULT 0, + CONSTRAINT pk_poster_image_retirement_intent PRIMARY KEY (id), + CONSTRAINT uq_poster_image_retirement_operation UNIQUE (operation_id), + CONSTRAINT uq_poster_image_retirement_exact + UNIQUE (image_reference, exact_version), + CONSTRAINT ck_poster_image_retirement_attempt_count CHECK (attempt_count >= 0), + CONSTRAINT ck_poster_image_retirement_revision CHECK (revision >= 0), + CONSTRAINT ck_poster_image_retirement_claim + CHECK ( + (claimant_digest IS NULL AND claim_fence IS NULL AND claim_expires_at IS NULL) + OR + (claimant_digest IS NOT NULL + AND claim_fence > 0 + AND claim_expires_at IS NOT NULL) + ) +); + +CREATE INDEX ix_poster_image_retirement_claim + ON poster_image_retirement_intent (state, retry_at, claim_expires_at); +CREATE INDEX ix_poster_image_retirement_retention + ON poster_image_retirement_intent (retain_until); + +CREATE TABLE poster_image_idempotency_key_epoch ( + epoch_id varchar(64) NOT NULL, + active_key_version varchar(64) NOT NULL, + updated_at timestamptz NOT NULL, + revision bigint NOT NULL DEFAULT 0, + CONSTRAINT pk_poster_image_idempotency_key_epoch PRIMARY KEY (epoch_id), + CONSTRAINT ck_poster_image_idempotency_epoch_revision CHECK (revision >= 0) +); diff --git a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java new file mode 100644 index 0000000..eaea8b6 --- /dev/null +++ b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java @@ -0,0 +1,30 @@ +package dev.caskeleton.sample.portfolio.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class PosterImageIdempotencyRotationQualificationTest { + + @Test + void overlapFirstActivationKeepsOldDigestDiscoverable() { + var active = new AtomicReference<>("key-v1"); + var keys = new LinkedHashMap(); + keys.put("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + keys.put("key-v2", "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.UTF_8)); + var digester = new HmacPosterImageIdempotencyScopeDigester(active::get, keys); + + var before = digester.digest("tenant", "principal", "use-case", "client-key"); + active.set("key-v2"); + var after = digester.digest("tenant", "principal", "use-case", "client-key"); + + assertThat(before.keyVersion()).isEqualTo("key-v1"); + assertThat(after.keyVersion()).isEqualTo("key-v2"); + assertThat(digester.retainedDigests("tenant", "principal", "use-case", "client-key")) + .contains(before, after); + } +} diff --git a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java new file mode 100644 index 0000000..877612a --- /dev/null +++ b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.sample.portfolio.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers +class PosterImageRetirementQualificationTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.4-alpine"); + + @BeforeEach + void migrateFreshDatabase() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration/postgresql", "classpath:db/sample-migration") + .cleanDisabled(false) + .load() + .clean(); + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration/postgresql", "classpath:db/sample-migration") + .load() + .migrate(); + } + + @Test + void exactRetirementWorkSurvivesPosterDeletionWithoutCascade() throws Exception { + UUID posterId = UUID.randomUUID(); + UUID intentId = UUID.randomUUID(); + try (Connection connection = connection()) { + insertPoster(connection, posterId); + try (PreparedStatement insert = + connection.prepareStatement( + """ + insert into poster_image_retirement_intent ( + id, poster_id, operation_id, image_reference, exact_version, + state, retain_until, attempt_count, created_at, updated_at, revision + ) values (?, ?, ?, ?, ?, 'PENDING', ?, 0, ?, ?, 0) + """)) { + insert.setObject(1, intentId); + insert.setObject(2, posterId); + insert.setString(3, "retire-operation-1"); + insert.setString(4, "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + insert.setString(5, "osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); + OffsetDateTime now = OffsetDateTime.parse("2026-07-29T00:00:00Z"); + insert.setObject(6, now); + insert.setObject(7, now); + insert.setObject(8, now); + assertThat(insert.executeUpdate()).isEqualTo(1); + } + try (PreparedStatement delete = + connection.prepareStatement("delete from poster where id = ?")) { + delete.setObject(1, posterId); + assertThat(delete.executeUpdate()).isEqualTo(1); + } + try (PreparedStatement select = + connection.prepareStatement( + "select poster_id, state from poster_image_retirement_intent where id = ?")) { + select.setObject(1, intentId); + try (ResultSet result = select.executeQuery()) { + assertThat(result.next()).isTrue(); + assertThat(result.getObject(1, UUID.class)).isEqualTo(posterId); + assertThat(result.getString(2)).isEqualTo("PENDING"); + } + } + } + } + + private Connection connection() throws Exception { + return DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + } + + private static void insertPoster(Connection connection, UUID posterId) throws Exception { + try (PreparedStatement statement = + connection.prepareStatement( + """ + insert into poster ( + id, title, caption, image_reference, status, version, + created_at, updated_at, created_by, updated_by + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + OffsetDateTime now = OffsetDateTime.parse("2026-07-29T00:00:00Z"); + statement.setObject(1, posterId); + statement.setString(2, "canonical"); + statement.setString(3, "retirement qualification"); + statement.setString(4, "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + statement.setString(5, "DRAFT"); + statement.setLong(6, 0); + statement.setObject(7, now); + statement.setObject(8, now); + statement.setString(9, "qualification"); + statement.setString(10, "qualification"); + assertThat(statement.executeUpdate()).isEqualTo(1); + } + } +} diff --git a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java new file mode 100644 index 0000000..a9350bc --- /dev/null +++ b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java @@ -0,0 +1,176 @@ +package dev.caskeleton.sample.portfolio.qualification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.OffsetDateTime; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers +class PosterImageV7MigrationQualificationTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.4-alpine"); + + @BeforeEach + void cleanDatabase() { + flyway(null).clean(); + } + + @Test + void freshTimelineCreatesAdditiveLocatorFreeIntentSchema() throws Exception { + assertThat(flyway(null).migrate().targetSchemaVersion).isEqualTo("7"); + + try (Connection connection = connection()) { + assertThat(columns(connection, "poster")).contains("image_key", "image_reference"); + assertThat(columns(connection, "poster_image_upload_intent")) + .contains( + "idempotency_scope_digest", + "request_fingerprint", + "stage_handle", + "handoff_fence", + "published_reference", + "revision") + .doesNotContain( + "idempotency_key", + "principal", + "tenant", + "payload", + "bucket", + "path", + "presigned_url"); + assertThat(columns(connection, "poster_image_retirement_intent")) + .contains("image_reference", "exact_version", "retain_until"); + assertThat(foreignKeys(connection, "poster_image_upload_intent")).isEmpty(); + assertThat(foreignKeys(connection, "poster_image_retirement_intent")).isEmpty(); + } + } + + @Test + void v6LegacyRowsAndOldBinaryProjectionSurviveV7Upgrade() throws Exception { + flyway("6").migrate(); + try (Connection connection = connection()) { + oldBinaryInsert(connection, "legacy-before-upgrade"); + } + + flyway(null).migrate(); + + try (Connection connection = connection()) { + assertThat( + scalar( + connection, "select image_key from poster where title = 'legacy-before-upgrade'")) + .isEqualTo("posters/legacy/image"); + assertThat( + scalar( + connection, + "select image_reference from poster" + " where title = 'legacy-before-upgrade'")) + .isNull(); + + oldBinaryInsert(connection, "legacy-after-upgrade"); + try (PreparedStatement update = + connection.prepareStatement("update poster set caption = ? where title = ?")) { + update.setString(1, "old binary update"); + update.setString(2, "legacy-after-upgrade"); + assertThat(update.executeUpdate()).isEqualTo(1); + } + assertThat( + scalar(connection, "select caption from poster where title = 'legacy-after-upgrade'")) + .isEqualTo("old binary update"); + } + } + + private Flyway flyway(String target) { + var configuration = + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration/postgresql", "classpath:db/sample-migration") + .cleanDisabled(false); + if (target != null) { + configuration.target(target); + } + return configuration.load(); + } + + private Connection connection() throws Exception { + return DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + } + + private static List columns(Connection connection, String table) throws Exception { + try (PreparedStatement statement = + connection.prepareStatement( + "select column_name from information_schema.columns" + + " where table_schema = 'public' and table_name = ?" + + " order by ordinal_position")) { + statement.setString(1, table); + try (ResultSet result = statement.executeQuery()) { + java.util.ArrayList columns = new java.util.ArrayList<>(); + while (result.next()) { + columns.add(result.getString(1)); + } + return List.copyOf(columns); + } + } + } + + private static List foreignKeys(Connection connection, String table) throws Exception { + try (PreparedStatement statement = + connection.prepareStatement( + """ + select constraint_name + from information_schema.table_constraints + where table_schema = 'public' + and table_name = ? + and constraint_type = 'FOREIGN KEY' + """)) { + statement.setString(1, table); + try (ResultSet result = statement.executeQuery()) { + java.util.ArrayList constraints = new java.util.ArrayList<>(); + while (result.next()) { + constraints.add(result.getString(1)); + } + return List.copyOf(constraints); + } + } + } + + private static void oldBinaryInsert(Connection connection, String title) throws Exception { + try (PreparedStatement statement = + connection.prepareStatement( + """ + insert into poster ( + id, title, caption, image_key, status, version, + created_at, updated_at, created_by, updated_by + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + statement.setObject(1, java.util.UUID.randomUUID()); + statement.setString(2, title); + statement.setString(3, "legacy"); + statement.setString(4, "posters/legacy/image"); + statement.setString(5, "DRAFT"); + statement.setLong(6, 0); + statement.setObject(7, OffsetDateTime.parse("2026-07-29T00:00:00Z")); + statement.setObject(8, OffsetDateTime.parse("2026-07-29T00:00:00Z")); + statement.setString(9, "old-binary"); + statement.setString(10, "old-binary"); + assertThat(statement.executeUpdate()).isEqualTo(1); + } + } + + private static String scalar(Connection connection, String sql) throws Exception { + try (PreparedStatement statement = connection.prepareStatement(sql); + ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java index ed8e0dc..e7b3381 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java @@ -46,11 +46,12 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @WebMvcTest( - controllers = PosterController.class, + controllers = {PosterController.class, LegacyPosterImageController.class}, excludeAutoConfiguration = SecurityAutoConfiguration.class) @AutoConfigureMockMvc(addFilters = false) @Import({ PosterController.class, + LegacyPosterImageController.class, GlobalExceptionHandler.class, DomainExceptionHandler.class, EnvelopeBodyAdvice.class, @@ -60,7 +61,8 @@ import org.springframework.test.web.servlet.MockMvc; @TestPropertySource( properties = { "spring.jackson.deserialization.fail-on-unknown-properties=true", - "spring.jackson.deserialization.fail-on-null-for-primitives=true" + "spring.jackson.deserialization.fail-on-null-for-primitives=true", + "app.poster-image.api.mode=legacy" }) class PosterControllerWireTest { diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java new file mode 100644 index 0000000..f71271c --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationStatusResponse; +import dev.caskeleton.sample.portfolio.application.poster.GetPosterImagePublicationStatusUseCase; +import dev.caskeleton.sample.portfolio.application.poster.PublishPosterImageUseCase; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservation; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; + +class PosterImagePublicationControllerWireTest { + + private static final PosterId POSTER = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final ObjectOperationId OPERATION = + ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901"); + + @Test + void acceptedAndReadyResponsesExposeNoStorageLocator() { + PublishPosterImageUseCase publish = mock(PublishPosterImageUseCase.class); + GetPosterImagePublicationStatusUseCase status = + mock(GetPosterImagePublicationStatusUseCase.class); + when(publish.handle(any())) + .thenReturn(new PosterImagePublicationReservation(POSTER, OPERATION)); + ObjectContentIdentity content = + new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))); + when(status.handle(any())) + .thenReturn( + new PosterImagePublicationStatus( + POSTER, + OPERATION, + PosterImageUploadIntentState.READY, + PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"), + content, + ObjectMediaType.of("image/png"))); + PosterImagePublicationController controller = + new PosterImagePublicationController(publish, status); + var principal = new AuthenticatedPrincipal("principal-a", "a@example.test", Set.of("user")); + var file = + new MockMultipartFile( + "file", "poster.png", "image/png", "hello".getBytes(StandardCharsets.UTF_8)); + + var accepted = + controller.publish( + POSTER.value(), "client-key", 0, content.fullDigest().base64Value(), file, principal); + PosterImagePublicationStatusResponse ready = + controller.status(POSTER.value(), OPERATION.value(), principal); + + assertThat(accepted.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(accepted.getHeaders().getLocation().toString()) + .isEqualTo("/posters/" + POSTER.value() + "/imagePublications/" + OPERATION.value()); + assertThat(ready.reference()).startsWith("osr1."); + assertThat(ready.toString()) + .doesNotContain("s3://", "file://", "bucket", "presigned", "providerVersion"); + } + + @Test + void missingIdempotencyKeyIsRejectedBeforeUseCaseLookup() { + PosterImagePublicationController controller = + new PosterImagePublicationController( + mock(PublishPosterImageUseCase.class), + mock(GetPosterImagePublicationStatusUseCase.class)); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> + controller.publish( + POSTER.value(), + " ", + 0, + ObjectDigest.sha256(new byte[] {1}).base64Value(), + new MockMultipartFile("file", "x.png", "image/png", new byte[] {1}), + new AuthenticatedPrincipal("principal-a", null, Set.of()))); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java new file mode 100644 index 0000000..9b50ab5 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java @@ -0,0 +1,63 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.outbound.CallBudget; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +class MultipartObjectContentProducerTest { + + @Test + void streamsBoundedChunksAndRejectsAnyUseAfterTheRequestBoundCallback() throws Exception { + byte[] content = "hello-stream".getBytes(StandardCharsets.UTF_8); + var expected = new ObjectContentIdentity(content.length, ObjectDigest.sha256(content)); + var producer = + new MultipartObjectContentProducer( + new MockMultipartFile("file", "image.png", "image/png", content), expected); + var context = + ObjectContentProductionContext.open( + CallBudget.fromNow(Duration.ofMinutes(1)), CancellationView.never(), 4); + ByteArrayOutputStream delivered = new ByteArrayOutputStream(); + + producer.produce( + context, + (bytes, offset, length) -> { + assertThat(length).isLessThanOrEqualTo(4); + delivered.write(bytes, offset, length); + }); + context.invalidate(); + + assertThat(delivered.toByteArray()).containsExactly(content); + assertThatThrownBy( + () -> + producer.produce( + ObjectContentProductionContext.open( + CallBudget.fromNow(Duration.ofMinutes(1)), CancellationView.never(), 4), + (bytes, offset, length) -> {})) + .hasMessageContaining("already consumed"); + } + + @Test + void exactLengthOrSha256MismatchFailsClosed() { + byte[] content = "hello".getBytes(StandardCharsets.UTF_8); + var producer = + new MultipartObjectContentProducer( + new MockMultipartFile("file", content), + new ObjectContentIdentity(content.length, ObjectDigest.sha256(new byte[] {1}))); + var context = + ObjectContentProductionContext.open( + CallBudget.fromNow(Duration.ofMinutes(1)), CancellationView.never(), 8); + + assertThatThrownBy(() -> producer.produce(context, (bytes, offset, length) -> {})) + .hasMessageContaining("does not match"); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java new file mode 100644 index 0000000..178c4cb --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class HmacPosterImageIdempotencyScopeDigesterTest { + + @Test + void lengthPrefixAndTenantPresencePreventAmbiguousScopeCollisions() { + HmacPosterImageIdempotencyScopeDigester digester = + new HmacPosterImageIdempotencyScopeDigester( + "key-v1", + Map.of("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8))); + + var first = + digester.digest(null, "principal-secret-ab", "use-case-secret-c", "idempotency-secret-d"); + var ambiguous = + digester.digest(null, "principal-secret-a", "buse-case-secret-c", "idempotency-secret-d"); + var tenant = + digester.digest( + "tenant-secret-a", "principal-secret-ab", "use-case-secret-c", "idempotency-secret-d"); + + assertThat(first).isNotEqualTo(ambiguous).isNotEqualTo(tenant); + assertThat(first.toString()) + .doesNotContain("principal-secret-ab", "use-case-secret-c", "idempotency-secret-d"); + } + + @Test + void retainedVersionsSupportOverlapFirstRotationWithoutChangingTheActiveDigest() { + Map keys = new LinkedHashMap<>(); + keys.put("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + keys.put("key-v2", "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.UTF_8)); + HmacPosterImageIdempotencyScopeDigester digester = + new HmacPosterImageIdempotencyScopeDigester(() -> "key-v2", keys); + + assertThat(digester.digest(null, "p", "u", "k").keyVersion()).isEqualTo("key-v2"); + assertThat(digester.retainedDigests(null, "p", "u", "k")) + .extracting( + dev.caskeleton.sample.portfolio.application.posterimage + .PosterImageIdempotencyScopeDigest + ::keyVersion) + .containsExactly("key-v1", "key-v2"); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java new file mode 100644 index 0000000..769abbd --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java @@ -0,0 +1,102 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +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.ObjectStageHandle; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; +import dev.caskeleton.application.objectstorage.model.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +@Import({JpaConfig.class, PosterImageUploadIntentRepositoryAdapter.class}) +@TestPropertySource( + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration,classpath:db/sample-migration", + "spring.jpa.hibernate.ddl-auto=validate" + }) +class PosterImageUploadIntentRepositoryIntegrationTest { + + @Container @ServiceConnection + static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); + + @Autowired PosterImageUploadIntentRepositoryAdapter adapter; + @Autowired JdbcTemplate jdbc; + + @Test + void reserveReplayConflictAndRevisionCasUseOnlyNonReversibleScope() { + PosterImageUploadIntent reserved = adapter.createOrRead(proposed("client-request-a")); + + assertThat(reserved.state()).isEqualTo(PosterImageUploadIntentState.RESERVED); + assertThat(adapter.createOrRead(proposed("client-request-a"))).isEqualTo(reserved); + assertThatThrownBy(() -> adapter.createOrRead(proposed("different-body"))) + .isInstanceOf(PosterImageUploadIntentConflictException.class); + + PosterImageUploadIntent pending = + reserved.pending( + reserved.revision(), + ObjectStageHandle.parse("osh1.0123456789ab.0123456789abcdefghjkmnpqrs.f3226a9833"), + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"), + "a".repeat(64), + 1, + Instant.parse("2026-07-29T00:01:00Z"), + Instant.parse("2026-07-29T00:00:01Z")); + + PosterImageUploadIntent saved = adapter.compareAndSet(reserved.revision(), pending); + + assertThat(saved.state()).isEqualTo(PosterImageUploadIntentState.PENDING); + assertThat(saved.revision()).isEqualTo(1); + assertThat( + jdbc.queryForObject( + "select idempotency_scope_digest from poster_image_upload_intent", String.class)) + .doesNotContain("client-request-a", "principal-a", "tenant-a"); + } + + private static PosterImageUploadIntent proposed(String fingerprintSource) { + Instant now = Instant.parse("2026-07-29T00:00:00Z"); + return PosterImageUploadIntent.reserve( + UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), + PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), + new ObjectOperationKey( + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("epoch-v1"), + ObjectOperationId.of("publication-42")), + new PosterImageIdempotencyScopeDigest("key-v1", "A".repeat(43)), + RequestFingerprint.ofSha256(fingerprintSource.getBytes(StandardCharsets.UTF_8)), + 0, + new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))), + ObjectMediaType.of("image/png"), + now, + now.plusSeconds(3600)); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java index daa0819..ee2b064 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java @@ -6,8 +6,10 @@ import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; import dev.caskeleton.sample.portfolio.domain.poster.Poster; import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import dev.caskeleton.sample.portfolio.domain.poster.PublishedPosterImageAttachment; import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; import java.time.Clock; import java.time.Instant; @@ -98,6 +100,29 @@ class PosterRepositoryAdapterIntegrationTest { assertThat(reread.imageKey()).isEqualTo("posters/" + ID.value() + "/image"); } + @Test + void canonicalReadyReferenceUsesOnlyTheAdditiveReferenceColumn(@Autowired TestEntityManager em) { + Poster poster = sample(); + PosterImageReference reference = + PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + poster.attachPublishedImage(reference); + adapter.save(poster); + em.flush(); + em.clear(); + + Poster reread = adapter.findById(ID).orElseThrow(); + assertThat(reread.imageAttachment()).isEqualTo(new PublishedPosterImageAttachment(reference)); + assertThat(reread.imageReference()).isEqualTo(reference.value()); + Number legacyKeyCount = + (Number) + em.getEntityManager() + .createNativeQuery( + "select count(*) from poster where id = :id and image_key is not null") + .setParameter("id", java.util.UUID.fromString(ID.value())) + .getSingleResult(); + assertThat(legacyKeyCount.longValue()).isZero(); + } + @Test void findPageAppliesStatusFilterAndExistsByTitleIsCaseInsensitive( @Autowired TestEntityManager em) { diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java new file mode 100644 index 0000000..7dedb17 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java @@ -0,0 +1,138 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class DeletePosterImageRetirementTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final PosterId POSTER = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final PosterImageReference REFERENCE = + PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + private static final ObjectVersionToken VERSION = + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); + + @Test + void commitsExactRetirementWorkBeforeDeletingCanonicalPoster() { + Poster poster = + Poster.rehydrate( + POSTER, "title", "caption", null, REFERENCE.value(), PosterStatus.DRAFT, 4L); + PosterRepository posters = mock(PosterRepository.class); + when(posters.findById(POSTER)).thenReturn(Optional.of(poster)); + PosterImageUploadIntent ready = mock(PosterImageUploadIntent.class); + when(ready.publishedVersion()).thenReturn(VERSION); + PosterImageUploadIntentStorePort uploads = mock(PosterImageUploadIntentStorePort.class); + when(uploads.findReadyByPublishedReference(REFERENCE)).thenReturn(Optional.of(ready)); + CapturingRetirements retirements = new CapturingRetirements(posters); + TrackingTransactions transactions = new TrackingTransactions(); + RetirePosterImageUseCase retire = + new RetirePosterImageUseCase( + retirements, uploads, allocated(), transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + DeletePosterUseCase useCase = + new DeletePosterUseCase( + posters, + mock(PosterEventPublisher.class), + transactions, + Optional.of(retire), + Duration.ofDays(7)); + + useCase.handle(new DeletePosterCommand(POSTER)); + + assertThat(retirements.stored).isNotNull(); + assertThat(retirements.stored.posterId()).isEqualTo(POSTER); + assertThat(retirements.stored.reference()).isEqualTo(REFERENCE); + assertThat(retirements.stored.exactVersion()).isEqualTo(VERSION); + assertThat(retirements.stored.retainUntil()).isEqualTo(NOW.plus(Duration.ofDays(7))); + } + + private static PosterImageOperationIdFactory allocated() { + return () -> + new PosterImageOperationIdFactory.AllocatedOperation( + UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), + ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901")); + } + + private static final class CapturingRetirements implements PosterImageRetirementIntentStorePort { + private final PosterRepository posters; + private PosterImageRetirementIntent stored; + + private CapturingRetirements(PosterRepository posters) { + this.posters = posters; + } + + @Override + public PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed) { + assertThat(posters.findById(POSTER)).isPresent(); + stored = proposed; + return proposed; + } + + @Override + public Optional findByOperation(ObjectOperationId operation) { + return Optional.ofNullable(stored); + } + + @Override + public PosterImageRetirementIntent compareAndSet( + long expectedRevision, PosterImageRetirementIntent replacement) { + stored = replacement; + return replacement; + } + + @Override + public Optional claimReady( + String claimantDigest, Instant now, Duration leaseDuration) { + return Optional.empty(); + } + } + + private static final class TrackingTransactions implements TransactionPort { + private boolean active; + + @Override + public T inWrite(Supplier action) { + assertThat(active).isFalse(); + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return inWrite(action); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java new file mode 100644 index 0000000..59f0c13 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.LegacyPosterImageController; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; +import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; +import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import java.io.IOException; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +/** Characterizes the sample's legacy database/object-storage coupling before migration. */ +class LegacyPosterImageUploadCharacterizationTest { + + private static final PosterId POSTER_ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final String LEGACY_KEY = "posters/" + POSTER_ID.value() + "/image"; + + @Test + void uploadCallsObjectStorageInsideTheWriteTransaction() { + PosterRepository repository = mock(PosterRepository.class); + Poster poster = Poster.create(POSTER_ID, "legacy", "characterization"); + when(repository.findById(POSTER_ID)).thenReturn(Optional.of(poster)); + TrackingTransactionPort tx = new TrackingTransactionPort(); + ObjectStoragePort storage = mock(ObjectStoragePort.class); + when(storage.put(eq(LEGACY_KEY), any(byte[].class), eq("image/png"))) + .thenAnswer( + ignored -> { + assertThat(tx.inWrite()).isTrue(); + return legacyReceipt(); + }); + PosterEventPublisher events = mock(PosterEventPublisher.class); + UploadPosterImageUseCase useCase = + new UploadPosterImageUseCase(repository, storage, events, tx); + + StoredObject stored = + useCase.handle(new UploadPosterImageCommand(POSTER_ID, new byte[] {1, 2, 3}, "image/png")); + + assertThat(stored.key()).isEqualTo(LEGACY_KEY); + assertThat(poster.imageKey()).isEqualTo(LEGACY_KEY); + verify(repository).save(poster); + verify(events).publishImageAttached(any()); + } + + @Test + void controllerMaterializesMultipartBytesAndReturnsRawKeyAndLocation() throws IOException { + UploadPosterImageUseCase upload = mock(UploadPosterImageUseCase.class); + when(upload.handle(any())).thenReturn(legacyReceipt()); + LegacyPosterImageController controller = new LegacyPosterImageController(upload); + MultipartFile file = mock(MultipartFile.class); + when(file.getBytes()).thenReturn(new byte[] {1, 2, 3}); + when(file.getContentType()).thenReturn("image/png"); + + StoredObjectResponse response = controller.uploadImage(POSTER_ID.value(), file); + + verify(file).getBytes(); + assertThat(response.key()).isEqualTo(LEGACY_KEY); + assertThat(response.location()).isEqualTo("s3://legacy-bucket/" + LEGACY_KEY); + } + + @Test + void deletingAPosterDoesNotRetireItsLegacyObject() { + InMemoryLegacyStorage storage = new InMemoryLegacyStorage(); + storage.put(LEGACY_KEY, new byte[] {9}, "image/png"); + PosterRepository repository = mock(PosterRepository.class); + when(repository.existsById(POSTER_ID)).thenReturn(true); + DeletePosterUseCase delete = + new DeletePosterUseCase( + repository, mock(PosterEventPublisher.class), new TrackingTransactionPort()); + + delete.handle(new DeletePosterCommand(POSTER_ID)); + + verify(repository).deleteById(POSTER_ID); + assertThat(storage.exists(LEGACY_KEY)).isTrue(); + } + + private static StoredObject legacyReceipt() { + return new StoredObject( + LEGACY_KEY, 3, "image/png", URI.create("s3://legacy-bucket/" + LEGACY_KEY)); + } + + private static final class TrackingTransactionPort implements TransactionPort { + private boolean inWrite; + + boolean inWrite() { + return inWrite; + } + + @Override + public T inWrite(Supplier action) { + inWrite = true; + try { + return action.get(); + } finally { + inWrite = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } + + private static final class InMemoryLegacyStorage implements ObjectStoragePort { + private final Map objects = new HashMap<>(); + + @Override + public StoredObject put(String key, byte[] content, String contentType) { + objects.put(key, content.clone()); + return new StoredObject(key, content.length, contentType, URI.create("memory:///" + key)); + } + + @Override + public Optional get(String key) { + byte[] content = objects.get(key); + return content == null ? Optional.empty() : Optional.of(content.clone()); + } + + @Override + public void delete(String key) { + objects.remove(key); + } + + @Override + public boolean exists(String key) { + return objects.containsKey(key); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java new file mode 100644 index 0000000..9d02e71 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java @@ -0,0 +1,309 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +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.ObjectDigest; +import dev.caskeleton.application.objectstorage.model.ObjectHandoffReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectMediaType; +import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; +import dev.caskeleton.application.objectstorage.model.ObjectStageReceipt; +import dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt; +import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; +import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; +import dev.caskeleton.sample.portfolio.application.command.PublishPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationPolicy; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentClaim; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class PosterImagePublicationWorkflowTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final PosterId POSTER = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final ObjectContentIdentity CONTENT = + new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))); + private static final ObjectStageHandle STAGE = + ObjectStageHandle.parse("osh1.0123456789ab.0123456789abcdefghjkmnpqrs.f3226a9833"); + private static final ObjectVersionToken VERSION = + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); + + @Test + void commitsTx1BeforeProducerAndTx2OnlyAfterExactStageHandoff() { + TrackingTransactions transactions = new TrackingTransactions(); + FakeIntents intents = new FakeIntents(); + StagedObjectPublicationPort publications = mock(StagedObjectPublicationPort.class); + ObjectPublicationHandoffPort handoffs = mock(ObjectPublicationHandoffPort.class); + PosterImagePublicationEventPublisher events = mock(PosterImagePublicationEventPublisher.class); + AtomicInteger produced = new AtomicInteger(); + + when(publications.stage(any(), any())) + .thenAnswer( + invocation -> { + assertThat(transactions.active).isFalse(); + var request = + (dev.caskeleton.application.objectstorage.request.ObjectStageRequest) + invocation.getArgument(0); + var producer = + (dev.caskeleton.application.objectstorage.content.ObjectContentProducer) + invocation.getArgument(1); + ObjectContentProductionContext context = + ObjectContentProductionContext.open(request.budget(), request.cancellation(), 64); + producer.produce(context, (bytes, offset, length) -> produced.addAndGet(length)); + context.invalidate(); + return new ObjectStageReceipt( + request.operationKey(), STAGE, VERSION, CONTENT, ObjectMutationOutcome.APPLIED); + }); + when(publications.verifyIntegrity(any())) + .thenAnswer( + invocation -> { + assertThat(transactions.active).isFalse(); + var request = + (dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest) + invocation.getArgument(0); + return new ObjectVerificationReceipt( + request.operationKey(), STAGE, VERSION, CONTENT, ObjectMutationOutcome.APPLIED); + }); + when(handoffs.claimForPublication(any())) + .thenAnswer( + invocation -> { + assertThat(transactions.active).isFalse(); + var request = + (dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest) + invocation.getArgument(0); + return new ObjectHandoffReceipt( + request.operationKey(), + STAGE, + 1, + NOW.plusSeconds(60), + ObjectMutationOutcome.APPLIED); + }); + doAnswer( + invocation -> { + assertThat(transactions.active).isTrue(); + return null; + }) + .when(events) + .publishPrepared(any()); + + var digester = + new HmacPosterImageIdempotencyScopeDigester( + "key-v1", + Map.of("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8))); + PublishPosterImageUseCase useCase = + new PublishPosterImageUseCase( + intents, + new PosterImageSanitizedIdempotencyContextFactory(digester), + () -> + new PosterImageOperationIdFactory.AllocatedOperation( + UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), + ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901")), + new IdempotencyExecutor(new FakeIdempotencyStore(), CLOCK, Duration.ofHours(24)), + transactions, + publications, + handoffs, + events, + new PosterImagePublicationPolicy(1024), + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("poster-image-v1"), + Duration.ofHours(24), + Duration.ofMinutes(1), + CLOCK); + PublishPosterImageCommand command = + new PublishPosterImageCommand( + POSTER, + 0, + "tenant-a", + "principal-a", + "client-key-a", + CONTENT, + ObjectMediaType.of("image/png"), + (context, sink) -> sink.write("hello".getBytes(StandardCharsets.UTF_8), 0, 5), + CallBudget.fromNow(Duration.ofMinutes(1)), + CancellationView.never()); + + var first = useCase.handle(command); + var replay = + useCase.handle( + new PublishPosterImageCommand( + POSTER, + 0, + "tenant-a", + "principal-a", + "client-key-a", + CONTENT, + ObjectMediaType.of("image/png"), + (context, sink) -> { + throw new AssertionError("producer must not replay after PENDING"); + }, + command.budget(), + command.cancellation())); + + assertThat(first).isEqualTo(replay); + assertThat(produced).hasValue(5); + assertThat(intents.only().state()).isEqualTo(PosterImageUploadIntentState.PENDING); + assertThat(transactions.writeCommits).isEqualTo(3); + } + + private static final class TrackingTransactions implements TransactionPort { + boolean active; + int writeCommits; + + @Override + public T inWrite(java.util.function.Supplier action) { + assertThat(active).isFalse(); + active = true; + try { + T result = action.get(); + writeCommits++; + return result; + } finally { + active = false; + } + } + + @Override + public T inRead(java.util.function.Supplier action) { + assertThat(active).isFalse(); + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inNew(java.util.function.Supplier action) { + return inWrite(action); + } + } + + private static final class FakeIntents implements PosterImageUploadIntentStorePort { + private final Map rows = new LinkedHashMap<>(); + + @Override + public PosterImageUploadIntent createOrRead(PosterImageUploadIntent proposed) { + return rows.computeIfAbsent( + proposed.operationKey().operationId().value(), ignored -> proposed); + } + + @Override + public Optional findByOperation( + dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation) { + return Optional.ofNullable(rows.get(operation.operationId().value())); + } + + @Override + public Optional findByAnyScopeDigest( + Collection retainedDigests) { + return rows.values().stream() + .filter(intent -> retainedDigests.contains(intent.idempotencyScope())) + .findFirst(); + } + + @Override + public Optional findReadyByPublishedReference( + PosterImageReference reference) { + return Optional.empty(); + } + + @Override + public PosterImageUploadIntent compareAndSet( + long expectedRevision, PosterImageUploadIntent replacement) { + PosterImageUploadIntent current = rows.get(replacement.operationKey().operationId().value()); + assertThat(current.revision()).isEqualTo(expectedRevision); + rows.put(replacement.operationKey().operationId().value(), replacement); + return replacement; + } + + @Override + public Optional claimPending( + String claimantDigest, Instant now, Duration leaseDuration) { + return Optional.empty(); + } + + PosterImageUploadIntent only() { + return rows.values().iterator().next(); + } + } + + private static final class FakeIdempotencyStore implements IdempotencyStorePort { + private final Map rows = new LinkedHashMap<>(); + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + return rows.putIfAbsent( + scope.storageKey(), + new IdempotencyRecord( + scope, fingerprint, IdempotencyStatus.IN_FLIGHT, null, NOW, expiresAt)) + == null; + } + + @Override + public Optional find(IdempotencyScope scope, Instant now) { + return Optional.ofNullable(rows.get(scope.storageKey())); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + IdempotencyRecord current = rows.get(scope.storageKey()); + rows.put( + scope.storageKey(), + new IdempotencyRecord( + current.scope(), + current.fingerprint(), + IdempotencyStatus.COMPLETED, + response, + current.createdAt(), + current.expiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + rows.remove(scope.storageKey()); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java new file mode 100644 index 0000000..154ea48 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java @@ -0,0 +1,186 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.objectstorage.content.CancellationView; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; +import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; +import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; +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.port.ObjectRetirementPort; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageRetirementCommand; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class PosterImageRetirementCrashMatrixTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + private static final String CLAIMANT = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final ObjectOperationId OPERATION = + ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901"); + private static final PosterImageReference REFERENCE = + PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + private static final ObjectVersionToken VERSION = + ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); + + @Test + void responseLossRetriesTheSameExactOperationOutsideTransactions() { + InMemoryRetirements store = new InMemoryRetirements(pending()); + TrackingTransactions transactions = new TrackingTransactions(); + AtomicInteger attempts = new AtomicInteger(); + ObjectRetirementPort objects = + request -> { + assertThat(transactions.active).isFalse(); + assertThat(request.reference().canonicalText()).isEqualTo(REFERENCE.value()); + assertThat(request.exactVersion()).isEqualTo(VERSION); + if (attempts.incrementAndGet() == 1) { + throw new IllegalStateException("response lost"); + } + return new ObjectMutationReceipt( + request.operationKey(), + ObjectMutationOutcome.REPLAYED, + ObjectOperationError.NONE, + NOW); + }; + ReconcilePosterImageRetirementUseCase useCase = useCase(store, transactions, objects); + + var first = useCase.handle(command()); + var second = useCase.handle(command()); + + assertThat(first.state()).isEqualTo(PosterImageRetirementIntentState.PENDING); + assertThat(second.state()).isEqualTo(PosterImageRetirementIntentState.RETIRED); + assertThat(attempts).hasValue(2); + assertThat(store.current.operationId()).isEqualTo(OPERATION); + assertThat(store.current.claimFence()).isEqualTo(2); + } + + @Test + void expiredWorkerCannotCommitAfterTakeoverAdvancesFence() { + PosterImageRetirementIntent first = pending().claim(0, CLAIMANT, NOW, Duration.ofSeconds(1)); + PosterImageRetirementIntent takeover = + first.claim( + first.revision(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + NOW.plusSeconds(2), + Duration.ofSeconds(30)); + + assertThat(takeover.claimFence()).isEqualTo(2); + assertThatThrownBy( + () -> takeover.retired(takeover.revision(), first.claimFence(), NOW.plusSeconds(3))) + .isInstanceOf(RuntimeException.class); + } + + private static ReconcilePosterImageRetirementUseCase useCase( + InMemoryRetirements store, TrackingTransactions transactions, ObjectRetirementPort objects) { + return new ReconcilePosterImageRetirementUseCase( + store, + transactions, + objects, + ObjectDestinationId.of("poster"), + ObjectOperationEpoch.of("poster-image-v1"), + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static ReconcilePosterImageRetirementCommand command() { + return new ReconcilePosterImageRetirementCommand( + CLAIMANT, + Duration.ofSeconds(30), + Duration.ZERO, + CallBudget.fromNow(Duration.ofSeconds(10)), + CancellationView.never()); + } + + private static PosterImageRetirementIntent pending() { + return PosterImageRetirementIntent.pending( + UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), + PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), + OPERATION, + REFERENCE, + VERSION, + NOW, + NOW); + } + + private static final class InMemoryRetirements implements PosterImageRetirementIntentStorePort { + private PosterImageRetirementIntent current; + + private InMemoryRetirements(PosterImageRetirementIntent current) { + this.current = current; + } + + @Override + public PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed) { + return current; + } + + @Override + public Optional findByOperation(ObjectOperationId operation) { + return Optional.of(current); + } + + @Override + public PosterImageRetirementIntent compareAndSet( + long expectedRevision, PosterImageRetirementIntent replacement) { + assertThat(current.revision()).isEqualTo(expectedRevision); + current = replacement; + return replacement; + } + + @Override + public Optional claimReady( + String claimantDigest, Instant now, Duration leaseDuration) { + if (current.state().terminal() + || (current.retryAt() != null && current.retryAt().isAfter(now))) { + return Optional.empty(); + } + PosterImageRetirementIntent claimed = + current.claim(current.revision(), claimantDigest, now, leaseDuration); + current = claimed; + return Optional.of(claimed); + } + } + + private static final class TrackingTransactions implements TransactionPort { + private boolean active; + + @Override + public T inWrite(Supplier action) { + assertThat(active).isFalse(); + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return inWrite(action); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java new file mode 100644 index 0000000..ed626a7 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java @@ -0,0 +1,34 @@ +package dev.caskeleton.sample.portfolio.application.posterimage; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PosterImageSanitizedIdempotencyContextFactoryTest { + + @Test + void rawCallerDimensionsNeverEnterTheGenericStoreScope() { + var digester = + new HmacPosterImageIdempotencyScopeDigester( + "key-v1", + Map.of("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8))); + var factory = new PosterImageSanitizedIdempotencyContextFactory(digester); + + var result = + factory.create( + "tenant-raw-secret", + "principal-raw-secret", + "idempotency-raw-secret", + RequestFingerprint.ofSha256(new byte[] {1})); + String storageKey = result.generic().scope().storageKey(); + + assertThat(storageKey) + .doesNotContain("tenant-raw-secret", "principal-raw-secret", "idempotency-raw-secret") + .contains(PosterImageSanitizedIdempotencyContextFactory.RESERVATION_USE_CASE); + assertThat(result.retainedScopes()).contains(result.activeScope()); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java new file mode 100644 index 0000000..d3c6505 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyKeyEpochPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigesterPort; +import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Base64; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class PosterImageIdempotencyConfigTest { + + @TempDir Path root; + + @Test + void disabledConfigurationCreatesNoDigester() { + new ApplicationContextRunner() + .withUserConfiguration(PosterImageIdempotencyConfig.class) + .withPropertyValues("app.poster-image.idempotency.enabled=false") + .run( + context -> { + assertThat(context).doesNotHaveBean(PosterImageIdempotencyScopeDigesterPort.class); + assertThat(context) + .doesNotHaveBean(PosterImageSanitizedIdempotencyContextFactory.class); + }); + } + + @Test + void permissionCheckedExternalKeyRingCreatesRotationAwareDigester() throws Exception { + Path keyRing = root.resolve("poster-image.keys"); + String key = + Base64.getEncoder() + .encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + Files.writeString( + keyRing, "key.key-v1=" + key + System.lineSeparator(), StandardCharsets.ISO_8859_1); + try { + Files.setPosixFilePermissions( + keyRing, Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } catch (UnsupportedOperationException ignored) { + // The production loader applies the portable no-follow/regular-file/size checks. + } + + new ApplicationContextRunner() + .withUserConfiguration(PosterImageIdempotencyConfig.class) + .withBean(PosterImageIdempotencyKeyEpochPort.class, FixedEpoch::new) + .withPropertyValues( + "app.poster-image.idempotency.enabled=true", + "app.poster-image.idempotency.key-ring-path=" + keyRing, + "app.poster-image.idempotency.bootstrap-active-key-version=key-v1") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(PosterImageIdempotencyScopeDigesterPort.class); + assertThat(context) + .hasSingleBean(PosterImageSanitizedIdempotencyContextFactory.class); + }); + } + + private static final class FixedEpoch implements PosterImageIdempotencyKeyEpochPort { + + @Override + public String activeVersionForReservation() { + return "key-v1"; + } + + @Override + public void activate(String expectedVersion, String newVersion) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java new file mode 100644 index 0000000..78e324b --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java @@ -0,0 +1,52 @@ +package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class PosterImageRetirementConfigTest { + + @Test + void disabledConfigurationCreatesNoWorker() { + new ApplicationContextRunner() + .withUserConfiguration(PosterImageRetirementConfig.class) + .withPropertyValues("app.poster-image.retirement.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(PosterImageRetirementJob.class)); + } + + @Test + void enabledConfigurationRequiresBoundedIdentityAndCreatesOneWorker() { + new ApplicationContextRunner() + .withUserConfiguration(PosterImageRetirementConfig.class) + .withBean( + ReconcilePosterImageRetirementUseCase.class, + () -> mock(ReconcilePosterImageRetirementUseCase.class)) + .withPropertyValues( + "app.poster-image.retirement.enabled=true", + "app.poster-image.retirement.claimant-digest=" + "a".repeat(64), + "app.poster-image.retirement.batch-size=10", + "app.poster-image.retirement.claim-lease=30s", + "app.poster-image.retirement.operation-timeout=5s") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(PosterImageRetirementJob.class); + }); + } + + @Test + void enabledConfigurationRejectsRawOrMissingClaimantIdentity() { + new ApplicationContextRunner() + .withUserConfiguration(PosterImageRetirementConfig.class) + .withBean( + ReconcilePosterImageRetirementUseCase.class, + () -> mock(ReconcilePosterImageRetirementUseCase.class)) + .withPropertyValues( + "app.poster-image.retirement.enabled=true", + "app.poster-image.retirement.claimant-digest=worker-1") + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java new file mode 100644 index 0000000..0dfc23a --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +class PosterImageReferenceTest { + + @Test + void acceptsOnlyTheBoundedOpaquePublishedReferenceGrammar() { + PosterImageReference reference = + PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + + assertThat(reference.value()) + .isEqualTo("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); + assertThatIllegalArgumentException() + .isThrownBy(() -> PosterImageReference.of("s3://bucket/posters/1")); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java index fe6468e..a81e9a0 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.api.Test; class PosterTest { private static final PosterId ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final PosterImageReference REFERENCE = + PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); private Poster sample() { return Poster.create(ID, "여름 세일 배너", "7월 한정"); @@ -67,6 +69,33 @@ class PosterTest { assertThat(p.hasImage()).isTrue(); } + @Test + void canonicalWriterAttachesOnlyAPublishedOpaqueReference() { + Poster p = sample(); + + p.attachPublishedImage(REFERENCE); + + assertThat(p.imageAttachment()).isEqualTo(new PublishedPosterImageAttachment(REFERENCE)); + assertThat(p.imageReference()).isEqualTo(REFERENCE.value()); + assertThat(p.imageKey()).isEqualTo(REFERENCE.value()); + } + + @Test + void dualReadRejectsAmbiguousLegacyAndPublishedColumns() { + assertThatThrownBy( + () -> + Poster.rehydrate( + ID, + "title", + "caption", + "posters/legacy", + REFERENCE.value(), + PosterStatus.DRAFT, + 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("both"); + } + @Test void draftCanBeArchivedDirectly() { Poster p = sample(); diff --git a/src/sample-portfolio/src/test/resources/application-test.yml b/src/sample-portfolio/src/test/resources/application-test.yml index 1e51a66..33a9dbb 100644 --- a/src/sample-portfolio/src/test/resources/application-test.yml +++ b/src/sample-portfolio/src/test/resources/application-test.yml @@ -168,6 +168,19 @@ ca-skeleton: published-retention: P7D app: + poster-image: + api: + mode: legacy + idempotency: + enabled: false + publication: + enabled: false + object-storage: + enabled: false + legacy: + enabled: true + backend: filesystem + base-path: ./.data/test-objectstorage cache: redis: enabled: false