diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index b71fa88a..4369095f 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -164,3 +164,31 @@ gates: workflow: link-check.yml job: lychee execution: job + - id: object-storage-minio-managed-contract + release_blocking: true + mechanism: gradle-custom-task + ref: objectStorageMinioContractTest + workflow: object-storage-qualification.yml + job: minio-managed-contract + execution: explicit + - id: poster-image-v7-migration + release_blocking: true + mechanism: gradle-custom-task + ref: posterImageMigrationTest + workflow: object-storage-qualification.yml + job: poster-image-v7-migration + execution: explicit + - id: object-storage-minio-managed-fault + release_blocking: conditional + mechanism: gradle-custom-task + ref: objectStorageMinioFaultTest + workflow: object-storage-qualification.yml + job: minio-managed-fault + execution: explicit + - id: object-storage-aws-protected-qualification + release_blocking: conditional + mechanism: delegated-pending + ref: approval-gate-b + workflow: object-storage-qualification.yml + job: aws-managed-common-subset + execution: job diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index 4bd445fc..0a3e362c 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=22 +readonly EXPECTED_GATE_COUNT=26 if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 diff --git a/.github/workflows/object-storage-qualification.yml b/.github/workflows/object-storage-qualification.yml new file mode 100644 index 00000000..5be0b4cc --- /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 00000000..39a7f5ac --- /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 00000000..9042f72a --- /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 00000000..5ed46ef8 --- /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 00000000..a4fb95fe --- /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 00000000..06c1598f --- /dev/null +++ b/docs/evidence/object-storage/batch-e-pause-checkpoint.md @@ -0,0 +1,94 @@ +# Object Storage Batch E Pause Checkpoint + +- Recorded: 2026-07-29 (Asia/Seoul) +- Branch: `codex/objectstorage-production-capability` +- Worktree: + `/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage` +- Status: implementation in progress; intentionally paused at the user's request +- Evidence grade: local unit/integration/architecture evidence only; no AWS R2 evidence + +## Implemented at this checkpoint + +- Staged integrity verification, fake-scanner routing, publication handoff fencing, and stable + replay receipts. +- Additive Poster V8 dual-read schema (renumbered from branch-local V7 during JPA integration), + upload/retirement intents, HMAC-sanitized idempotency scope, + PostgreSQL atomic claim SPI, and forward-only migration qualification lane. +- Short-transaction Poster image publication flow and additive locator-free `202` API under the + AIP-122-compatible `/posters/{id}/imagePublications` collection. +- Exact-reference/version logical retirement enqueue, lease/fence takeover, response-loss retry, + Poster deletion survival, and disabled-by-default worker composition. +- Isolated legacy migration contracts, report-only inspection, two-distinct-approver Ed25519 + approval verification, nonce replay boundary, and explicit maintenance-only composition. + +## Verification completed + +The following focused command passed after the final architecture fixes: + +```bash +cd src +./gradlew \ + :sample-portfolio:spotlessApply \ + :sample-portfolio:test --tests '*PosterImagePublicationControllerWireTest' \ + :app-bootstrap:test --tests '*CleanArchitectureTest' \ + --console=plain +``` + +The following focused suites also passed during this checkpoint: + +```bash +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LegacyObjectAdoptionServiceTest' \ + --tests '*LegacyAdoptionApprovalVerifierTest' \ + --tests '*ObjectStorageLegacyMigrationConfigTest' --console=plain + +./gradlew :sample-portfolio:test \ + --tests '*DeletePosterImageRetirementTest' \ + --tests '*PosterImageRetirementCrashMatrixTest' \ + --tests '*PosterImageRetirementConfigTest' \ + --tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain + +./gradlew :sample-portfolio:test \ + --tests '*SampleApplicationContextTest' \ + :sample-portfolio:posterImageMigrationTest --console=plain +``` + +The migration lane included +`PosterImageRetirementQualificationTest`, which proved that an exact retirement row survives +deletion of its Poster row. + +## Failures found and resolved + +- `spotlessJavaCheck` initially found formatting drift in newly changed application-core and + persistence files. The owner-module Spotless apply tasks fixed it. +- `SampleApplicationContextTest` initially failed because Spring's persistence exception advisor + could not CGLIB-proxy the final `PosterImageAttachmentCasRepository`. Removing `final` fixed the + context; the focused context suite then passed. +- `CleanArchitectureTest` initially rejected an application-core return type from the sample domain + and the kebab-case `image-publications` path. Conversion moved back to the application use case, + and the endpoint changed to the repository's AIP-122-compatible `imagePublications` segment. The + complete focused architecture suite then passed. + +## Not yet re-run / not complete + +- The combined Batch E checkpoint command stopped on the two architecture failures above before all + requested root tasks could complete. The focused failing suites passed after the fixes, but + `:sample-portfolio:check`, `verifyPublicPathSnapshot`, and the full combined Batch E command have + not been re-run after those final fixes. +- The complete repository `./gradlew test` and `./gradlew check` have not been re-run after the + Batch E additions. +- The legacy adoption runner/configuration is not yet wired to a production legacy inspector, + permission-checked trust-key loader, or durable control-record replay-store implementation. +- Tasks 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 + +At this isolated-branch checkpoint, the then-selected private vault +`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` was absent, so capture was blocked. The final +main integration was later captured in the user-designated public vault at +`raw/branch-notes/chore-main-worktree-capability-integration.md`, with the derived error note +`raw/errors/multi-worktree-contract-drift-2026-07-31.md`. 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 00000000..9220594d --- /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 00000000..cacc0a80 --- /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 index 8bb5383a..2355bf39 100644 --- a/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md +++ b/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md @@ -1,10 +1,10 @@ # Object Storage Production Capability Implementation Plan - 작성일: 2026-07-28 -- 상태: 구현 계획 작성 완료, 모든 task 미착수, R2 미구현 +- 상태: Batch A–D 및 Batch E pause checkpoint를 main에 통합, Batch E 전체 gate와 R2는 미완료 - 범위: 상세 설계 Phase 0–6; Phase 7/R3는 별도 승인 계획 -- LLM Wiki capture: 정본 vault - `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함 +- LLM Wiki capture: public vault의 + `raw/branch-notes/chore-main-worktree-capability-integration.md`에 main 통합 기록 완료 > **For agentic workers:** REQUIRED SUB-SKILLS: use > `superpowers:subagent-driven-development` to execute independent tasks, @@ -43,6 +43,27 @@ provider-leaf split, a production malware-scanner implementation, and browser AP outside the approved contracts require follow-up plans. Writing this plan changes no runtime behavior and advances no readiness card. +**Implementation checkpoint (2026-07-28):** The Batch 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). + +Batch E의 개별 focused fix 증거는 +[Batch E pause checkpoint](../../evidence/object-storage/batch-e-pause-checkpoint.md)에 기록됐다. +당시 Batch E combined/root gate는 재실행되지 않았으므로 Batch E 전체 완료나 R2를 주장하지 +않는다. 격리 브랜치에서 잘못 선택했던 private vault 경로 대신, main 통합 작업은 사용자가 +지정한 public vault의 `raw/branch-notes/chore-main-worktree-capability-integration.md`에 +캡처했다. + --- ## Execution rules @@ -154,7 +175,7 @@ Task 30; a batch is not closed if any output is missing. - Create: `docs/evidence/object-storage/phase-0-inventory.md` -- [ ] **Step 1: Record current executable behavior** +- [x] **Step 1: Record current executable behavior** Prove the current contract exactly as-is: @@ -169,7 +190,7 @@ Prove the current contract exactly as-is: - the response exposes raw key/location; - Poster deletion does not retire the object. -- [ ] **Step 2: Verify the characterization baseline** +- [x] **Step 2: Verify the characterization baseline** Run: @@ -185,7 +206,7 @@ cd src Expected: PASS against the current implementation. This is a baseline, not R1/R2 evidence. -- [ ] **Step 3: Inventory runtime and data dependencies** +- [x] **Step 3: Inventory runtime and data dependencies** In the evidence document record command output, not an unsupported repository-wide conclusion: @@ -204,7 +225,7 @@ Classify every hit as legacy runtime, test, documentation, stored-data schema, o Record whether real deployed consumers/data were inspected; if they were not, state that external inventory is still required. -- [ ] **Step 4: Run unchanged focused suites** +- [x] **Step 4: Run unchanged focused suites** Run: @@ -232,7 +253,7 @@ Expected: PASS. No source behavior changes belong to this task. - Test: `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java` -- [ ] **Step 1: Add the approved test-only property engine** +- [x] **Step 1: Add the approved test-only property engine** Add `testImplementation 'net.jqwik:jqwik:1.9.1'`, matching the existing sample test version, and update only the application-core lockfile: @@ -245,7 +266,7 @@ cd src Expected: PASS with test-only jqwik entries and no production dependency. -- [ ] **Step 2: Write the failing identity contract** +- [x] **Step 2: Write the failing identity contract** Test null/blank/control-character/oversize rejection, canonical round trips, operation-key composition, part range `1..10_000`, prefix separation, route-token grammar, reference check-digit @@ -253,7 +274,7 @@ tampering, and provider-locator non-disclosure. Include jqwik properties for arb input and a fixed golden vector for each prefix. Route existence/retirement is adapter binding state and is deliberately not tested or imported in `application-core`. -- [ ] **Step 3: Verify RED** +- [x] **Step 3: Verify RED** Run: @@ -265,7 +286,7 @@ cd src Expected: compilation failure because the new identity types do not exist. -- [ ] **Step 4: Implement the minimum values** +- [x] **Step 4: Implement the minimum values** Use immutable final classes or records with constructor validation. `ObjectOperationKey` contains exactly destination, epoch, and operation ID. Public reference/handle types expose canonical text @@ -274,7 +295,7 @@ validation in framework-free Java (`MessageDigest`). The adapter-owned codec in minting path from a retained route token plus generated `ObjectId`; application values do not consult a route registry. -- [ ] **Step 5: Verify GREEN** +- [x] **Step 5: Verify GREEN** Run the command from Step 3 and `./gradlew :application-core:verifyDependencyLocks --console=plain`. Expected: PASS. @@ -299,7 +320,7 @@ Run the command from Step 3 and - Test: `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java` -- [ ] **Step 1: Write failing callback and value tests** +- [x] **Step 1: Write failing callback and value tests** Prove: @@ -313,7 +334,7 @@ Prove: - range offset/length arithmetic rejects zero, negative, overflow, and over-budget delivery; - media types are canonical, bounded, and contain no control characters. -- [ ] **Step 2: Verify RED** +- [x] **Step 2: Verify RED** Run: @@ -326,14 +347,14 @@ cd src Expected: compilation failure because the callback/value types do not exist. -- [ ] **Step 3: Implement the minimum contract** +- [x] **Step 3: Implement the minimum contract** Reuse `CallBudget`; do not create serializable wall-clock deadlines. Callback types may declare only application exceptions and Java primitive/array types. Do not expose `InputStream`, `OutputStream`, `ByteBuffer`, Reactor, Flow, servlet, Spring, or AWS types. Document that callbacks are synchronous and blocking and that adapters must not invoke application callbacks on SDK event-loop threads. -- [ ] **Step 4: Verify GREEN** +- [x] **Step 4: Verify GREEN** Run the command from Step 2. Expected: PASS. @@ -376,7 +397,7 @@ Run the command from Step 2. Expected: PASS. - Test: `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java` -- [ ] **Step 1: Write the failing port-shape contract** +- [x] **Step 1: Write the failing port-shape contract** Use reflection and construction tests to prove: @@ -393,7 +414,7 @@ Use reflection and construction tests to prove: - receipts expose no locator, ETag, upload ID, URL, credential, or provider enum; - requirements can strengthen but never lower destination policy. -- [ ] **Step 2: Verify RED** +- [x] **Step 2: Verify RED** Run: @@ -405,7 +426,7 @@ cd src Expected: compilation failure because the semantic port family does not exist. -- [ ] **Step 3: Implement the minimum framework-free API** +- [x] **Step 3: Implement the minimum framework-free API** Follow design §9 exactly. Keep one public top-level type per file. Use immutable collections and defensive copies where required. Grants contain a bounded URI, signed header names/values, @@ -414,7 +435,7 @@ expiration, and opaque session identity, but their `toString` must redact the UR Document the separate scanner-workflow composition boundary on `ObjectScanMaintenancePort`; the normal staged port owns only stage, integrity verification, finalize, and abort. -- [ ] **Step 4: Verify GREEN** +- [x] **Step 4: Verify GREEN** Run the command from Step 2. Expected: PASS. @@ -431,7 +452,7 @@ Run the command from Step 2. Expected: PASS. - Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` -- [ ] **Step 1: Write failing purity tests** +- [x] **Step 1: Write failing purity tests** Assert recursively through fields, methods, constructors, record components, generic arguments, and annotations that `dev.caskeleton.application.objectstorage..` has no Spring, AWS SDK, servlet, @@ -442,7 +463,7 @@ boundary. Add an ArchUnit rule that new sample business code may not import into an explicitly named legacy-only package, and permit the later exact `application.storage.migration` adoption use case as an admin-only compatibility exception. -- [ ] **Step 2: Verify RED** +- [x] **Step 2: Verify RED** Run: @@ -458,7 +479,7 @@ Expected: the new sample-import rule initially identifies the current legacy con fixture explicitly records it as the single frozen violation. No unrelated architecture violation may be accepted. -- [ ] **Step 3: Deprecate without adapting new calls back to raw keys** +- [x] **Step 3: Deprecate without adapting new calls back to raw keys** Mark both legacy types `@Deprecated(forRemoval = true)` and document: @@ -473,13 +494,13 @@ Task 23 must move the remaining compatibility surface into an allowlisted `..pos slice; no non-legacy sample package may import the old port after that cutover. Do not create a semantic-to-legacy adapter that throws away operation identity or guarantees. -- [ ] **Step 4: Verify GREEN** +- [x] **Step 4: Verify GREEN** Run the command from Step 2. Expected: PASS with exactly the named frozen legacy violation and zero provider/framework leaks. The later migration exception must be bounded/redacted and visible only to the named administrative use case. -- [ ] **Step 5: Run Batch A checkpoint** +- [x] **Step 5: Run Batch A checkpoint** Run: @@ -523,7 +544,7 @@ object-storage provider. - Test: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java` -- [ ] **Step 1: Add the approved test-only property engine** +- [x] **Step 1: Add the approved test-only property engine** Add `testImplementation 'net.jqwik:jqwik:1.9.1'` and update only this leaf's lockfile: @@ -535,7 +556,7 @@ cd src Expected: PASS with no production jqwik dependency. -- [ ] **Step 2: Write failing codec and epoch tests** +- [x] **Step 2: Write failing codec and epoch tests** Use golden vectors and property tests for: @@ -555,7 +576,7 @@ Use golden vectors and property tests for: - epoch `WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED` transitions, no token reuse, and `OPERATION_EXPIRED` after seal/compaction. -- [ ] **Step 3: Verify RED** +- [x] **Step 3: Verify RED** Run: @@ -569,14 +590,14 @@ cd src Expected: compilation failure because the codec/kernel types do not exist. -- [ ] **Step 4: Implement deterministic codecs** +- [x] **Step 4: Implement deterministic codecs** Keep all physical key construction in these codecs. Route lookup uses retained binding revision, never a current-provider default. The fingerprint includes exact content identity for R2 and an explicit `R1_UNVERIFIED_CONTENT` marker for compatibility; it never hashes content by materializing the object. Operation epoch records include finite replay/retention/compaction bounds. -- [ ] **Step 5: Verify GREEN** +- [x] **Step 5: Verify GREEN** Run the command from Step 3 and `./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain`. Expected: PASS. @@ -610,7 +631,7 @@ Run the command from Step 3 and - Test: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java` -- [ ] **Step 1: Write failing strict-codec tests** +- [x] **Step 1: Write failing strict-codec tests** For every record family introduced through Task 7 prove: @@ -629,7 +650,7 @@ size/digest/media type, encryption/retention evidence, and immutable data versio small reference pointer CASes only the current manifest revision. No mutable pointer is treated as the manifest itself. -- [ ] **Step 2: Write failing transition-table tests** +- [x] **Step 2: Write failing transition-table tests** Cover every allowed and forbidden transition from design §12, including: @@ -641,7 +662,7 @@ Cover every allowed and forbidden transition from design §12, including: - stale fence/revision rejection; - unknown/newer state never auto-deleted or downgraded. -- [ ] **Step 3: Verify RED** +- [x] **Step 3: Verify RED** Run: @@ -655,7 +676,7 @@ cd src Expected: compilation failure because the control-plane and state-machine types do not exist. -- [ ] **Step 4: Implement the minimum kernel** +- [x] **Step 4: Implement the minimum kernel** The kernel accepts a compiled policy snapshot and an `ObjectControlStore`; it does not import a provider SDK. Reserve writes the frozen snapshot before provider mutation. Every mutation writes a @@ -665,7 +686,7 @@ discriminator; no reflective or `Map` binding and no undecided JS allowed. A later task that adds a durable family must modify this codec, add checked-in golden bytes, and prove old/new reader compatibility before the selected write version changes. -- [ ] **Step 5: Verify GREEN** +- [x] **Step 5: Verify GREEN** Run the command from Step 3. Expected: PASS. @@ -689,7 +710,7 @@ Run the command from Step 3. Expected: PASS. - Test: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java` -- [ ] **Step 1: Write the reusable provider contract** +- [x] **Step 1: Write the reusable provider contract** The abstract suite must be executable for each provider and cover: @@ -707,7 +728,7 @@ The abstract suite must be executable for each provider and cover: Unsupported optional capabilities must assert descriptor `UNSUPPORTED`, not skip. -- [ ] **Step 2: Write failing local security/recovery tests** +- [x] **Step 2: Write failing local security/recovery tests** Use `@TempDir` and injected filesystem/fault collaborators to test: @@ -720,7 +741,7 @@ Use `@TempDir` and injected filesystem/fault collaborators to test: - corrupt/newer records quarantined, never absent/deleted; - file descriptor and temporary-file cleanup. -- [ ] **Step 3: Verify RED** +- [x] **Step 3: Verify RED** Run: @@ -733,7 +754,7 @@ cd src Expected: compilation failure because the local provider/kernel integration does not exist. -- [ ] **Step 4: Implement bounded local R1** +- [x] **Step 4: Implement bounded local R1** Use adapter-generated immutable data names, `CREATE_NEW`, bounded chunks, streaming SHA-256, staged temporary files, force/atomic move only where the host proves it, and strict relative path checks. @@ -741,7 +762,7 @@ The local control store serializes per operation in one process. When portabilit durability cannot be proven, return the truthful R1 descriptor; never claim multi-node CAS or power-loss durability. This provider is rejected in production profiles. -- [ ] **Step 5: Verify GREEN** +- [x] **Step 5: Verify GREEN** Run the command from Step 3. Expected: PASS. @@ -770,7 +791,7 @@ Run the command from Step 3. Expected: PASS. - Modify: `src/sample-portfolio/src/main/resources/application.yml` -- [ ] **Step 1: Write failing binding tests** +- [x] **Step 1: Write failing binding tests** Bind `app.object-storage` and reject: @@ -788,7 +809,7 @@ Bind `app.object-storage` and reject: Prove a valid local profile compiles one exact route and immutable policy snapshot. -- [ ] **Step 2: Write failing composition tests** +- [x] **Step 2: Write failing composition tests** Use `ApplicationContextRunner` to prove: @@ -805,7 +826,7 @@ Use `ApplicationContextRunner` to prove: - a namespace-separated canonical `legacy` subgroup plus the new capability may run together only in the explicit migration/sample-local profile. -- [ ] **Step 3: Verify RED** +- [x] **Step 3: Verify RED** Run: @@ -819,7 +840,7 @@ cd src Expected: compilation/test failure because canonical settings/composition do not exist and current legacy configuration activates filesystem by default. -- [ ] **Step 4: Implement exact binding and activation** +- [x] **Step 4: Implement exact binding and activation** Use immutable constructor-bound settings and typed `Duration`/`DataSize`/enums. Compile settings before constructing any provider. Contributions are side-effect-free descriptors; the assembler @@ -838,11 +859,11 @@ Until Task 22 migrates the consumer, the sample-local YAML explicitly enables th filesystem seam and may also select `filesystem-local-dev` at `./.data/object-storage-v1` for the new capability. No production YAML receives a local fallback. -- [ ] **Step 5: Verify GREEN** +- [x] **Step 5: Verify GREEN** Run the command from Step 3. Expected: PASS. -- [ ] **Step 6: Prove the stable contribution seam** +- [x] **Step 6: Prove the stable contribution seam** Add an `ApplicationContextRunner` matrix with a counting fake contribution: disabled, unselected, invalid binding, selected success, selected construction failure, and close. Prove construction @@ -874,7 +895,7 @@ unverified post-GREEN addition. - Modify: `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md` -- [ ] **Step 1: Write the failing registry/schema test** +- [x] **Step 1: Write the failing registry/schema test** Validate the frozen schema and exact nine card IDs. Reject: @@ -886,7 +907,7 @@ Validate the frozen schema and exact nine card IDs. Reject: - local-dev above R1; - a limitation-free row when a provider descriptor reports limitations. -- [ ] **Step 2: Verify RED** +- [x] **Step 2: Verify RED** Run: @@ -898,13 +919,13 @@ cd src Expected: failure because the registry and runtime card types do not exist. -- [ ] **Step 3: Add only evidenced claims** +- [x] **Step 3: Add only evidenced claims** Initial entries may claim R0 for contract-only cards and R1 for local managed single upload/download only after Tasks 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. -- [ ] **Step 4: Run Batch B checkpoint** +- [x] **Step 4: Run Batch B checkpoint** Run: @@ -919,7 +940,7 @@ cd src Expected: PASS. -- [ ] **Step 5: Verify rollback** +- [x] **Step 5: Verify rollback** Start a context with the canonical capability disabled and legacy disabled. Assert there are no storage beans or side effects. Start the explicit legacy profile and prove old data remains @@ -945,7 +966,7 @@ readable. No migration or deletion occurs at startup. - Test: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java` -- [ ] **Step 1: Write the failing pinned-API characterization** +- [x] **Step 1: Write the failing pinned-API characterization** Compile and assert the exact `2.30.0` API used by later tasks: @@ -960,7 +981,7 @@ Add boundary tests at `Integer.MAX_VALUE`, `Integer.MAX_VALUE + 1L`, part count minimum S3 non-final part size. Record the full-object multipart checksum profile as unsupported above the SDK integer boundary unless an independently approved path exists. -- [ ] **Step 2: Write failing client-policy tests** +- [x] **Step 2: Write failing client-policy tests** Reject missing/non-positive/contradictory: @@ -974,7 +995,7 @@ Reject missing/non-positive/contradictory: Assert the factory uses `S3AsyncClient` plus explicit `NettyNioAsyncHttpClient`, not CRT or the sync client, and owns close order. -- [ ] **Step 3: Verify RED** +- [x] **Step 3: Verify RED** Run: @@ -988,14 +1009,14 @@ cd src Expected: compilation failure because the client factory and compile-scoped Netty async client do not exist. -- [ ] **Step 4: Add only the required dependency and implementation** +- [x] **Step 4: Add only the required dependency and implementation** Add `software.amazon.awssdk:netty-nio-client` under the existing AWS BOM. Keep `software.amazon.awssdk:s3` and the BOM at `2.30.0`; do not add CRT or Transfer Manager. Configure finite client and HTTP timeouts/pools/retry from the compiled policy. Default-chain credentials are resolved only after the provider is selected. -- [ ] **Step 5: Regenerate and verify locks** +- [x] **Step 5: Regenerate and verify locks** Run: @@ -1752,6 +1773,13 @@ Approval Gate A. ## Approval Gate A — Scanner ownership and sample public contract +**Approved 2026-07-29:** The user authorized continued implementation using the recommended +defaults. The deep design now records the exact decisions: test-fake scanner only, +`integrity-verified-reference`, additive POST/GET with stable `202`, Base64 SHA-256 multipart +field, required keyed idempotency with atomic TX1 reservation/intent, existing `poster:write`, +legacy compatibility profile, new V2 event without v1 removal, intentionally scoped OpenAPI +snapshot, and additive forward-only V8 dual read. + Do not execute Tasks 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 @@ -1759,7 +1787,7 @@ Do not execute Tasks 20–24 until the approved design records all of the follow - the sample's first migrated profile is either `integrity-verified-reference` or an explicitly named scan-gated profile with a real scanner owner; - the additive endpoint proposal - `POST /posters/{id}/image-publications`, status, authorization, request size/media policy, and + `POST /posters/{id}/imagePublications`, status, authorization, request size/media policy, and locator-free response fields; - the asynchronous response contract: POST always returns the same bounded `202` reservation (opaque publication operation plus status link) replayed by `IdempotencyExecutor`; a separately @@ -1783,8 +1811,8 @@ Do not execute Tasks 20–24 until the approved design records all of the follow window, consumer migration evidence, rollback, and zero-consumer proof before v1 removal. Never rename `imageKey` to `reference` under the same unversioned event contract; - intentional OpenAPI snapshot approval; -- the additive V7 schema and forward-only rollback window. -- the compatibility model during V7: existing legacy attachments remain readable/publishable, +- the additive V8 schema and forward-only rollback window. +- the compatibility model during V8: existing legacy attachments remain readable/publishable, legacy writes are restricted to the compatibility controller/profile, and the canonical profile writes only READY opaque references. @@ -1902,7 +1930,7 @@ scanner, `object-storage-quarantine-publication` remains below R2. - Modify: `src/sample-portfolio/gradle.lockfile` - Create: - `src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql` + `src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql` - Create under `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`: `PosterImageUploadIntent.java`, `PosterImageUploadIntentState.java`, @@ -1954,7 +1982,7 @@ scanner, `object-storage-quarantine-publication` remains below R2. - Test: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java` - Create: - `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java` + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java` - Create: `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java` - Modify: @@ -1986,7 +2014,7 @@ Expected: the task/configuration exists and locks are valid; no migration behavi Run real PostgreSQL/Flyway and prove: -- fresh V1–V7 and V6→V7 upgrade; +- fresh V1–V8 and V7→V8 upgrade; - V6 is unchanged; - existing `poster.image_key` rows remain readable through the internal dual-read mapper; - new READY attachments store only bounded opaque `image_reference`; @@ -2004,7 +2032,7 @@ Run real PostgreSQL/Flyway and prove: - replacement/delete work uses a separate durable `poster_image_retirement_intent` row and cannot be inferred from a deleted Poster or overloaded upload intent. -The V7 intent table must include stable operation identity/epoch/destination, request fingerprint, +The V8 intent table must include stable operation identity/epoch/destination, request fingerprint, bounded idempotency-scope digest, expected poster version, exact content identity/media type, state, stage handle, handoff claim/fence/expiry, published reference/version, replaced reference, retry/error timestamps, and optimistic revision. Index the unique scope, operation, worker claims, @@ -2047,7 +2075,7 @@ cd src ./gradlew :sample-portfolio:posterImageMigrationTest --console=plain ``` -Expected: compilation/migration failures because V7, the intent model, key-ring binding, and +Expected: compilation/migration failures because V8, the intent model, key-ring binding, and non-skipping qualification lane do not exist. Docker/PostgreSQL absence is an actionable failure, not a skip or passing default test. @@ -2084,7 +2112,7 @@ Expected: PASS, and the `posterImageMigrationTest` result XML reports zero skipp - [ ] **Step 6: Verify rollback window** -The non-skipping `PosterImageV7MigrationQualificationTest` must run a V6-compatible SQL/JPA +The non-skipping `PosterImageV8MigrationQualificationTest` must run a V7-compatible SQL/JPA projection against the expanded schema, including old INSERT/UPDATE/read behavior. Document that rollback is binary-only while old code ignores additive columns; there is no down migration, column drop, intent deletion, or object mutation. @@ -2313,7 +2341,7 @@ idempotency package. Preserve the existing application port contract and prove w expired-find→reclaim→complete with new fingerprint/expiry/COMPLETED state, and unrelated managed entity preservation in non-skipping real PostgreSQL. Do not emulate with a process lock. Update the owner README/CLAUDE vendor SPI table. The same qualification queries both `idempotency_record` and -V7 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored +V8 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored aliases/digests retain tenant separation. Reconciliation settings are constructor-bound, disabled by default, and bound batch size, @@ -2671,7 +2699,7 @@ migration-port/verifier/replay-store/runner beans and zero side effects. The clo registers approval replay records with golden/new-reader/old-reader compatibility fixtures. Retirement settings are constructor-bound and disabled by default, with bounded claim/renew, -batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V7 +batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V8 retirement table and calls only logical exact-reference retirement outside DB transactions. A disabled context creates no scheduler/thread; stale fence/takeover is rejected and response loss remains operation-keyed exact-reference/version reconciliation, not blind retry. TX3 replacement and @@ -3419,7 +3447,7 @@ gates. Before the implementation completion response, read the canonical vault instructions and update: ```text -/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/.md +/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/.md ``` Record files, decisions, test commands/results, failures/blocks, evidence grade, and derivative 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 index 76e199fd..669d1e05 100644 --- a/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md +++ b/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md @@ -1,13 +1,15 @@ # Object Storage Production Capability Deep Design - 작성일: 2026-07-28 -- 상태: 상세 설계 및 구현 계획 완료, 구현 미착수, R2 미구현 +- 상태: Approval Gate A 승인 반영, Batch A–D 및 Batch E pause checkpoint를 main에 통합, R2 미구현 - 독립 재리뷰: 완료 — 아키텍처 및 문서 실행성 blocker 0, high 0 -- LLM Wiki capture: 정본 vault - `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함 +- LLM Wiki capture: public vault의 + `raw/branch-notes/chore-main-worktree-capability-integration.md`에 main 통합 기록 완료 - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 대상 leaf: `adapter-outbound-objectstorage` -- 현재 구현 수준: whole-object `byte[]` 기반 local filesystem/S3-MinIO 예제, R0~R1 일부 +- 현재 구현 수준: provider-neutral kernel + `filesystem-local-dev` managed single R1, + S3 managed/direct primitives, exact MinIO mutation profiles `UNSUPPORTED`, + legacy whole-object filesystem/S3-MinIO 격리 - 상위 문서: [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) - 구현 계획: @@ -19,39 +21,100 @@ ## 0. 구현 상태 -2026-07-28 기준 구현된 범위는 다음뿐이다. +### Approval Gate A 결정 — 2026-07-29 + +사용자의 구현 계속 승인에 따라 Batch E의 public/sample 계약을 다음과 같이 고정한다. + +- production scanner는 주장하지 않는다. 스캔 경계는 test fake만 제공하고 첫 sample + destination profile은 `integrity-verified-reference`로 한다. +- canonical surface는 additive + `POST /posters/{id}/imagePublications`와 별도 authorized GET status resource다. 저장소의 + AIP-122 경로 규칙에 따라 collection segment에는 kebab-case를 사용하지 않는다. + POST는 항상 동일한 bounded `202` reservation을 반환하며 READY 결과를 같은 idempotency + response에 섞지 않는다. +- 정확한 full-file SHA-256은 canonical Base64 형식의 bounded multipart field로 받고 TX1 전에 + semantic fingerprint에 포함한 뒤 staging 중 다시 검증한다. +- `Idempotency-Key`는 필수다. 첫 committed UploadIntent가 stable operation ID를 할당하며 + generic idempotency reservation과 intent는 같은 TX1에서 원자적으로 commit한다. +- sample authorization은 기존 `poster:write` permission을 유지한다. 존재하지 않는 Poster + owner 속성을 발명하지 않는다. +- legacy `/posters/{id}/image`는 명시적 compatibility profile에서만 유지한다. canonical과 + legacy controller는 동시에 활성화하지 않는다. +- 기존 `poster.image-attached`/`imageKey` 이벤트는 변경하거나 제거하지 않는다. canonical + 흐름은 새 versioned V2 event를 사용하며 외부 zero-consumer를 주장하지 않는다. +- OpenAPI 승인은 새 publication/status surface에만 한정한다. 기존 DTO의 locator-shaped + 필드는 compatibility window 동안 그대로 둔다. +- 통합 타임라인의 V8은 additive, forward-only다. V7 Poster schema의 `image_key`와 legacy + reader를 유지하고 canonical writer는 + READY opaque reference만 기록한다. rollback은 구 binary가 additive columns를 무시하는 + binary-only window이며 down migration이나 자동 object mutation은 없다. + +2026-07-28 Batch D 체크포인트 기준 다음 범위가 구현되었다. + +- `application-core`의 framework/provider-neutral object-storage identity, opaque reference, + bounded content callback, digest/range, request/receipt/outcome, capability requirement; +- managed, staged, direct single/multipart, scan-maintenance, purge-maintenance를 분리한 narrow port; +- 모든 mutation의 `ObjectOperationKey`, strict reference grammar/checksum, transient grant redaction; +- application contract의 framework/provider/transport/persistent-locator purity 검사; +- legacy port/receipt의 removal-boundary deprecation과 sample legacy import freeze; +- deterministic `data/v1`/`control/v1` namespace, reference/handle/fingerprint/policy codec, + operation epoch와 immutable route/policy snapshot; +- strict canonical JSON control envelope와 operation/manifest/reference/multipart record, + reserve/replay/conflict/pending-effect state kernel; +- bounded immutable local create, exact inspect/version, full/range transfer, conditional retire, + create resolution, symlink/path confinement, restrictive permissions와 single-process CAS; +- constructor-bound `app.object-storage` settings, compile-before-construction provider + contribution/assembler, destination/retained-route routers, disabled/unselected/invalid/close + side-effect matrix; +- explicit canonical legacy subgroup와 old alias isolation, sample local/test explicit activation; +- exact nine-card readiness registry. `filesystem-local-dev` managed single upload/download만 R1, + 나머지는 R0; +- pinned AWS SDK `2.30.0` conditional/checksum/expected-owner API characterization과 explicit + `NettyNioAsyncHttpClient` policy/factory/lifecycle; +- exact AWS/MinIO binding, bounded async transport, managed S3 single/multipart/control primitives, + response-loss resolution, selected-only provider composition; +- durable direct single/multipart session and grant records, S3 presigner lifecycle, exact part + acknowledgement, completion/abort fences, terminal exact-version replay; +- digest-pinned MinIO contract/fault lanes and protected AWS compile-only lane. Exact MinIO는 + create-only PUT/complete 조건을 무시하므로 managed/direct mutation profile을 + `UNSUPPORTED`로 유지하며 readiness를 올리지 않는다. + +증거는 [Batch A checkpoint](../../evidence/object-storage/batch-a-checkpoint.md), +[Batch B checkpoint](../../evidence/object-storage/batch-b-checkpoint.md), +[Batch C checkpoint](../../evidence/object-storage/batch-c-progress.md), +[Batch D checkpoint](../../evidence/object-storage/batch-d-checkpoint.md), +[Batch E pause checkpoint](../../evidence/object-storage/batch-e-pause-checkpoint.md)에 기록했다. + +격리 브랜치에서는 private vault 경로를 잘못 선택해 capture가 차단됐지만, main 통합 작업은 +사용자가 지정한 public vault의 +`raw/branch-notes/chore-main-worktree-capability-integration.md`에 캡처했다. + +legacy runtime 범위는 별도로 유지된다. - `application-core`의 범용 `ObjectStoragePort`; - caller가 지정한 raw key에 `byte[]`를 put/get/delete/exists하는 계약; - local filesystem adapter; - synchronous AWS SDK v2 `S3Client`를 사용하는 S3/MinIO adapter; -- 설정 한 개로 filesystem 또는 S3 backend를 선택하는 Spring composition; +- 명시적 legacy-only filesystem 또는 S3 Spring composition; - filesystem unit test, mocked S3 unit test, Testcontainers MinIO integration test; - module registry가 허용한 `application-core`, `shared-contract` 의존성. -아직 구현되지 않은 범위: +아직 구현되지 않은 runtime/provider 범위: -- streaming upload/download와 range read; -- immutable object reference와 version token; -- stable operation ID, request fingerprint, durable operation record; -- conditional create/update/delete와 unknown-outcome reconciliation; -- checksum 생성·전송·검증 계약; -- metadata/head 계약; - staged upload, quarantine, scan, publication; -- presigned upload/download; -- multipart start/part/complete/abort/recovery; +- public presigned upload/download endpoint와 inbound authorization; +- R2 수준의 exhaustive multipart/direct response-loss·race·restart qualification; - lifecycle, versioning, retention, legal hold 검증; -- production credential, encryption, TLS, expected-owner 정책; -- bounded timeout, connection pool, retry amplification, graceful shutdown; -- provider capability qualification과 exact readiness card; +- multi-node linearizable control CAS와 production unknown-outcome reconciliation; +- production credential, encryption, TLS, retention/Object Lock qualification; - orphan/abandoned multipart/retired object reaper; - database와 object storage 사이의 crash-safe workflow; - 운영 metric, trace, audit, runbook; - AWS sandbox fault/security evidence. 따라서 현재 MinIO round-trip test가 통과하더라도 S3 production readiness를 의미하지 않는다. -이 문서의 상태가 “상세 설계 완료”인 것은 구현, R2 qualification 또는 운영 준비 완료를 -뜻하지 않는다. +Batch B의 R1은 exact local functional topology 증거다. Multi-node CAS, power-loss durability, +S3/MinIO qualification, R2 또는 운영 준비 완료를 뜻하지 않는다. ## 1. 설계 판정 @@ -3955,8 +4018,10 @@ authorization endpoint의 ephemeral response이며 stored object DTO에 영구 정본 구현 순서와 아래 결정의 현재 freeze/approval gate는 [Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md)에 -기록한다. 계획 작성은 완료됐지만 모든 구현 task는 아직 미착수이며, 계획 승인이 public API, -scanner provider 또는 AWS/IaC 외부 변경 권한을 자동으로 부여하지 않는다. +기록한다. Tasks 1–24의 구현은 Batch E pause checkpoint까지 진행됐지만 Batch E 전체 +combined/root gate는 아직 통과하지 않았다. Tasks 25–30은 미착수다. 이 구현 진행이나 +계획 승인이 public API, scanner provider 또는 AWS/IaC 외부 변경 권한을 자동으로 부여하지 +않는다. 구현 계획은 다음 결정을 task 단위로 명시해야 한다. diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java index f720f645..157dc88c 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java @@ -65,8 +65,7 @@ final class RedisSentinelMasterDiscovery { && query != null; } - private static Set normalizedSentinels( - List sentinelEndpoints) { + private static Set normalizedSentinels(List sentinelEndpoints) { Set normalized = new HashSet<>(); for (SentinelEndpoint endpoint : sentinelEndpoints) { DataEndpoint normalizedEndpoint = normalizedEndpoint(endpoint.host(), endpoint.port()); @@ -262,8 +261,7 @@ final class RedisSentinelMasterDiscovery { } int[] parsed = new int[8]; - if (!parseIpv6Groups(left, parsed, 0) - || !parseIpv6Groups(right, parsed, 8 - right.length)) { + if (!parseIpv6Groups(left, parsed, 0) || !parseIpv6Groups(right, parsed, 8 - right.length)) { return null; } return parsed; @@ -323,7 +321,8 @@ final class RedisSentinelMasterDiscovery { return true; } if (allZero(address, 6) || (allZero(address, 5) && address[5] == 0xffff)) { - return semanticIpv4(new int[] {address[6] >>> 8, address[6] & 0xff, address[7] >>> 8, address[7] & 0xff}); + return semanticIpv4( + new int[] {address[6] >>> 8, address[6] & 0xff, address[7] >>> 8, address[7] & 0xff}); } return false; } diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java index 5af872e5..034f3c1e 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java @@ -52,7 +52,7 @@ class RedisCacheSecurityEvidenceTest { Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( deployment, settings, 65_536, credentials, trust, Clock.systemUTC())) { runtime.set( diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java index 824edf78..821d65b1 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java @@ -124,7 +124,7 @@ class RedisEfficiencyLeaseEvidenceTest { Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); byte[] hmacSecret = randomSecret(); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( secureDeployment(container), runtimeSettings(), diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java index 43e3daa7..86fb8bfa 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java @@ -122,7 +122,7 @@ class RedisIdempotencyEvidenceTest { Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); byte[] hmacSecret = randomSecret(); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( secureDeployment(container), runtimeSettings(), diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java index 13e50547..6e92cad9 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java @@ -255,7 +255,7 @@ class RedisPrimitiveCatalogEvidenceTest { assertThat(wrongTypeMutation.certainty()) .isEqualTo(RedisPrimitiveMutationResult.Certainty.NOT_APPLIED); - RedisTopologyCommandRuntime lossDelegate = harness.runtime("primitive-loss-delegate"); + RedisRoutableCommandRuntime lossDelegate = harness.runtime("primitive-loss-delegate"); LossAfterApplyRuntime lossRuntime = new LossAfterApplyRuntime(lossDelegate); try (RedisRoleCommandRouter lossRouter = new RedisRoleCommandRouter( @@ -633,14 +633,14 @@ class RedisPrimitiveCatalogEvidenceTest { } private Pod pod(RedisRole role, String clientName) { - RedisTopologyCommandRuntime runtime = runtime(clientName); + RedisRoutableCommandRuntime runtime = runtime(clientName); RedisRoleCommandRouter router = new RedisRoleCommandRouter( role, runtime, 64, 65_536, 4_194_304, Duration.ofSeconds(6), Duration.ofSeconds(30)); return new Pod(runtime, router); } - private RedisTopologyCommandRuntime runtime(String clientName) { + private RedisRoutableCommandRuntime runtime(String clientName) { RedisCredentialMaterialProvider credentials = reference -> new VersionedRedisCredentialMaterial( @@ -678,7 +678,7 @@ class RedisPrimitiveCatalogEvidenceTest { } } - private record Pod(RedisTopologyCommandRuntime runtime, RedisRoleCommandRouter router) + private record Pod(RedisRoutableCommandRuntime runtime, RedisRoleCommandRouter router) implements AutoCloseable { @Override @@ -689,10 +689,10 @@ class RedisPrimitiveCatalogEvidenceTest { private static final class LossAfterApplyRuntime implements RedisRoutableCommandRuntime { - private final RedisTopologyCommandRuntime delegate; + private final RedisRoutableCommandRuntime delegate; private int calls; - private LossAfterApplyRuntime(RedisTopologyCommandRuntime delegate) { + private LossAfterApplyRuntime(RedisRoutableCommandRuntime delegate) { this.delegate = delegate; } diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java index 63fa3e1d..c89061bd 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java @@ -76,7 +76,7 @@ class RedisRateLimitEvidenceTest { Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); byte[] hmacSecret = randomSecret(); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( secureDeployment(container), runtimeSettings(), diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java index 7a5d59c0..48f5dc14 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java @@ -174,7 +174,7 @@ class RedisSemanticReadinessSecurityEvidenceTest { DestroyableRedisPem.from(container.trustPem())); RedisClientRuntimeSettings clientSettings = runtimeSettings(); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( deployment(container), clientSettings, @@ -244,7 +244,7 @@ class RedisSemanticReadinessSecurityEvidenceTest { "semantic-evidence-v1", Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( deployment(container), runtimeSettings(), diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java index 729ed441..7a1af64e 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java @@ -112,7 +112,7 @@ class RedisSessionEvidenceTest { Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); byte[] hmacSecret = randomSecret(); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( secureDeployment(container), runtimeSettings(), diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java index 1fbae1ff..58857241 100644 --- a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java @@ -91,7 +91,7 @@ class RedisSoftLeaseEvidenceTest { Instant.now().plusSeconds(300), DestroyableRedisPem.from(container.trustPem())); byte[] hmacSecret = randomSecret(); - try (RedisTopologyCommandRuntime runtime = + try (RedisRoutableCommandRuntime runtime = RedisTopologyCommandRuntime.connect( secureDeployment(container), runtimeSettings(), diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java index 97179671..0b226ff1 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java @@ -26,8 +26,7 @@ class RedisSentinelMasterDiscoveryTest { new RedisSentinelMasterDiscovery.DataEndpoint("master-b.internal", 6380)); @Test - void returnsTheNormalizedAllowlistedMasterWhenTwoSentinelsAgreeAndOneTimesOut() - throws Exception { + void returnsTheNormalizedAllowlistedMasterWhenTwoSentinelsAgreeAndOneTimesOut() throws Exception { List queried = new ArrayList<>(); RedisSentinelMasterDiscovery.DataEndpoint discovered = @@ -39,9 +38,11 @@ class RedisSentinelMasterDiscoveryTest { queried.add(sentinel); assertThat(masterName).isEqualTo("cache-master"); if (sentinel.host().equals("sentinel-c.internal")) { - throw new TimeoutException("sentinel-c timeout with secret://redis/sentinel-password"); + throw new TimeoutException( + "sentinel-c timeout with secret://redis/sentinel-password"); } - return new RedisSentinelMasterDiscovery.MasterObservation("Master-A.Internal", "6379"); + return new RedisSentinelMasterDiscovery.MasterObservation( + "Master-A.Internal", "6379"); }); assertThat(discovered) @@ -62,7 +63,8 @@ class RedisSentinelMasterDiscoveryTest { new FixedObservations(observations))); } - private static List> nonQuorumObservations() { + private static List> + nonQuorumObservations() { return List.of( List.of( observation("master-a.internal", "6379"), @@ -125,12 +127,14 @@ class RedisSentinelMasterDiscoveryTest { "cache-master", invalidAllowlist, new FixedObservations( - List.of(observation(host, "6379"), observation(host, "6379"), observation(host, "6379"))))); + List.of( + observation(host, "6379"), + observation(host, "6379"), + observation(host, "6379"))))); } private static List syntacticallyInvalidMasterHosts() { - return List.of( - "localhost", "a..b", "a.-b", "a:b", "1.2.3", "256.0.0.1", "::ffff:127.0.0.1"); + return List.of("localhost", "a..b", "a.-b", "a:b", "1.2.3", "256.0.0.1", "::ffff:127.0.0.1"); } @Test @@ -225,7 +229,8 @@ class RedisSentinelMasterDiscoveryTest { })); } - private static RedisSentinelMasterDiscovery.MasterObservation observation(String host, String port) { + private static RedisSentinelMasterDiscovery.MasterObservation observation( + String host, String port) { return new RedisSentinelMasterDiscovery.MasterObservation(host, port); } diff --git a/src/adapter/outbound/objectstorage/CLAUDE.md b/src/adapter/outbound/objectstorage/CLAUDE.md index e665427a..848a96fa 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 fc7f3c1d..b009f7e1 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 0c25eb66..526ad61a 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 6543162f..54a31f6f 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 c71b18d2..73f8f595 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 ac7f8b92..9c30d685 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 00000000..f4e47e0f --- /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 00000000..e9e12e86 --- /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 00000000..2ebb7dfd --- /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 00000000..f35a1fb4 --- /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 00000000..67067d53 --- /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 00000000..0e6077e8 --- /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 00000000..3704a0a2 --- /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 00000000..70535fba --- /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 00000000..9ae8d2fe --- /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 00000000..6e94c0d8 --- /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 00000000..1705a48c --- /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 00000000..9bb2f579 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java @@ -0,0 +1,276 @@ +package dev.caskeleton.adapter.outbound.objectstorage.config; + +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicyRevision; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot; +import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider; +import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor; +import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; +import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; +import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; +import java.nio.file.Path; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Fail-closed compiler from mutable binding input to exact immutable provider/route policy. */ +public final class ObjectStorageBindingCompiler { + + private final Map 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 00000000..d8648f15 --- /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 00000000..a4ff0468 --- /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 00000000..346fd88c --- /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 00000000..eb8e5a2d --- /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 00000000..edaea157 --- /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 00000000..784343e7 --- /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 00000000..da0692f4 --- /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 00000000..58ea89e5 --- /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 00000000..41a53fae --- /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 00000000..2c0559b1 --- /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 00000000..57723eb8 --- /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 00000000..24ed0f52 --- /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 00000000..85aaa9a8 --- /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 00000000..19266694 --- /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 00000000..2aad9351 --- /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 00000000..181741da --- /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 00000000..cb68ae97 --- /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 00000000..fb3b7ff5 --- /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 00000000..96ba2e2a --- /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 00000000..f37e0aba --- /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 00000000..5e557c13 --- /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 00000000..f11454db --- /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 00000000..0d46ec38 --- /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 00000000..8952f710 --- /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 00000000..00219ff8 --- /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 00000000..3c509271 --- /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 00000000..e74088f1 --- /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 00000000..d7ee355f --- /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 00000000..2e8b2678 --- /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 00000000..33e0de98 --- /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 00000000..b33587f0 --- /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 00000000..057db599 --- /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 00000000..9d777251 --- /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 00000000..cec085f7 --- /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 00000000..a72bfdaf --- /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 00000000..6c38f41b --- /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 00000000..fb3f5735 --- /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 00000000..b70eb659 --- /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 00000000..a383f614 --- /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 00000000..ebf7f8ca --- /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 00000000..4078d3d3 --- /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 00000000..038e9855 --- /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 00000000..4708542e --- /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 00000000..d98d11e2 --- /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 00000000..f039dc14 --- /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 00000000..44097b32 --- /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 00000000..768779d2 --- /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 00000000..94bb156c --- /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 00000000..b9409468 --- /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 00000000..225bd102 --- /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 00000000..2cf1d611 --- /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 00000000..f5c77750 --- /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 00000000..91cae220 --- /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 00000000..8c0e5d93 --- /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 00000000..e559bd8b --- /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 00000000..8e3b9fc5 --- /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 00000000..ab238c3d --- /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 00000000..c3501c60 --- /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 00000000..e0ab04a0 --- /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 00000000..f655f016 --- /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 00000000..080de5e2 --- /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 00000000..03f1b0b5 --- /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 00000000..d2c91ffe --- /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 00000000..2a97c122 --- /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 00000000..f4eb50d4 --- /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 00000000..e07d3437 --- /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 00000000..5b1a0bd8 --- /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 00000000..e3dde313 --- /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 00000000..90565016 --- /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 00000000..fdec949b --- /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 00000000..d3e8ae64 --- /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 00000000..5601b9a4 --- /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 00000000..42aee3cf --- /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 00000000..9cb62421 --- /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 00000000..41e544e2 --- /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 00000000..18d77842 --- /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 00000000..cce69c84 --- /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 00000000..3634318c --- /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 00000000..0f91e090 --- /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 00000000..15406877 --- /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 00000000..41a47780 --- /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 00000000..4332c6cc --- /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 00000000..a96a2a89 --- /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 00000000..9c19cf8a --- /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 00000000..83b67717 --- /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 00000000..0d699968 --- /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 00000000..e11be710 --- /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 00000000..4794bc61 --- /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 00000000..d6e5c166 --- /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 00000000..9236dc51 --- /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 00000000..9642d221 --- /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 00000000..a6b25109 --- /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 00000000..27e7f34c --- /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 00000000..95a00035 --- /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 00000000..46013987 --- /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 00000000..46a3676d --- /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 00000000..08961f7d --- /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 00000000..dc53066f --- /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 00000000..5371d008 --- /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 00000000..0be1dcc0 --- /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 00000000..354ba7ec --- /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 00000000..a11b85bc --- /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 00000000..55e5092e --- /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 00000000..eb2acc4d --- /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 00000000..8f6f807c --- /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 00000000..3fa5dfe8 --- /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 00000000..019d1113 --- /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 00000000..eb4a0603 --- /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 00000000..8921803d --- /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 00000000..f1b13054 --- /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 00000000..84396fad --- /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 00000000..329d53f8 --- /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 00000000..f7a57a87 --- /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 00000000..a4180450 --- /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 00000000..5d8d3524 --- /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 00000000..9db6d172 --- /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 00000000..acad9741 --- /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 00000000..acd82d10 --- /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 00000000..dc987d67 --- /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 00000000..669b7a07 --- /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 00000000..4772bcd7 --- /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 00000000..09eccbee --- /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 00000000..f999696a --- /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 00000000..482bf896 --- /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 00000000..d68446f2 --- /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 00000000..54964c9b --- /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 00000000..b2ee18c3 --- /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 00000000..00de3d38 --- /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 00000000..6ee78a77 --- /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 00000000..6c754330 --- /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 00000000..6fdba8a5 --- /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 00000000..40858f58 --- /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 00000000..ed5ca640 --- /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 00000000..843ff6cb --- /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 00000000..69271418 --- /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 00000000..7a58f87e --- /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 00000000..1edea47a --- /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 00000000..38f61d16 --- /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 00000000..2d2da62d --- /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 00000000..605fc6f9 --- /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 00000000..40611830 --- /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 00000000..8474c92e --- /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 00000000..b00dbe72 --- /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 00000000..674f1637 --- /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 00000000..b455e767 --- /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 00000000..5db09bc8 --- /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 00000000..ace0020d --- /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 00000000..f8eee5cf --- /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 00000000..dc9f9f56 --- /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 00000000..d5c64c0b --- /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 00000000..8c02e0a7 --- /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 00000000..dbbfa053 --- /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 00000000..8e3e3a28 --- /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 00000000..8c7560b9 --- /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 00000000..91eb7028 --- /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 00000000..a690e909 --- /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 00000000..f553a33c --- /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 00000000..1624b66f --- /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 00000000..85a81a90 --- /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 00000000..f24f2179 --- /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 00000000..c9ce5db4 --- /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 00000000..5ff36c72 --- /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 00000000..54331d71 --- /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 00000000..40230247 --- /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 00000000..4a57af09 --- /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 00000000..325e11d1 --- /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 00000000..51d6c734 --- /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 00000000..75bb915e --- /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 00000000..d389c9af --- /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 00000000..ae891add --- /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 00000000..d2041916 --- /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 00000000..65e05b1b --- /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 00000000..b707951c --- /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 00000000..71bd20f2 --- /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 00000000..5feb8bd1 --- /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 00000000..405c3e93 --- /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 00000000..23fd021f --- /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 00000000..fa7c910a --- /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 00000000..f63aa1c3 --- /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 00000000..a9796529 --- /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 00000000..9fc3ebe5 --- /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 00000000..f4e41f21 --- /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 00000000..d0b41d15 --- /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 ae7ff73a..280bcc60 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.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @@ -38,14 +39,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; } @@ -56,9 +68,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 } } @@ -75,6 +85,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/notification/crypto/DirectAeadNotificationPayloadCrypto.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java index 652d9e58..4583b15b 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java @@ -34,8 +34,7 @@ public final class DirectAeadNotificationPayloadCrypto { Objects.requireNonNull(plaintext, "notification plaintext must be non-null"); Objects.requireNonNull(context, "notification AAD context must be non-null"); if (plaintext.length < 1 || plaintext.length > 10_000_000) { - throw new IllegalArgumentException( - "notification plaintext must contain 1..10000000 bytes"); + throw new IllegalArgumentException("notification plaintext must contain 1..10000000 bytes"); } requireProfile(context); byte[] nonce = new byte[12]; @@ -97,10 +96,7 @@ public final class DirectAeadNotificationPayloadCrypto { byte[] keyCopy = material.clone(); try { Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init( - mode, - new SecretKeySpec(keyCopy, "AES"), - new GCMParameterSpec(TAG_BITS, nonce)); + cipher.init(mode, new SecretKeySpec(keyCopy, "AES"), new GCMParameterSpec(TAG_BITS, nonce)); cipher.updateAAD(context.canonicalBytes()); return cipher.doFinal(input); } catch (GeneralSecurityException failure) { @@ -113,8 +109,7 @@ public final class DirectAeadNotificationPayloadCrypto { private static void requireProfile(NotificationCiphertext.AadContext context) { if (!PROFILE.equals(context.cryptoProfileVersion())) { - throw new NotificationCryptoException( - "notification AAD crypto profile is unsupported"); + throw new NotificationCryptoException("notification AAD crypto profile is unsupported"); } } } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java index 043c87b1..4beea2bb 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java @@ -30,8 +30,7 @@ public record NotificationCiphertext( NotificationKeyMaterialHandle.requireSlug( "notification crypto profile version", cryptoProfileVersion); aadRevision = - NotificationKeyMaterialHandle.requireSlug( - "notification AAD revision", aadRevision); + NotificationKeyMaterialHandle.requireSlug("notification AAD revision", aadRevision); Objects.requireNonNull(nonce, "notification ciphertext nonce must be non-null"); Objects.requireNonNull(ciphertext, "notification ciphertext bytes must be non-null"); if (nonce.length != 12 || ciphertext.length < 17 || ciphertext.length > 10_000_016) { @@ -72,8 +71,7 @@ public record NotificationCiphertext( @Override public int hashCode() { int result = - Objects.hash( - algorithm, keyReference, keyVersion, cryptoProfileVersion, aadRevision); + Objects.hash(algorithm, keyReference, keyVersion, cryptoProfileVersion, aadRevision); result = 31 * result + Arrays.hashCode(nonce); return 31 * result + Arrays.hashCode(ciphertext); } @@ -114,8 +112,7 @@ public record NotificationCiphertext( deliveryId = requireOptional("notification AAD delivery ID", deliveryId); attemptId = requireOptional("notification AAD attempt ID", attemptId); fieldPurpose = - NotificationKeyMaterialHandle.requireSlug( - "notification AAD field purpose", fieldPurpose); + NotificationKeyMaterialHandle.requireSlug("notification AAD field purpose", fieldPurpose); providerBindingRevision = NotificationKeyMaterialHandle.requireSlug( "notification AAD provider binding revision", providerBindingRevision); @@ -137,8 +134,7 @@ public record NotificationCiphertext( return output.toByteArray(); } - private static Optional requireOptional( - String field, Optional value) { + private static Optional requireOptional(String field, Optional value) { Objects.requireNonNull(value, field + " container must be non-null"); return value.map(item -> requireOpaque(field, item)); } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java index 5ed43970..67b0c318 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java @@ -25,15 +25,11 @@ public final class NotificationHmacDigester { } public Digest digest( - String purpose, - List fields, - String keyReference, - String keyVersion) { + String purpose, List fields, String keyReference, String keyVersion) { byte[] canonical = canonical(purpose, fields); try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) { String value = - handle.readBytes( - material -> HexFormat.of().formatHex(hmac(material, canonical))); + handle.readBytes(material -> HexFormat.of().formatHex(hmac(material, canonical))); return new Digest(handle.keyReference(), handle.keyVersion(), value); } finally { Arrays.fill(canonical, (byte) 0); @@ -56,11 +52,9 @@ public final class NotificationHmacDigester { if (!versions.contains(expected.keyVersion())) { return false; } - Digest actual = - digest(purpose, fields, expected.keyReference(), expected.keyVersion()); + Digest actual = digest(purpose, fields, expected.keyReference(), expected.keyVersion()); return MessageDigest.isEqual( - HexFormat.of().parseHex(expected.value()), - HexFormat.of().parseHex(actual.value())); + HexFormat.of().parseHex(expected.value()), HexFormat.of().parseHex(actual.value())); } private static byte[] canonical(String purpose, List fields) { @@ -88,8 +82,7 @@ public final class NotificationHmacDigester { private static byte[] hmac(byte[] material, byte[] canonical) { if (material.length < 32) { - throw new NotificationCryptoException( - "notification HMAC key revision has an invalid length"); + throw new NotificationCryptoException("notification HMAC key revision has an invalid length"); } byte[] keyCopy = material.clone(); try { @@ -116,11 +109,9 @@ public final class NotificationHmacDigester { NotificationKeyMaterialHandle.requireSlug( "notification HMAC key reference", keyReference); keyVersion = - NotificationKeyMaterialHandle.requireSlug( - "notification HMAC key version", keyVersion); + NotificationKeyMaterialHandle.requireSlug("notification HMAC key version", keyVersion); if (value == null || !value.matches("[0-9a-f]{64}")) { - throw new IllegalArgumentException( - "notification HMAC value must be lowercase SHA-256 hex"); + throw new IllegalArgumentException("notification HMAC value must be lowercase SHA-256 hex"); } } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java index 0561a37b..842aae40 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java @@ -12,8 +12,7 @@ public final class NotificationKeyMaterialHandle implements AutoCloseable { private final byte[] material; private boolean closed; - private NotificationKeyMaterialHandle( - String keyReference, String keyVersion, byte[] material) { + private NotificationKeyMaterialHandle(String keyReference, String keyVersion, byte[] material) { this.keyReference = requireSlug("notification key reference", keyReference); this.keyVersion = requireSlug("notification key version", keyVersion); this.material = material; @@ -23,8 +22,7 @@ public final class NotificationKeyMaterialHandle implements AutoCloseable { String keyReference, String keyVersion, byte[] material) { Objects.requireNonNull(material, "notification key material must be non-null"); if (material.length < 32 || material.length > 65_536) { - throw new IllegalArgumentException( - "notification key material must contain 32..65536 bytes"); + throw new IllegalArgumentException("notification key material must contain 32..65536 bytes"); } return new NotificationKeyMaterialHandle(keyReference, keyVersion, material.clone()); } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java index 6a595068..ff668d54 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java @@ -2,7 +2,9 @@ package dev.caskeleton.adapter.outbound.persistence.notification.crypto; import java.util.List; -/** Acquires versioned mutable key copies and declares bounded current-plus-retiring verification. */ +/** + * Acquires versioned mutable key copies and declares bounded current-plus-retiring verification. + */ public interface NotificationKeyMaterialProvider { NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion); 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 00000000..e210c55f --- /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 78248b09..323d94b7 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 dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; import jakarta.persistence.EntityManager; @@ -35,6 +36,11 @@ public class PostgreSqlPersistenceConfig { return new PostgreSqlLocalTimeoutConfigurer(jdbcOperations); } + @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/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java index 7c57e37e..34723da7 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java @@ -55,8 +55,7 @@ final class TransactionRetryBackoff { Math.addExact( settings.minimumActionWindow().toNanos(), Math.addExact( - settings.beginBudget().toNanos(), - settings.completionMargin().toNanos()))); + settings.beginBudget().toNanos(), settings.completionMargin().toNanos()))); } catch (ArithmeticException exception) { throw new IllegalArgumentException( "retry minimum attempt window exceeds long range", exception); 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 ef1e484b..df1c1ae3 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/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java index 229c47a7..e0a6772b 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java @@ -35,7 +35,8 @@ class NotificationHmacDigesterTest { NotificationHmacDigester digester = new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard()); NotificationHmacDigester.Digest retiring = - digester.digest("intent-dedupe-v1", List.of("tenant-42", "scope-42"), "alias-key", "hmac-r1"); + digester.digest( + "intent-dedupe-v1", List.of("tenant-42", "scope-42"), "alias-key", "hmac-r1"); assertThat(digester.verify(retiring, "intent-dedupe-v1", List.of("tenant-42", "scope-42"))) .isTrue(); @@ -43,8 +44,7 @@ class NotificationHmacDigesterTest { .isFalse(); assertThat( digester.verify( - new NotificationHmacDigester.Digest( - "alias-key", "hmac-r9", retiring.value()), + new NotificationHmacDigester.Digest("alias-key", "hmac-r9", retiring.value()), "intent-dedupe-v1", List.of("tenant-42", "scope-42"))) .isFalse(); @@ -54,9 +54,7 @@ class NotificationHmacDigesterTest { @Override public NotificationKeyMaterialHandle acquire(String reference, String version) { return NotificationKeyMaterialHandle.fromBytes( - reference, - version, - NotificationPayloadCryptoTest.InMemoryKeys.HMAC_R2); + reference, version, NotificationPayloadCryptoTest.InMemoryKeys.HMAC_R2); } @Override @@ -67,10 +65,7 @@ class NotificationHmacDigesterTest { assertThatThrownBy( () -> new NotificationHmacDigester(oversized) - .verify( - retiring, - "intent-dedupe-v1", - List.of("tenant-42", "scope-42"))) + .verify(retiring, "intent-dedupe-v1", List.of("tenant-42", "scope-42"))) .isInstanceOf(NotificationCryptoException.class) .hasMessageContaining("bounded"); } diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java index 837f9e25..c075c7d4 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java @@ -22,10 +22,8 @@ class NotificationPayloadCryptoTest { NotificationCiphertext.AadContext context = context("record-42", "parameters"); byte[] plaintext = "recipient-secret-value".getBytes(StandardCharsets.UTF_8); - NotificationCiphertext first = - crypto.encrypt(plaintext, context, "payload-key", "payload-r2"); - NotificationCiphertext second = - crypto.encrypt(plaintext, context, "payload-key", "payload-r2"); + NotificationCiphertext first = crypto.encrypt(plaintext, context, "payload-key", "payload-r2"); + NotificationCiphertext second = crypto.encrypt(plaintext, context, "payload-key", "payload-r2"); assertThat(first.algorithm()).isEqualTo("AES-256-GCM"); assertThat(first.cryptoProfileVersion()).isEqualTo("notification-direct-aead-v1"); @@ -46,16 +44,10 @@ class NotificationPayloadCryptoTest { NotificationCiphertext.AadContext recipient = context("record-43", "recipient"); NotificationCiphertext encrypted = crypto.encrypt( - "secret-A".getBytes(StandardCharsets.UTF_8), - parameters, - "payload-key", - "payload-r2"); + "secret-A".getBytes(StandardCharsets.UTF_8), parameters, "payload-key", "payload-r2"); NotificationCiphertext other = crypto.encrypt( - "secret-B".getBytes(StandardCharsets.UTF_8), - recipient, - "payload-key", - "payload-r2"); + "secret-B".getBytes(StandardCharsets.UTF_8), recipient, "payload-key", "payload-r2"); assertThatThrownBy(() -> crypto.decrypt(encrypted, recipient)) .isInstanceOf(NotificationCryptoException.class) @@ -131,8 +123,7 @@ class NotificationPayloadCryptoTest { private final Map keys; private final Map> verificationVersions; - private InMemoryKeys( - Map keys, Map> verificationVersions) { + private InMemoryKeys(Map keys, Map> verificationVersions) { this.keys = keys; this.verificationVersions = verificationVersions; } diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java index f8b58449..0853eaae 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java @@ -91,14 +91,9 @@ class TransactionRetryBackoffTest { 3); TransactionRetryBackoff backoff = new TransactionRetryBackoff( - settings, - Duration.ofSeconds(1), - now::get, - slept::addAndGet, - bound -> bound - 1); + settings, Duration.ofSeconds(1), now::get, slept::addAndGet, bound -> bound - 1); - boolean retry = - backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(2)), 1); + boolean retry = backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(2)), 1); assertThat(retry).isFalse(); assertThat(slept).hasValue(0); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java index bbb6a522..43434fe3 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java @@ -6,7 +6,6 @@ import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.EvaluationResult; import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; -import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase; import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; 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 fbc00db1..caa8bf98 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() @@ -1565,6 +1583,30 @@ class CleanArchitectureTest { || iface.getPackageName().endsWith(".application"))); } + @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 d4584b4e..bf9a54d4 100644 --- a/src/application-core/build.gradle +++ b/src/application-core/build.gradle @@ -2,6 +2,9 @@ // 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' } sourceSets { diff --git a/src/application-core/gradle.lockfile b/src/application-core/gradle.lockfile index d97cb37d..984f7842 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,redisPolicyCo javax.inject:javax.inject:1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs net.bytebuddy:byte-buddy:1.17.8=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-api:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-engine:1.9.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath +net.jqwik:jqwik-time:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-web:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,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=redisPolicyContractTestCompileClasspath,testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.27.6=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,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 00000000..f9314f55 --- /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 00000000..7222b973 --- /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 00000000..c9f19765 --- /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 00000000..d33840cc --- /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 00000000..09053380 --- /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 00000000..995decbf --- /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 00000000..495d1b0d --- /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 00000000..a884ef0e --- /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 00000000..d435bfa5 --- /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 00000000..0f2b2925 --- /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 00000000..698c8784 --- /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 00000000..f172ccb4 --- /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 00000000..814ec7a3 --- /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 00000000..ef676437 --- /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 00000000..20bc9107 --- /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 00000000..3a4bf6b8 --- /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 00000000..09ff1632 --- /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 00000000..19132d36 --- /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 00000000..d134f89c --- /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 00000000..f8203cc0 --- /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 00000000..862f2953 --- /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 00000000..c294b33e --- /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 00000000..7461c087 --- /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 00000000..81c01b83 --- /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 00000000..8d1901e5 --- /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 00000000..a19a2e87 --- /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 00000000..aa77fba6 --- /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 00000000..3ee83da7 --- /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 00000000..46708951 --- /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 00000000..64130c4f --- /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 00000000..d76eef83 --- /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 00000000..8bb5d2df --- /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 00000000..abee02c1 --- /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 00000000..1bf20494 --- /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 00000000..65545565 --- /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 00000000..77a8e60d --- /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 00000000..ee68153a --- /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 00000000..6c764de4 --- /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 00000000..6af07925 --- /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 00000000..e6c8efd9 --- /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 00000000..1e2c4a52 --- /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 00000000..25d18caa --- /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 00000000..49504070 --- /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 00000000..b5ba1764 --- /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 00000000..ca3fdeb3 --- /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 00000000..9b3d544e --- /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 00000000..6b32bbb7 --- /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 00000000..cbe679bd --- /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 00000000..a2bcdc19 --- /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 00000000..6c201483 --- /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 00000000..076ee0c0 --- /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 00000000..cbc8eba9 --- /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 00000000..c72eca96 --- /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 00000000..e82eacd8 --- /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 00000000..37dcd58a --- /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 00000000..8fe87e8a --- /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 00000000..8708c68c --- /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 00000000..cd73dbd1 --- /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 00000000..854352d3 --- /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 00000000..1bdd2175 --- /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 00000000..25987f91 --- /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 00000000..f878138e --- /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 00000000..07356d45 --- /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 00000000..1cbb4f33 --- /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 00000000..073eedd0 --- /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 00000000..68e79597 --- /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 00000000..862a4ec6 --- /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 00000000..9a36f5f8 --- /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 00000000..7e4faa61 --- /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 00000000..a7bacf50 --- /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 00000000..87fd5592 --- /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 00000000..a983befc --- /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 00000000..7d90bbb8 --- /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 00000000..f45ed33c --- /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 00000000..c5f7d98f --- /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 00000000..c19ce4a5 --- /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 00000000..081ad0cf --- /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 00000000..4a2117c9 --- /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 00000000..c72bf9da --- /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 00000000..9a71139e --- /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 00000000..88dd1409 --- /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 00000000..ccb111f4 --- /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 00000000..adc4847b --- /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 00000000..36b1866a --- /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 00000000..4a1c8ea2 --- /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 00000000..6a257c39 --- /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 00000000..eda56c8f --- /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 00000000..f49b3573 --- /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 c89f6e1b..980801a3 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 c75955f5..39c8e26e 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 00000000..3b71ff1c --- /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 00000000..51b7620d --- /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 00000000..3db5ab39 --- /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 00000000..d04073df --- /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 00000000..7842e702 --- /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 00000000..94da6b82 --- /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 00000000..b97fa10a --- /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 00000000..42772a03 --- /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 00000000..bbaf3013 --- /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 00000000..3e7bbaf9 --- /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 00000000..10f6dbb9 --- /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/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle index f078688f..70543f90 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 V8 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 2e814204..5153fe4e 100644 --- a/src/sample-portfolio/gradle.lockfile +++ b/src/sample-portfolio/gradle.lockfile @@ -1,336 +1,336 @@ # 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.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,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.session:spring-session-core:4.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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 00000000..4383bdc8 --- /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 2574c611..8cdf94fe 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 00000000..ee0ea3e6 --- /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 00000000..7f0a034d --- /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 00000000..628d8281 --- /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 00000000..5b6b5019 --- /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 00000000..85936e15 --- /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 00000000..8d4c35c9 --- /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 00000000..3b2871c2 --- /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 0ecf1f9b..1643068e 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 00000000..9aec1a74 --- /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 00000000..25af5bbb --- /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 00000000..bbb84b2a --- /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 V8 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 00000000..5c39b4c0 --- /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 V8 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 00000000..bcd11daa --- /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 V8 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 2316e1c7..ab7110c2 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 00000000..a80817de --- /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 V8 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 00000000..f91f9f63 --- /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 00000000..f80bc628 --- /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 00000000..cf9e5210 --- /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 00000000..f77bebc4 --- /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 00000000..e7cb1dd3 --- /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 00000000..ee87cc60 --- /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 00000000..82123abb --- /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 00000000..8607ae4a --- /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 00000000..fe49ea26 --- /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 00000000..184a947c --- /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 00000000..e8366316 --- /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 00000000..423143a4 --- /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 00000000..382c237a --- /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 00000000..1225b146 --- /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 00000000..d3b3a566 --- /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 00000000..0854c234 --- /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 dd24480f..ee60d111 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 00000000..7b6da5a6 --- /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 00000000..d0b0247b --- /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 00000000..d46d3900 --- /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 00000000..045e5045 --- /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 00000000..7fca3229 --- /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 ed5f5b6d..0b47746b 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 00000000..4360a5bb --- /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 00000000..3bc22e5c --- /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 00000000..e8b2627a --- /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 00000000..89c58d9a --- /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 00000000..365aa058 --- /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 00000000..8942d0e0 --- /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 V8 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 00000000..8ee31654 --- /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 00000000..8b2c1571 --- /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 00000000..47ad1f53 --- /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 00000000..df4a7298 --- /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 00000000..8296b70f --- /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 00000000..7e1e98eb --- /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 00000000..37781303 --- /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 00000000..414d439b --- /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 00000000..70f9a501 --- /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 00000000..e09bdc13 --- /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 00000000..a8c5ca6c --- /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 00000000..704e81f2 --- /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 00000000..c82687b8 --- /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 00000000..b9c58652 --- /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 00000000..e244d9d7 --- /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 00000000..8f0fb805 --- /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 00000000..14779e7d --- /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 00000000..77e0f7df --- /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 00000000..d74d7672 --- /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 00000000..b98ee979 --- /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 00000000..6e5cbe3f --- /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 00000000..47e21347 --- /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 00000000..87596925 --- /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 00000000..5fdb29ac --- /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 00000000..95524c51 --- /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 00000000..ec99f77c --- /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 00000000..3de564b4 --- /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 00000000..e23d00af --- /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 00000000..2fd37160 --- /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 f4dc108b..dac991c3 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.PostgreSqlLocalTimeoutConfigurer; import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository; import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; @@ -42,6 +44,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 00000000..a819a0b4 --- /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 13c7c93b..9e14d56f 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 V8 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 V8 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 00000000..3134d5ca --- /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 00000000..9c668cb7 --- /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 00000000..2eb1b3fa --- /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 184be211..06f582be 100644 --- a/src/sample-portfolio/src/main/resources/application.yml +++ b/src/sample-portfolio/src/main/resources/application.yml @@ -242,6 +242,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} @@ -276,3 +283,41 @@ app: provider: ${APP_NOTIFICATION_SLACK_PROVIDER:} email: provider: ${APP_NOTIFICATION_EMAIL_PROVIDER:} + outbound: + http: + connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT:2s} + read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT:5s} + global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT:10s} + maximum-in-flight-calls: ${APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS:128} + retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} + retry: + max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} + initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms} + backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0} + circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false} + circuit-breaker: + failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50} + sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100} + minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100} + 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/V8__poster_image_publication.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql new file mode 100644 index 00000000..c9f3be3f --- /dev/null +++ b/src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql @@ -0,0 +1,128 @@ +-- Additive, forward-only Poster image publication state. V8 follows the V7 Poster schema. +-- +-- 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 00000000..eaea8b6f --- /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 00000000..877612a0 --- /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/PosterImageV8MigrationQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java new file mode 100644 index 00000000..90ccf4f7 --- /dev/null +++ b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.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 PosterImageV8MigrationQualificationTest { + + @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("8"); + + 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 v7LegacyRowsAndOldBinaryProjectionSurviveV8Upgrade() throws Exception { + flyway("7").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 ed8e0dc0..e7b3381d 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 00000000..f71271c6 --- /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 00000000..9b50ab53 --- /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 00000000..178c4cb3 --- /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 00000000..63ec7e32 --- /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/postgresql,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 475dc5ee..22d40be4 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 00000000..cc195ae0 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java @@ -0,0 +1,143 @@ +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 inRootWrite(Supplier action) { + return inWrite(action); + } + + @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 00000000..8e403cfb --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java @@ -0,0 +1,158 @@ +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 inRootWrite(Supplier action) { + assertThat(inWrite).isFalse(); + return inWrite(action); + } + + @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 00000000..84b5401c --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java @@ -0,0 +1,314 @@ +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 inRootWrite(java.util.function.Supplier action) { + return inWrite(action); + } + + @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 00000000..b9104989 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java @@ -0,0 +1,191 @@ +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 inRootWrite(Supplier action) { + return inWrite(action); + } + + @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 00000000..ed626a7b --- /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 00000000..d3c65058 --- /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 00000000..78e324bd --- /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 00000000..0dfc23aa --- /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 fe6468e3..a81e9a09 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 ed9c4554..f9f38b37 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