feat: add object storage production capability
This commit is contained in:
@@ -143,3 +143,31 @@ gates:
|
||||
workflow: link-check.yml
|
||||
job: lychee
|
||||
execution: job
|
||||
- id: object-storage-minio-managed-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioContractTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-contract
|
||||
execution: explicit
|
||||
- id: poster-image-v7-migration
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: posterImageMigrationTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: poster-image-v7-migration
|
||||
execution: explicit
|
||||
- id: object-storage-minio-managed-fault
|
||||
release_blocking: conditional
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioFaultTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-fault
|
||||
execution: explicit
|
||||
- id: object-storage-aws-protected-qualification
|
||||
release_blocking: conditional
|
||||
mechanism: delegated-pending
|
||||
ref: approval-gate-b
|
||||
workflow: object-storage-qualification.yml
|
||||
job: aws-managed-common-subset
|
||||
execution: job
|
||||
|
||||
@@ -5,7 +5,7 @@ readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
||||
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
||||
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
||||
readonly EXPECTED_GATE_COUNT=19
|
||||
readonly EXPECTED_GATE_COUNT=23
|
||||
|
||||
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
name: object-storage-qualification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "23 3 * * 2"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_protected_aws:
|
||||
description: Run the protected AWS sandbox qualification lane
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||
|
||||
jobs:
|
||||
poster-image-v7-migration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run non-skipping Poster image V7 migration qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
||||
|
||||
minio-managed-contract:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run exact-release MinIO managed contract
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
|
||||
|
||||
minio-managed-fault:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
|
||||
|
||||
aws-managed-common-subset:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
|
||||
environment: object-storage-aws-qualification
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
|
||||
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
|
||||
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
|
||||
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run protected AWS common-subset qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
|
||||
@@ -0,0 +1,81 @@
|
||||
# Object Storage Batch A Checkpoint
|
||||
|
||||
- Date: 2026-07-28
|
||||
- Branch: `codex/objectstorage-production-capability`
|
||||
- Worktree:
|
||||
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||
- Claimed level: R0 application contract only
|
||||
- Provider readiness advanced: no
|
||||
|
||||
## Implemented scope
|
||||
|
||||
- Characterized the legacy caller-key overwrite, whole-object materialization, locator exposure,
|
||||
eager filesystem directory creation, optional S3 bucket provisioning, and Poster transaction/API
|
||||
coupling without changing those behaviors.
|
||||
- Added provider-neutral identities, opaque checked references/handles, bounded streaming
|
||||
callbacks, content identity, digest/range values, requests, receipts, outcomes, and narrow ports
|
||||
under `dev.caskeleton.application.objectstorage`.
|
||||
- Required an `ObjectOperationKey` on mutation requests and separated normal publication,
|
||||
scan-maintenance, purge-maintenance, direct, and staged privilege surfaces.
|
||||
- Added recursive contract-purity tests and an ArchUnit freeze for the one existing sample legacy
|
||||
import.
|
||||
- Marked the legacy `ObjectStoragePort` and `StoredObject` as removal boundaries without adapting
|
||||
new semantic calls back to raw keys.
|
||||
|
||||
No provider-neutral kernel, canonical namespace/control codec, local R1 provider, S3/MinIO
|
||||
qualification, sample migration, or R2 readiness claim is included.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
The planned RED checks failed only for the intentionally missing types or removal annotations:
|
||||
|
||||
- `ObjectStorageIdentityContractTest`: missing identity types before Task 2 implementation.
|
||||
- `ObjectContentContractTest` and `ObjectStorageValueContractTest`: missing content/value types
|
||||
before Task 3 implementation.
|
||||
- `ObjectStoragePortContractTest`: missing request/receipt/port family before Task 4 implementation.
|
||||
- `ObjectStorageArchitectureContractTest`: missing legacy removal annotations before Task 5
|
||||
implementation.
|
||||
|
||||
An initial ArchUnit DSL compilation error was a test-authoring error, not accepted as a RED result;
|
||||
the rule was corrected and rerun.
|
||||
|
||||
## GREEN verification
|
||||
|
||||
All commands ran from `src/` and completed with `BUILD SUCCESSFUL`:
|
||||
|
||||
```bash
|
||||
./gradlew :application-core:resolveAndLockAll --write-locks
|
||||
./gradlew :application-core:verifyDependencyLocks --console=plain
|
||||
./gradlew :application-core:test --tests '*ObjectStorageIdentityContractTest' --console=plain
|
||||
./gradlew :application-core:test \
|
||||
--tests '*ObjectContentContractTest' \
|
||||
--tests '*ObjectStorageValueContractTest' --console=plain
|
||||
./gradlew :application-core:test --tests '*ObjectStoragePortContractTest' --console=plain
|
||||
./gradlew :application-core:test \
|
||||
--tests '*ObjectStorageArchitectureContractTest' --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew :application-core:check --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain
|
||||
```
|
||||
|
||||
The final combined legacy focused suites completed in 27 seconds. Deprecation-for-removal warnings
|
||||
are expected evidence that legacy consumers remain visible; they are not suppressed.
|
||||
|
||||
## LLM Wiki capture
|
||||
|
||||
The canonical vault required by repository policy,
|
||||
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
|
||||
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
|
||||
`raw/branch-notes/codex-objectstorage-production-capability.md` could not be created or updated.
|
||||
No similarly named non-canonical clone was used. This exact access block is recorded in both the
|
||||
plan and design headers and here at the Batch A boundary.
|
||||
|
||||
## Remaining gates and risks
|
||||
|
||||
- External broker and REST consumers and deployed legacy data were not inspected; Gate A remains
|
||||
blocked for legacy removal or public API versioning.
|
||||
- The new contracts have no provider implementation yet.
|
||||
- The current legacy adapter retains whole-object and raw-locator behavior by design until the
|
||||
later migration batch.
|
||||
- No readiness registry row is promoted by this checkpoint.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Object Storage Batch B Checkpoint
|
||||
|
||||
- Date: 2026-07-28
|
||||
- Branch: `codex/objectstorage-production-capability`
|
||||
- Worktree:
|
||||
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||
- Evidence grade: repository-local non-skipping unit/contract/application-context tests
|
||||
- Advanced cards: local managed single upload R1, local managed download R1
|
||||
- R2 or production-provider readiness advanced: no
|
||||
|
||||
## Implemented scope
|
||||
|
||||
- Added deterministic data/control namespaces, opaque reference/handle codecs, canonical request
|
||||
fingerprints, frozen binding/policy revisions, and bounded operation epochs.
|
||||
- Added six strict canonical JSON control-record families with fixed field order, outer SHA-256
|
||||
envelopes, schema/size checks, corruption rejection, and checked-in golden digests.
|
||||
- Added provider-neutral publication, scan, reference, direct-session, multipart, and pending-effect
|
||||
state transitions with same-operation replay and conflicting-intent rejection.
|
||||
- Added a provider contract and `filesystem-local-dev` implementation with bounded streaming,
|
||||
immutable exclusive create, SHA-256 verification, exact inspect/version, full/range transfer,
|
||||
conditional retirement, create resolution, restrictive permissions, and path/symlink
|
||||
confinement.
|
||||
- Added single-process exact-version control CAS and restart/corruption/fault characterization.
|
||||
Logical control keys use `.record` physical leaves locally so object-store-valid prefix/leaf key
|
||||
pairs cannot collide as filesystem file/directory paths.
|
||||
- Added constructor-bound `app.object-storage` settings and compile-before-construction
|
||||
provider/destination/route/policy binding. The capability is disabled by default and
|
||||
`filesystem-local-dev` is rejected for `prod`/`production`.
|
||||
- Added disabled, unselected, invalid, selected-success, selected-construction-failure, close,
|
||||
legacy-only, and namespace-separated dual-run composition tests.
|
||||
- Added semantic routing evidence for publish, replay without producer invocation, inspect,
|
||||
full transfer, absent reference, and exact retained route lookup.
|
||||
- Added the exact nine-card readiness registry. Only local managed single upload/download are R1;
|
||||
direct, multipart, quarantine, retention, and production reconciliation remain R0.
|
||||
|
||||
## TDD and defect evidence
|
||||
|
||||
Planned RED checks failed for the intentionally absent codec/kernel/provider/settings/readiness
|
||||
types before each implementation. Additional tests found and drove these corrections:
|
||||
|
||||
- Local control keys may legally have both a leaf and a child in object storage, while a filesystem
|
||||
cannot have both `reference` and `reference/lifecycle`; local physical `.record` mapping fixed the
|
||||
collision without changing logical keys.
|
||||
- `ObjectInspectionPort.inspect` initially threw for an absent known-route reference; it now
|
||||
returns `Optional.empty()` while incomplete/corrupt evidence still fails closed.
|
||||
- The application purity test initially scanned its own test output after a full `check`; it now
|
||||
derives the production class root from a production contract type.
|
||||
- The general B7 ArchUnit rule initially classified objectstorage provider-internal SPI/control
|
||||
return values as public adapter responses. The existing negative fixture remains active, while a
|
||||
dedicated non-empty rule now checks the actual objectstorage `*Adapter` semantic boundaries.
|
||||
|
||||
No skipped Docker or external-service test is used as Batch B readiness evidence.
|
||||
|
||||
## GREEN verification
|
||||
|
||||
All commands ran from `src/` unless noted and completed with `BUILD SUCCESSFUL` after the documented
|
||||
RED/fix cycles:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*ObjectNamespaceCodecTest' \
|
||||
--tests '*ObjectRequestFingerprintCodecTest' \
|
||||
--tests '*ObjectOperationEpochTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*ObjectControlRecordCodecTest' \
|
||||
--tests '*ObjectOperationStateMachineTest' \
|
||||
--tests '*ObjectOperationKernelTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*ObjectStorageProviderContract' \
|
||||
--tests '*LocalDevObjectStorageProviderTest' \
|
||||
--tests '*LocalDevObjectStorageRecoveryTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*ObjectStorageBindingCompilerTest' \
|
||||
--tests '*ObjectStorageCapabilityConfigTest' \
|
||||
--tests '*RoutingObjectStorageAdapterTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
|
||||
./gradlew :sample-portfolio:test --console=plain
|
||||
./gradlew :application-core:check \
|
||||
:adapter:outbound:objectstorage:check --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
```
|
||||
|
||||
The final combined application/objectstorage checkpoint completed in 23 seconds. The focused
|
||||
Clean Architecture suite and dependency verification also passed.
|
||||
|
||||
## LLM Wiki capture
|
||||
|
||||
The canonical vault required by repository policy,
|
||||
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
|
||||
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
|
||||
`raw/branch-notes/codex-objectstorage-production-capability.md` and any derived raw documents could
|
||||
not be created or updated. No similarly named non-canonical clone was used. This exact access block
|
||||
is recorded in the plan/design status and at this Batch B boundary.
|
||||
|
||||
## Remaining gates and risks
|
||||
|
||||
- `filesystem-local-dev` has no multi-node linearizability or power-loss durability evidence and is
|
||||
forbidden in production profiles.
|
||||
- The canonical S3/MinIO provider contribution, async bounded transport, provider qualification,
|
||||
response-loss fault tests, and protected AWS evidence are not implemented.
|
||||
- Direct grants, multipart, quarantine/scan, retention/legal hold, privileged purge, reapers, and
|
||||
production reconciliation remain R0.
|
||||
- The sample Poster workflow still uses the deprecated whole-`byte[]` port and transaction-coupled
|
||||
legacy choreography. It is explicitly activated only in sample local/test configuration.
|
||||
- External API/broker consumers and deployed legacy data remain uninspected, so Gate A still blocks
|
||||
destructive migration or legacy removal.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Object Storage Batch C Checkpoint
|
||||
|
||||
- Date: 2026-07-28
|
||||
- Branch: `codex/objectstorage-production-capability`
|
||||
- Worktree:
|
||||
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||
- Evidence grade: repository-local tests plus digest-pinned single-node MinIO/Toxiproxy tests
|
||||
- AWS execution: not authorized; source set compiled only
|
||||
- Production-provider readiness advanced: no
|
||||
|
||||
## Implemented scope
|
||||
|
||||
- Added exact AWS S3 and MinIO provider bindings, bounded evidence descriptors, qualifier/error
|
||||
mapping, secret references, endpoint/owner/addressing validation, and selected-only lifecycle
|
||||
construction.
|
||||
- Added bounded async request/response bridges and the managed S3 put, inspect, full/range download,
|
||||
checksum, exact-version, cancellation, and content-length paths.
|
||||
- Added canonical conditional S3 control storage and operation response-loss resolution. Provider
|
||||
ETags remain adapter-private and are never exposed as logical versions.
|
||||
- Added low-level managed multipart planning, sharded immutable part ledgers, initiate-before-I/O
|
||||
state, explicit create/upload/list/complete/abort calls, and exact completion verification.
|
||||
- Added non-skipping MinIO contract/fault lanes, an AWS compile-only qualification lane, a protected
|
||||
workflow, and gate-matrix coverage.
|
||||
|
||||
The exact MinIO image is
|
||||
`minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8`.
|
||||
The exact Toxiproxy image is
|
||||
`ghcr.io/shopify/toxiproxy@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e`.
|
||||
|
||||
## Exact MinIO finding
|
||||
|
||||
Real-provider tests proved an asymmetric conditional profile:
|
||||
|
||||
- `PutObject If-None-Match: *` was accepted but overwrote an existing object.
|
||||
- stale `PutObject If-Match` was rejected with HTTP 412.
|
||||
- `CompleteMultipartUpload If-None-Match: *` was accepted and overwrote an existing object.
|
||||
- checksum, HEAD, and range behavior passed the exercised contract.
|
||||
|
||||
Because immutable create and create-if-absent control CAS cannot be proven, the exact MinIO managed
|
||||
and direct mutation profiles remain `UNSUPPORTED`. The implementation does not emulate missing
|
||||
atomicity with HEAD followed by an unconditional write and does not promote a readiness card.
|
||||
|
||||
## TDD and verification
|
||||
|
||||
The task-focused RED runs first failed on the planned absent binding, bridge, conditional store,
|
||||
multipart, and qualification types. Provider qualification then found the real MinIO conditional
|
||||
behavior above; the descriptor and negative contract were changed instead of weakening the
|
||||
contract.
|
||||
|
||||
Commands completed with `BUILD SUCCESSFUL`:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*S3ProviderBindingTest' \
|
||||
--tests '*S3ProviderQualifierTest' \
|
||||
--tests '*S3ProviderCompositionTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
|
||||
verifyCleanArchitectureDependencies --console=plain
|
||||
bash ../.github/scripts/verify-gate-matrix.sh
|
||||
```
|
||||
|
||||
The gate matrix reports 22 gates: 21 verified and the protected AWS qualification gate explicitly
|
||||
`delegated-pending`.
|
||||
|
||||
## LLM Wiki capture
|
||||
|
||||
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
|
||||
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
|
||||
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
|
||||
be created or updated. No similarly named non-canonical clone was used.
|
||||
|
||||
## Remaining risks
|
||||
|
||||
- No AWS request was executed, so there is no observed AWS provider claim.
|
||||
- The pinned MinIO topology is a local single-node container and is not production TLS,
|
||||
multi-node, durability, or linearizability evidence.
|
||||
- The detailed managed multipart fault matrix is not exhaustive enough for R2.
|
||||
- No sample migration, public API, scan/publication choreography, retention, purge, or reaper is
|
||||
included in this checkpoint.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Object Storage Batch D Checkpoint
|
||||
|
||||
- Date: 2026-07-28
|
||||
- Branch: `codex/objectstorage-production-capability`
|
||||
- Worktree:
|
||||
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||
- Scope: direct-transfer provider/application primitives only
|
||||
- Public endpoint: none
|
||||
- Readiness advanced: no; all direct cards remain R0
|
||||
|
||||
## Implemented scope
|
||||
|
||||
- Added direct single-upload session policy, durable prepared/issued transitions, bearer
|
||||
redaction, exact completion verification, published-version download resolution, and an
|
||||
S3-presigner lifecycle owned by the selected provider.
|
||||
- Added direct multipart durable session and part-grant families, opaque acknowledgement tokens,
|
||||
sharded part records, admission-close/expiry fencing, exact ledger validation, completion/abort
|
||||
states, response-loss resolution, and persisted terminal exact-version replay.
|
||||
- Added direct S3 initiate/discovery, exact-part presign, `ListParts` acknowledgement, conditional
|
||||
complete followed by exact HEAD verification, and abort resolution.
|
||||
- Registered direct single and multipart delegates only when their exact compiled capability is
|
||||
selected. One presigner is constructed and closed exactly once.
|
||||
- Added golden canonical envelopes for the direct session, direct multipart session, and direct
|
||||
multipart grant families.
|
||||
- Fixed `MultipartCompleteRequest` null validation so valid immutable `List.of(...)` input no longer
|
||||
throws from `contains(null)`.
|
||||
|
||||
## Qualification truth
|
||||
|
||||
The exact MinIO release cannot prove create-only PUT or create-only multipart completion, so both
|
||||
direct profiles are explicitly `UNSUPPORTED`. The direct MinIO contract/fault lanes are negative
|
||||
admission tests: they prove no bearer or multipart mutation enters an unsupported profile. No test
|
||||
skip is used as positive evidence.
|
||||
|
||||
The AWS managed/direct source sets compile, but no AWS call was made and no AWS evidence row was
|
||||
published. No inbound controller, authorization surface, CORS runtime configuration, or public
|
||||
direct API exists.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands completed with `BUILD SUCCESSFUL`:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*DirectTransferCoordinatorTest' \
|
||||
--tests '*PresignedGrantRedactionTest' \
|
||||
--tests '*S3DirectTransferProviderTest' \
|
||||
--tests '*ObjectControlRecordCodecTest' \
|
||||
--tests '*S3ProviderCompositionTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*DirectMultipartCoordinatorTest' \
|
||||
--tests '*DirectMultipartRaceTest' \
|
||||
--tests '*S3DirectMultipartProviderTest' \
|
||||
--tests '*ObjectControlRecordCodecTest' \
|
||||
--tests '*S3ProviderCompositionTest' --console=plain
|
||||
./gradlew \
|
||||
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
|
||||
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
|
||||
--tests '*DirectTransfer*' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*DirectTransferCorsContractTest' --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
|
||||
verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew \
|
||||
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
|
||||
:adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
|
||||
bash ../.github/scripts/verify-gate-matrix.sh
|
||||
./gradlew test --console=plain
|
||||
./gradlew check --console=plain
|
||||
```
|
||||
|
||||
The module `check` includes unit tests, Checkstyle, Spotless, SpotBugs, architecture, configuration
|
||||
processor, environment-key, and repository-wide policy checks. Existing test-only compiler
|
||||
warnings remain non-failing. The final repository-wide test run completed 79 tasks and the final
|
||||
repository-wide check completed 214 tasks.
|
||||
|
||||
## Deliberate limitations
|
||||
|
||||
- Issued bearer material is process-local. A restart fails closed instead of reconstructing or
|
||||
reissuing an already-issued bearer.
|
||||
- The signing clock/window is stored and bounded, but AWS SDK presigner query timing is not driven
|
||||
by the injected application clock.
|
||||
- The direct multipart recovery/race matrix covers its principal fences and completion response
|
||||
loss but is not exhaustive enough for an R2 claim.
|
||||
- Retention/Object Lock grant headers and a provider-enforced direct-single hard size ceiling are
|
||||
not qualified.
|
||||
- No public endpoint exists, so CORS evidence is a pure contract and no direct card may exceed R0
|
||||
in the current registry.
|
||||
|
||||
## Approval Gate A
|
||||
|
||||
Tasks 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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Object Storage Batch E Pause Checkpoint
|
||||
|
||||
- Recorded: 2026-07-29 (Asia/Seoul)
|
||||
- Branch: `codex/objectstorage-production-capability`
|
||||
- Worktree:
|
||||
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||
- Status: implementation in progress; intentionally paused at the user's request
|
||||
- Evidence grade: local unit/integration/architecture evidence only; no AWS R2 evidence
|
||||
|
||||
## Implemented at this checkpoint
|
||||
|
||||
- Staged integrity verification, fake-scanner routing, publication handoff fencing, and stable
|
||||
replay receipts.
|
||||
- Additive Poster V7 dual-read schema, upload/retirement intents, HMAC-sanitized idempotency scope,
|
||||
PostgreSQL atomic claim SPI, and forward-only migration qualification lane.
|
||||
- Short-transaction Poster image publication flow and additive locator-free `202` API under the
|
||||
AIP-122-compatible `/posters/{id}/imagePublications` collection.
|
||||
- Exact-reference/version logical retirement enqueue, lease/fence takeover, response-loss retry,
|
||||
Poster deletion survival, and disabled-by-default worker composition.
|
||||
- Isolated legacy migration contracts, report-only inspection, two-distinct-approver Ed25519
|
||||
approval verification, nonce replay boundary, and explicit maintenance-only composition.
|
||||
|
||||
## Verification completed
|
||||
|
||||
The following focused command passed after the final architecture fixes:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew \
|
||||
:sample-portfolio:spotlessApply \
|
||||
:sample-portfolio:test --tests '*PosterImagePublicationControllerWireTest' \
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest' \
|
||||
--console=plain
|
||||
```
|
||||
|
||||
The following focused suites also passed during this checkpoint:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:outbound:objectstorage:test \
|
||||
--tests '*LegacyObjectAdoptionServiceTest' \
|
||||
--tests '*LegacyAdoptionApprovalVerifierTest' \
|
||||
--tests '*ObjectStorageLegacyMigrationConfigTest' --console=plain
|
||||
|
||||
./gradlew :sample-portfolio:test \
|
||||
--tests '*DeletePosterImageRetirementTest' \
|
||||
--tests '*PosterImageRetirementCrashMatrixTest' \
|
||||
--tests '*PosterImageRetirementConfigTest' \
|
||||
--tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain
|
||||
|
||||
./gradlew :sample-portfolio:test \
|
||||
--tests '*SampleApplicationContextTest' \
|
||||
:sample-portfolio:posterImageMigrationTest --console=plain
|
||||
```
|
||||
|
||||
The migration lane included
|
||||
`PosterImageRetirementQualificationTest`, which proved that an exact retirement row survives
|
||||
deletion of its Poster row.
|
||||
|
||||
## Failures found and resolved
|
||||
|
||||
- `spotlessJavaCheck` initially found formatting drift in newly changed application-core and
|
||||
persistence files. The owner-module Spotless apply tasks fixed it.
|
||||
- `SampleApplicationContextTest` initially failed because Spring's persistence exception advisor
|
||||
could not CGLIB-proxy the final `PosterImageAttachmentCasRepository`. Removing `final` fixed the
|
||||
context; the focused context suite then passed.
|
||||
- `CleanArchitectureTest` initially rejected an application-core return type from the sample domain
|
||||
and the kebab-case `image-publications` path. Conversion moved back to the application use case,
|
||||
and the endpoint changed to the repository's AIP-122-compatible `imagePublications` segment. The
|
||||
complete focused architecture suite then passed.
|
||||
|
||||
## Not yet re-run / not complete
|
||||
|
||||
- The combined Batch E checkpoint command stopped on the two architecture failures above before all
|
||||
requested root tasks could complete. The focused failing suites passed after the fixes, but
|
||||
`:sample-portfolio:check`, `verifyPublicPathSnapshot`, and the full combined Batch E command have
|
||||
not been re-run after those final fixes.
|
||||
- The complete repository `./gradlew test` and `./gradlew check` have not been re-run after the
|
||||
Batch E additions.
|
||||
- The legacy adoption runner/configuration is not yet wired to a production legacy inspector,
|
||||
permission-checked trust-key loader, or durable control-record replay-store implementation.
|
||||
- Tasks 25–30 (Batch F) have not started in this continuation.
|
||||
- Actual AWS qualification is blocked by Approval Gate B: no approved account, bucket/namespaces,
|
||||
workload roles, signed deployment attestation, or mutation/test authority was supplied.
|
||||
- No readiness card was promoted. Local/MinIO ceilings and unsupported conditional behavior remain
|
||||
unchanged.
|
||||
|
||||
## Wiki capture
|
||||
|
||||
The canonical vault
|
||||
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` is absent in this environment. Therefore the
|
||||
required branch note and derived raw documents could not be written. No non-canonical substitute
|
||||
was used.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Object Storage Phase 0 Inventory
|
||||
|
||||
- Captured: 2026-07-28
|
||||
- Branch: `codex/objectstorage-production-capability`
|
||||
- Scope: repository-local source, tests, configuration, migrations, and documentation
|
||||
- Evidence grade: repository-local only; deployed data, broker subscribers, and external REST
|
||||
consumers were not inspected
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \
|
||||
src docs
|
||||
rg -n 'image_key|posters/.*/image' src/sample-portfolio
|
||||
rg -n 'poster\.image-attached|StoredObjectResponse|PosterResponse|imageKey' \
|
||||
src/sample-portfolio docs
|
||||
```
|
||||
|
||||
The commands completed successfully in the isolated worktree. Results are classified below.
|
||||
Documentation hits in the Object Storage design/plan describe the migration and are not runtime
|
||||
consumers. The `s3://bucket/key-1` fixture in
|
||||
`IdempotencyStoreAdapterTest` belongs to the generic idempotency response-reference test and is not
|
||||
an Object Storage legacy-port consumer.
|
||||
|
||||
## Repository-local runtime inventory
|
||||
|
||||
| Contract/data | Producer | Repository-local consumers | Classification |
|
||||
| --- | --- | --- | --- |
|
||||
| `ObjectStoragePort` / `StoredObject` | `application-core/application/storage` | filesystem and S3 adapters, `UploadPosterImageUseCase`, `PosterController`/`PosterWebMapper` | legacy runtime contract |
|
||||
| `ca-skeleton.objectstorage.*` | `ObjectStorageSettings` / `ObjectStorageConfig` | sample runtime through its objectstorage runtime dependency | legacy runtime configuration |
|
||||
| `file://` receipt | `FilesystemObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
|
||||
| `s3://bucket/key` receipt | `S3ObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
|
||||
| `/posters/{id}/image` | `PosterController` | repository tests and the generated/public HTTP contract | legacy inbound API |
|
||||
| `StoredObjectResponse` | `PosterController` / `PosterWebMapper` | HTTP caller, with `key`, `size`, `contentType`, and `location` | legacy response DTO |
|
||||
| `PosterResponse.imageKey` | `PosterWebMapper` | list/get/create/update/publish/archive HTTP responses | legacy general response field |
|
||||
| `poster.image-attached` | `PosterEventPublisher` | no subscriber found in this repository | versionless broker event; external consumers unknown |
|
||||
| `poster.image-attached.imageKey` | `PosterImageAttached` and publisher JSON | no subscriber found in this repository | raw locator-shaped event field |
|
||||
| `poster.image_key` | Flyway V6, `PosterEntity`, persistence mapper | `Poster` aggregate and repository adapter | stored-data schema |
|
||||
| `posters/{id}/image` key | `UploadPosterImageUseCase` | aggregate `imageKey`, event payload, DB row, HTTP response | deterministic overwriteable legacy key |
|
||||
|
||||
## Executable characterization
|
||||
|
||||
The following tests pin the current behavior without approving it as the target design:
|
||||
|
||||
- `LegacyObjectStorageBehaviorTest`
|
||||
- caller-selected keys overwrite;
|
||||
- `get` returns `Optional<byte[]>` and materializes the whole object;
|
||||
- receipts expose `file://` and `s3://` locators.
|
||||
- `LegacyObjectStorageConfigTest`
|
||||
- missing backend configuration selects filesystem;
|
||||
- context creation creates the filesystem directory before the first write;
|
||||
- `autoCreateBucket=true` probes and creates a missing bucket during S3 bean construction.
|
||||
- `LegacyPosterImageUploadCharacterizationTest`
|
||||
- remote storage is called while `TransactionPort.inWrite` is active;
|
||||
- the controller calls `MultipartFile.getBytes`;
|
||||
- the response exposes raw key and provider location;
|
||||
- Poster deletion leaves the legacy object untouched.
|
||||
|
||||
## External inventory gap and Gate A
|
||||
|
||||
Repository search does not prove that the following have no deployed consumers:
|
||||
|
||||
- `POST /posters/{id}/image`;
|
||||
- `StoredObjectResponse.key` and `.location`;
|
||||
- `PosterResponse.imageKey`;
|
||||
- broker event type `poster.image-attached` and its `imageKey` payload;
|
||||
- rows already stored in `poster.image_key`;
|
||||
- filesystem/S3 objects already written under `posters/{id}/image`.
|
||||
|
||||
No deployed database, object namespace, access log, API client catalog, broker consumer group, schema
|
||||
registry, or owning team approval was inspected. Therefore removal, in-place field rename, event
|
||||
payload replacement, or legacy-object deletion remains blocked. Approval Gate A must obtain owner
|
||||
and consumer evidence and choose an additive/versioned migration contract.
|
||||
@@ -0,0 +1,114 @@
|
||||
schema_version: 1
|
||||
claims:
|
||||
- card_id: object-storage-managed-upload-single
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-managed-integrity
|
||||
claimed_level: R1
|
||||
evidence_revision: batch-b-local-r1
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":adapter:outbound:objectstorage:check"
|
||||
limitations:
|
||||
- single-process control CAS only; no multi-node linearizability
|
||||
- fsync and atomic move tests do not prove power-loss durability
|
||||
- local development provider is forbidden in production profiles
|
||||
|
||||
- card_id: object-storage-managed-upload-multipart
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- multipart publication protocol is not implemented
|
||||
|
||||
- card_id: object-storage-managed-download
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-managed-integrity
|
||||
claimed_level: R1
|
||||
evidence_revision: batch-b-local-r1
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":adapter:outbound:objectstorage:check"
|
||||
limitations:
|
||||
- local functional full and range reads are not production-provider qualification
|
||||
- no multi-node or power-loss durability claim
|
||||
|
||||
- card_id: object-storage-direct-upload-single
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- direct grant provider and public inbound endpoint are not implemented
|
||||
|
||||
- card_id: object-storage-direct-upload-multipart
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- direct multipart session and public inbound endpoint are not implemented
|
||||
|
||||
- card_id: object-storage-direct-download
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- direct download grant and public inbound endpoint are not implemented
|
||||
|
||||
- card_id: object-storage-quarantine-publication
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- scanner handoff and verdict fencing are not implemented
|
||||
|
||||
- card_id: object-storage-retention
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- retention and legal-hold provider enforcement are not implemented
|
||||
- privileged purge composition remains intentionally empty
|
||||
|
||||
- card_id: object-storage-reconciliation
|
||||
provider_type: filesystem-local-dev
|
||||
provider_version: jdk-21
|
||||
destination_profile: local-unimplemented
|
||||
claimed_level: R0
|
||||
evidence_revision: batch-b-contract-r0
|
||||
evidence_expires_on: ""
|
||||
required_tasks:
|
||||
- ":application-core:check"
|
||||
limitations:
|
||||
- local create resolution is single-process functional evidence only
|
||||
- production response-loss and multi-node reconciliation are not implemented
|
||||
File diff suppressed because it is too large
Load Diff
@@ -949,6 +949,13 @@ and durable interfaces are explicit.
|
||||
|
||||
### 13.3 Object storage
|
||||
|
||||
The authoritative implementation-level design for this capability is
|
||||
[Object Storage Production Capability Deep Design](2026-07-28-objectstorage-production-capability-design.md).
|
||||
Its ordered RED–GREEN execution batches and promotion gates are in the
|
||||
[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md).
|
||||
This subsection is only the cross-capability baseline; the dedicated design governs when details
|
||||
differ.
|
||||
|
||||
Replace whole-object `byte[]` as the only path with:
|
||||
|
||||
- streaming upload/download and range reads;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
# adapter:outbound:objectstorage — design-decision reference
|
||||
|
||||
Object-storage outbound (driven) adapter. Package root:
|
||||
`dev.caskeleton.adapter.outbound.objectstorage`. Implements the `application-core` port
|
||||
`dev.caskeleton.application.storage.ObjectStoragePort` behind an opt-in `@ConditionalOnProperty`
|
||||
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient).
|
||||
Object-storage driven adapter under
|
||||
`dev.caskeleton.adapter.outbound.objectstorage`. It implements the semantic contracts in
|
||||
`dev.caskeleton.application.objectstorage` and keeps provider keys, paths, SDK types, and control
|
||||
records behind the application boundary.
|
||||
|
||||
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
|
||||
`allowedProjectDependencies['adapter:outbound:objectstorage']` (SSOT). Module rules live in
|
||||
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
|
||||
comments.
|
||||
The allowed production dependencies are owned by
|
||||
`src/config/architecture/modules.json`. Module rules live in [CLAUDE.md](CLAUDE.md), the full design
|
||||
in `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md`, and exact
|
||||
evidence claims in `docs/registries/object-storage-readiness.yaml`.
|
||||
|
||||
## Module overview
|
||||
## Current readiness
|
||||
|
||||
An **opt-in** blob-storage adapter placed behind an application-core port. Two backends select the
|
||||
same `ObjectStoragePort` by configuration:
|
||||
Batch B implements the provider-neutral namespace/control kernel and
|
||||
`filesystem-local-dev` managed single upload/download at R1. R1 means bounded local functional
|
||||
evidence only:
|
||||
|
||||
- **filesystem** (default, `matchIfMissing`) — `FilesystemObjectStorageAdapter` writes blobs under
|
||||
`ca-skeleton.objectstorage.base-path`. No external service, so the local profile just works. The
|
||||
`location` in the `StoredObject` receipt is the `file://` URI.
|
||||
- **s3** — `S3ObjectStorageAdapter` uses the AWS SDK v2 `S3Client`. The client's endpoint override +
|
||||
path-style access (wired in `ObjectStorageConfig`) make the same code work against real AWS S3
|
||||
(leave `endpoint` unset) and MinIO (`endpoint=http://localhost:9000`). The `location` is an
|
||||
`s3://bucket/key` URI.
|
||||
- immutable streamed create with exact SHA-256 and exact version inspection;
|
||||
- full/range bounded reads;
|
||||
- strict canonical control-record codecs and single-process CAS;
|
||||
- same-operation replay without invoking the producer again;
|
||||
- restart/path/symlink/corruption/fault characterization;
|
||||
- exactly-once selected-provider lifecycle.
|
||||
|
||||
Selector: `ca-skeleton.objectstorage.backend=filesystem|s3` (filesystem is the default). Exactly one
|
||||
`ObjectStoragePort` bean is contributed, so a fork injects the port without knowing the active
|
||||
backend.
|
||||
It does not prove multi-node CAS, power-loss durability, production credentials/TLS/IAM/encryption,
|
||||
S3 response-loss behavior, or R2 readiness. Direct transfer, multipart, quarantine, retention, and
|
||||
production reconciliation cards remain R0.
|
||||
|
||||
## The port contract (framework-neutral)
|
||||
## Activation and routing
|
||||
|
||||
`ObjectStoragePort` is a minimal, framework-neutral surface:
|
||||
The canonical namespace is `app.object-storage` and is disabled by default. Enabling it requires an
|
||||
explicit provider, exact provider version, logical destination, route token, binding/policy
|
||||
revisions, and bounded sizes. Settings compile fully before any selected provider creates a
|
||||
directory, client, thread, scheduler, or credential lookup.
|
||||
|
||||
- `StoredObject put(String key, byte[] content, String contentType)` — store/overwrite.
|
||||
- `Optional<byte[]> get(String key)` — read, `empty()` when absent.
|
||||
- `void delete(String key)` — idempotent delete.
|
||||
- `boolean exists(String key)`.
|
||||
`filesystem-local-dev` is rejected under `prod`/`production`. Normal composition contributes only
|
||||
managed publication, inspection/transfer, retirement, and operation-resolution ports. Scanner and
|
||||
privileged purge composition remain separate and empty; no direct-grant port is registered.
|
||||
|
||||
Keys are caller-supplied, backend-relative, opaque strings. Implementations reject a blank key or a
|
||||
key that escapes the backend namespace (path traversal) with `IllegalArgumentException` — the
|
||||
filesystem adapter normalises the resolved path and checks it still starts with the base directory.
|
||||
The port intentionally exposes **no** streaming or presigned-URL surface; a fork adds those when a
|
||||
concrete feature needs them. Raw external SDK types never cross the port (B7) — the adapter returns
|
||||
only `StoredObject` / `byte[]` / primitives.
|
||||
The local provider maps provider-neutral logical control keys to `.record` leaf files. This permits
|
||||
object-store-valid pairs such as `reference` and `reference/lifecycle` without a filesystem
|
||||
file/directory collision; callers and durable control records retain the canonical logical keys.
|
||||
|
||||
## AWS SDK versioning (why the BOM is imported at module scope)
|
||||
## Legacy compatibility
|
||||
|
||||
`software.amazon.awssdk:*` versions are **not** managed by the Spring Boot BOM and this repo has no
|
||||
version catalog. The AWS SDK v2 BOM is therefore imported as a `dependencyManagement` platform in
|
||||
**this module's** `build.gradle` using the root `ext.awsSdkVersion` SSOT (set in `src/build.gradle`),
|
||||
exactly like the grpc module imports `grpc-bom`. This keeps the strict-locking blast radius to this
|
||||
module — the shared root `dependencyManagement` block stays awssdk-free.
|
||||
`ObjectStoragePort`, `StoredObject`, and the old whole-`byte[]` filesystem/S3 adapters are deprecated
|
||||
compatibility only. They preserve overwrite/raw-key semantics in an isolated namespace and never
|
||||
back the new semantic ports.
|
||||
|
||||
## IO-failure handling
|
||||
- Preferred temporary activation:
|
||||
`app.object-storage.legacy.enabled=true` with an explicit backend and namespace.
|
||||
- Old `ca-skeleton.objectstorage.*` properties remain an explicit legacy-only alias.
|
||||
- Mixing any old alias with canonical settings fails startup without echoing values.
|
||||
- The sample `local`/`test` profiles explicitly opt into legacy filesystem storage until its
|
||||
consumer migration; no production profile receives a local fallback.
|
||||
|
||||
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
|
||||
(`dependencyName="objectstorage"`) so a fork's web error handler classifies them uniformly with the
|
||||
other outbound dependencies. Illegal/blank keys are `IllegalArgumentException` (a caller bug, not a
|
||||
dependency failure). The S3 adapter maps `NoSuchKey` / HTTP 404 to `Optional.empty()` / `false`.
|
||||
## Provider and dependency scope
|
||||
|
||||
## Tests
|
||||
The AWS SDK v2 BOM stays module-scoped because Spring Boot does not manage
|
||||
`software.amazon.awssdk:*`. The existing legacy S3/MinIO adapter remains characterized but is not a
|
||||
canonical production provider and advances no S3 readiness card.
|
||||
|
||||
- `FilesystemObjectStorageAdapterTest` — `@TempDir` put/get/delete/exists round-trip, overwrite,
|
||||
idempotent delete, path-traversal + blank-key rejection.
|
||||
- `S3ObjectStorageAdapterTest` — key/metadata/URI mapping against a mocked `S3Client` (no network).
|
||||
- `S3ObjectStorageAdapterIT` — real S3-protocol round-trip against Testcontainers MinIO; skipped
|
||||
automatically when Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`).
|
||||
## Verification
|
||||
|
||||
Focused contracts cover codecs/state machines, local provider behavior, fail-closed binding,
|
||||
composition side effects/lifecycle, routing replay/read behavior, and the readiness registry.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:check
|
||||
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||
```
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Driven adapter: object storage behind application-core's ObjectStoragePort. Two backends — local
|
||||
// filesystem (default, no external service) and S3/MinIO via the AWS SDK v2 S3 client (endpoint
|
||||
// override makes the same code work against real AWS S3 and MinIO). Opt-in via
|
||||
// @ConditionalOnProperty (ca-skeleton.objectstorage.backend); filesystem is the matchIfMissing
|
||||
// default.
|
||||
// Driven adapter: provider-neutral semantic object-storage ports plus a bounded local-development
|
||||
// provider. Canonical app.object-storage activation is disabled by default. The old whole-byte[]
|
||||
// filesystem/S3 adapters remain isolated, explicit legacy compatibility only.
|
||||
//
|
||||
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, and this repo has no
|
||||
// version catalog, so the AWS SDK v2 BOM platform is imported HERE (module scope) using the root
|
||||
@@ -10,6 +8,36 @@
|
||||
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
|
||||
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
|
||||
|
||||
sourceSets {
|
||||
objectStorageMinioContractTest {
|
||||
java.srcDir 'src/objectStorageMinioContractTest/java'
|
||||
resources.srcDir 'src/objectStorageMinioContractTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
objectStorageMinioFaultTest {
|
||||
java.srcDir 'src/objectStorageMinioFaultTest/java'
|
||||
resources.srcDir 'src/objectStorageMinioFaultTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
objectStorageAwsQualificationTest {
|
||||
java.srcDir 'src/objectStorageAwsQualificationTest/java'
|
||||
resources.srcDir 'src/objectStorageAwsQualificationTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
objectStorageMinioContractTestImplementation.extendsFrom testImplementation
|
||||
objectStorageMinioContractTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
objectStorageMinioFaultTestImplementation.extendsFrom testImplementation
|
||||
objectStorageMinioFaultTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
objectStorageAwsQualificationTestImplementation.extendsFrom testImplementation
|
||||
objectStorageAwsQualificationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
|
||||
@@ -23,6 +51,7 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
implementation 'software.amazon.awssdk:s3'
|
||||
implementation 'software.amazon.awssdk:netty-nio-client'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
@@ -31,4 +60,32 @@ dependencies {
|
||||
// Docker is available and is skipped (disabledWithoutDocker) otherwise.
|
||||
testImplementation 'org.testcontainers:testcontainers'
|
||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
|
||||
testImplementation 'net.jqwik:jqwik:1.9.1'
|
||||
}
|
||||
|
||||
tasks.register('objectStorageMinioContractTest', Test) {
|
||||
description = 'Runs the non-skipping exact-release MinIO managed object contract.'
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.objectStorageMinioContractTest.output.classesDirs
|
||||
classpath = sourceSets.objectStorageMinioContractTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
shouldRunAfter tasks.named('test')
|
||||
}
|
||||
|
||||
tasks.register('objectStorageMinioFaultTest', Test) {
|
||||
description = 'Runs the non-skipping digest-pinned MinIO/Toxiproxy fault contract.'
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.objectStorageMinioFaultTest.output.classesDirs
|
||||
classpath = sourceSets.objectStorageMinioFaultTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
shouldRunAfter tasks.named('objectStorageMinioContractTest')
|
||||
}
|
||||
|
||||
tasks.register('objectStorageAwsQualificationTest', Test) {
|
||||
description = 'Runs only with explicit protected AWS sandbox authority and exact inputs.'
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.objectStorageAwsQualificationTest.output.classesDirs
|
||||
classpath = sourceSets.objectStorageAwsQualificationTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
+8
-5
@@ -5,6 +5,7 @@ import java.net.URI;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
@@ -14,11 +15,10 @@ import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3ClientBuilder;
|
||||
|
||||
/**
|
||||
* Opt-in wiring for the object-storage adapter. The backend is selected by {@code
|
||||
* ca-skeleton.objectstorage.backend}: {@code filesystem} (the {@code matchIfMissing} default)
|
||||
* contributes a {@link FilesystemObjectStorageAdapter}; {@code s3} contributes an AWS SDK v2 {@link
|
||||
* S3Client} plus an {@link S3ObjectStorageAdapter}. Exactly one {@link ObjectStoragePort} bean is
|
||||
* contributed, so a fork can inject the port without knowing which backend is active.
|
||||
* Deprecated opt-in wiring for the legacy object-storage adapter. The class-level activation guard
|
||||
* requires an explicit {@code ca-skeleton.objectstorage.*} property before the historical
|
||||
* filesystem backend default can contribute anything. New deployments use the disabled-by-default
|
||||
* {@code app.object-storage} capability.
|
||||
*
|
||||
* <p>The S3 client's endpoint override + path-style access make the same adapter work against real
|
||||
* AWS S3 (leave {@code endpoint} unset) and MinIO (set {@code endpoint=http://localhost:9000}).
|
||||
@@ -26,6 +26,9 @@ import software.amazon.awssdk.services.s3.S3ClientBuilder;
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ObjectStorageSettings.class)
|
||||
@Conditional(
|
||||
dev.caskeleton.adapter.outbound.objectstorage.config.LegacyObjectStorageActivationGuard.class)
|
||||
@SuppressWarnings("removal")
|
||||
public class ObjectStorageConfig {
|
||||
|
||||
@Bean
|
||||
|
||||
+7
-6
@@ -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}. */
|
||||
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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]);
|
||||
}
|
||||
}
|
||||
+132
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Fully validated provider declaration safe to pass to the selected contribution factory. */
|
||||
public record CompiledObjectStorageProvider(
|
||||
String providerId,
|
||||
String providerType,
|
||||
String providerVersion,
|
||||
Path localRoot,
|
||||
long maximumObjectBytes,
|
||||
int chunkBytes,
|
||||
ObjectStorageProviderDescriptor descriptor,
|
||||
Object providerSpecificBinding) {
|
||||
|
||||
public CompiledObjectStorageProvider(
|
||||
String providerId,
|
||||
String providerType,
|
||||
String providerVersion,
|
||||
Path localRoot,
|
||||
long maximumObjectBytes,
|
||||
int chunkBytes,
|
||||
ObjectStorageProviderDescriptor descriptor) {
|
||||
this(
|
||||
providerId,
|
||||
providerType,
|
||||
providerVersion,
|
||||
localRoot,
|
||||
maximumObjectBytes,
|
||||
chunkBytes,
|
||||
descriptor,
|
||||
null);
|
||||
}
|
||||
|
||||
public CompiledObjectStorageProvider {
|
||||
Objects.requireNonNull(providerId, "providerId must be non-null");
|
||||
Objects.requireNonNull(providerType, "providerType must be non-null");
|
||||
Objects.requireNonNull(providerVersion, "providerVersion must be non-null");
|
||||
Objects.requireNonNull(descriptor, "descriptor must be non-null");
|
||||
if (maximumObjectBytes < 1 || chunkBytes < 1) {
|
||||
throw new IllegalArgumentException("compiled provider bounds must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public <T> T requireProviderBinding(Class<T> bindingType) {
|
||||
if (!bindingType.isInstance(providerSpecificBinding)) {
|
||||
throw new IllegalArgumentException("compiled provider binding has an unexpected type");
|
||||
}
|
||||
return bindingType.cast(providerSpecificBinding);
|
||||
}
|
||||
}
|
||||
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
import org.springframework.context.annotation.Condition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
/** Activates the deprecated alias only when explicitly present and rejects mixed namespaces. */
|
||||
public final class LegacyObjectStorageActivationGuard implements Condition {
|
||||
|
||||
private static final String LEGACY_PREFIX = "ca-skeleton.objectstorage.";
|
||||
private static final String CANONICAL_PREFIX = "app.object-storage.";
|
||||
|
||||
@Override
|
||||
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
boolean legacy = hasPrefix(context, LEGACY_PREFIX);
|
||||
boolean canonical = hasPrefix(context, CANONICAL_PREFIX);
|
||||
if (legacy && canonical) {
|
||||
throw new IllegalStateException(
|
||||
"legacy and canonical object-storage configuration cannot be combined");
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
|
||||
private static boolean hasPrefix(ConditionContext context, String prefix) {
|
||||
if (context.getEnvironment() instanceof ConfigurableEnvironment environment) {
|
||||
for (var propertySource : environment.getPropertySources()) {
|
||||
if (propertySource instanceof EnumerablePropertySource<?> enumerable
|
||||
&& Arrays.stream(enumerable.getPropertyNames())
|
||||
.anyMatch(name -> name.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return knownProperties(prefix).stream().anyMatch(context.getEnvironment()::containsProperty);
|
||||
}
|
||||
|
||||
private static java.util.List<String> knownProperties(String prefix) {
|
||||
if (LEGACY_PREFIX.equals(prefix)) {
|
||||
return java.util.List.of(
|
||||
prefix + "backend",
|
||||
prefix + "base-path",
|
||||
prefix + "bucket",
|
||||
prefix + "endpoint",
|
||||
prefix + "region",
|
||||
prefix + "access-key",
|
||||
prefix + "secret-key",
|
||||
prefix + "path-style-access",
|
||||
prefix + "auto-create-bucket");
|
||||
}
|
||||
return java.util.List.of(
|
||||
prefix + "enabled",
|
||||
prefix + "default-destination",
|
||||
prefix + "legacy.enabled",
|
||||
prefix + "legacy.backend",
|
||||
prefix + "legacy.base-path");
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicyRevision;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fail-closed compiler from mutable binding input to exact immutable provider/route policy. */
|
||||
public final class ObjectStorageBindingCompiler {
|
||||
|
||||
private final Map<String, ObjectStorageProviderContribution> contributions;
|
||||
private final Set<String> activeProfiles;
|
||||
private final boolean scanSeamAvailable;
|
||||
private final Clock clock;
|
||||
|
||||
public ObjectStorageBindingCompiler(
|
||||
List<ObjectStorageProviderContribution> contributions,
|
||||
Set<String> activeProfiles,
|
||||
boolean scanSeamAvailable) {
|
||||
this(contributions, activeProfiles, scanSeamAvailable, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public ObjectStorageBindingCompiler(
|
||||
List<ObjectStorageProviderContribution> contributions,
|
||||
Set<String> activeProfiles,
|
||||
boolean scanSeamAvailable,
|
||||
Clock clock) {
|
||||
Objects.requireNonNull(contributions, "contributions must be non-null");
|
||||
Map<String, ObjectStorageProviderContribution> indexed = new LinkedHashMap<>();
|
||||
for (ObjectStorageProviderContribution contribution : contributions) {
|
||||
for (String contributedType : contribution.providerTypes()) {
|
||||
String type = canonicalId("provider type", contributedType);
|
||||
if (indexed.put(type, contribution) != null) {
|
||||
throw new IllegalArgumentException("duplicate object-storage provider contribution");
|
||||
}
|
||||
}
|
||||
}
|
||||
this.contributions = Map.copyOf(indexed);
|
||||
this.activeProfiles = Set.copyOf(activeProfiles);
|
||||
this.scanSeamAvailable = scanSeamAvailable;
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
}
|
||||
|
||||
public CompiledBindings compile(ObjectStorageCapabilitySettings settings) {
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
if (!settings.enabled()) {
|
||||
return new CompiledBindings(null, Map.of(), Map.of());
|
||||
}
|
||||
if (settings.providers().isEmpty()
|
||||
|| settings.destinations().isEmpty()
|
||||
|| settings.defaultDestination() == null) {
|
||||
throw invalid();
|
||||
}
|
||||
Map<String, CompiledObjectStorageProvider> providers = compileProviders(settings);
|
||||
Map<String, CompiledObjectStorageDestination> destinations =
|
||||
compileDestinations(settings, providers);
|
||||
String defaultId = canonicalId("default destination", settings.defaultDestination());
|
||||
CompiledObjectStorageDestination defaultDestination = destinations.get(defaultId);
|
||||
if (defaultDestination == null) {
|
||||
throw invalid();
|
||||
}
|
||||
rejectLegacyOverlap(settings, providers);
|
||||
return new CompiledBindings(defaultDestination.destinationId(), providers, destinations);
|
||||
}
|
||||
|
||||
private Map<String, CompiledObjectStorageProvider> compileProviders(
|
||||
ObjectStorageCapabilitySettings settings) {
|
||||
Map<String, CompiledObjectStorageProvider> compiled = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, ObjectStorageProviderSettings> entry : settings.providers().entrySet()) {
|
||||
String providerId = canonicalId("provider id", entry.getKey());
|
||||
ObjectStorageProviderSettings source =
|
||||
Objects.requireNonNull(entry.getValue(), "provider settings must be non-null");
|
||||
String providerType = canonicalId("provider type", source.type());
|
||||
String version = bounded("provider version", source.version(), 128);
|
||||
ObjectStorageProviderContribution contribution = contributions.get(providerType);
|
||||
if (contribution == null
|
||||
|| source.maximumObjectBytes() == null
|
||||
|| source.chunkBytes() == null) {
|
||||
throw invalid();
|
||||
}
|
||||
long maximumObjectBytes = source.maximumObjectBytes().toBytes();
|
||||
long rawChunkBytes = source.chunkBytes().toBytes();
|
||||
if (maximumObjectBytes < 1
|
||||
|| rawChunkBytes < 1
|
||||
|| rawChunkBytes > Integer.MAX_VALUE
|
||||
|| rawChunkBytes > maximumObjectBytes
|
||||
|| rawChunkBytes > 16L * 1024 * 1024) {
|
||||
throw invalid();
|
||||
}
|
||||
ObjectStorageProviderDescriptor descriptor = contribution.describe(source);
|
||||
if (!providerType.equals(descriptor.providerType())
|
||||
|| !version.equals(descriptor.providerVersion())
|
||||
|| maximumObjectBytes > descriptor.maximumObjectBytes()
|
||||
|| rawChunkBytes > descriptor.maximumChunkBytes()) {
|
||||
throw invalid();
|
||||
}
|
||||
Path localRoot =
|
||||
source.root() == null ? null : Path.of(source.root()).toAbsolutePath().normalize();
|
||||
CompiledObjectStorageProvider provider =
|
||||
new CompiledObjectStorageProvider(
|
||||
providerId,
|
||||
providerType,
|
||||
version,
|
||||
localRoot,
|
||||
maximumObjectBytes,
|
||||
(int) rawChunkBytes,
|
||||
descriptor,
|
||||
contribution.compileProviderBinding(providerId, source));
|
||||
if (compiled.put(providerId, provider) != null) {
|
||||
throw invalid();
|
||||
}
|
||||
if ("filesystem-local-dev".equals(providerType) && productionProfileActive()) {
|
||||
throw new IllegalArgumentException("filesystem-local-dev is rejected in production");
|
||||
}
|
||||
}
|
||||
return Map.copyOf(compiled);
|
||||
}
|
||||
|
||||
private Map<String, CompiledObjectStorageDestination> compileDestinations(
|
||||
ObjectStorageCapabilitySettings settings,
|
||||
Map<String, CompiledObjectStorageProvider> providers) {
|
||||
Map<String, CompiledObjectStorageDestination> compiled = new LinkedHashMap<>();
|
||||
Set<String> routes = new java.util.HashSet<>();
|
||||
for (Map.Entry<String, ObjectStorageDestinationSettings> entry :
|
||||
settings.destinations().entrySet()) {
|
||||
String destinationId = canonicalId("destination id", entry.getKey());
|
||||
ObjectStorageDestinationSettings source =
|
||||
Objects.requireNonNull(entry.getValue(), "destination settings must be non-null");
|
||||
String providerId = canonicalId("provider ref", source.providerRef());
|
||||
CompiledObjectStorageProvider provider = providers.get(providerId);
|
||||
if (provider == null) {
|
||||
throw invalid();
|
||||
}
|
||||
ObjectRouteToken route = ObjectRouteToken.parse(source.routeToken());
|
||||
if (!routes.add(route.canonicalText())) {
|
||||
throw invalid();
|
||||
}
|
||||
if (source.publicationRequirement() == ObjectPublicationRequirement.SCAN_CLEAN
|
||||
&& !scanSeamAvailable) {
|
||||
throw new IllegalArgumentException("scan-required destination has no scanner seam");
|
||||
}
|
||||
requireCapabilities(source.capabilityRequirements(), provider.descriptor());
|
||||
requireNamedProfile(source.operationProfile(), provider.descriptor());
|
||||
ObjectPolicySnapshot policy =
|
||||
new ObjectPolicySnapshot(
|
||||
ObjectBindingRevision.of(source.bindingRevision()),
|
||||
ObjectPolicyRevision.of(source.policyRevision()),
|
||||
source.publicationRequirement(),
|
||||
source.retentionRequirement(),
|
||||
source.encryptionRequirement(),
|
||||
source.capabilityRequirements(),
|
||||
provider.maximumObjectBytes(),
|
||||
provider.chunkBytes(),
|
||||
source.minimumReplayHorizon());
|
||||
CompiledObjectStorageDestination destination =
|
||||
new CompiledObjectStorageDestination(
|
||||
ObjectDestinationId.of(destinationId), providerId, route, policy);
|
||||
if (compiled.put(destinationId, destination) != null) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
return Map.copyOf(compiled);
|
||||
}
|
||||
|
||||
private void requireNamedProfile(
|
||||
String profileName, ObjectStorageProviderDescriptor descriptor) {
|
||||
if (descriptor.capabilityDescriptor().isEmpty()) {
|
||||
if (profileName != null && !profileName.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"provider does not expose named operation-profile evidence");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (profileName == null || profileName.isBlank()) {
|
||||
throw new IllegalArgumentException("exact operation profile is required");
|
||||
}
|
||||
descriptor
|
||||
.capabilityDescriptor()
|
||||
.orElseThrow()
|
||||
.requireSupported(profileName, clock.instant());
|
||||
}
|
||||
|
||||
private static void requireCapabilities(
|
||||
Set<ObjectCapabilityRequirement> requirements, ObjectStorageProviderDescriptor descriptor) {
|
||||
if (requirements == null) {
|
||||
throw invalid();
|
||||
}
|
||||
for (ObjectCapabilityRequirement requirement : requirements) {
|
||||
ObjectStorageProvider.Capability capability =
|
||||
switch (requirement) {
|
||||
case IMMUTABLE_CREATE -> ObjectStorageProvider.Capability.IMMUTABLE_CREATE;
|
||||
case EXACT_VERSION_READ, SHA_256_VERIFICATION ->
|
||||
ObjectStorageProvider.Capability.EXACT_INSPECT;
|
||||
case CONDITIONAL_RETIREMENT -> ObjectStorageProvider.Capability.CONDITIONAL_RETIREMENT;
|
||||
case RESPONSE_LOSS_RECONCILIATION ->
|
||||
ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION;
|
||||
case DIRECT_UPLOAD -> ObjectStorageProvider.Capability.DIRECT_UPLOAD;
|
||||
case DIRECT_MULTIPART -> ObjectStorageProvider.Capability.DIRECT_MULTIPART;
|
||||
case SCAN_GATED_PUBLICATION, RETENTION_HOLD, SERVER_SIDE_ENCRYPTION -> null;
|
||||
};
|
||||
if (capability == null
|
||||
|| descriptor.support().get(capability) != ObjectStorageProvider.Support.SUPPORTED) {
|
||||
throw new IllegalArgumentException("provider does not satisfy destination requirements");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectLegacyOverlap(
|
||||
ObjectStorageCapabilitySettings settings,
|
||||
Map<String, CompiledObjectStorageProvider> providers) {
|
||||
ObjectStorageCapabilitySettings.Legacy legacy = settings.legacy();
|
||||
if (legacy == null
|
||||
|| !legacy.enabled()
|
||||
|| !"filesystem".equals(legacy.backend())
|
||||
|| legacy.basePath() == null) {
|
||||
return;
|
||||
}
|
||||
Path legacyRoot = Path.of(legacy.basePath()).toAbsolutePath().normalize();
|
||||
for (CompiledObjectStorageProvider provider : providers.values()) {
|
||||
Path providerRoot = provider.localRoot();
|
||||
if (providerRoot != null
|
||||
&& (providerRoot.startsWith(legacyRoot) || legacyRoot.startsWith(providerRoot))) {
|
||||
throw new IllegalArgumentException("legacy and canonical namespaces overlap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean productionProfileActive() {
|
||||
return activeProfiles.stream()
|
||||
.map(profile -> profile.toLowerCase(Locale.ROOT))
|
||||
.anyMatch(profile -> profile.equals("prod") || profile.equals("production"));
|
||||
}
|
||||
|
||||
private static String canonicalId(String label, String value) {
|
||||
String bounded = bounded(label, value, 64);
|
||||
if (!bounded.matches("[a-z0-9][a-z0-9_-]*")
|
||||
|| !bounded.equals(bounded.toLowerCase(Locale.ROOT))) {
|
||||
throw invalid();
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
|
||||
private static String bounded(String label, String value, int maximumLength) {
|
||||
if (value == null
|
||||
|| value.isBlank()
|
||||
|| value.length() > maximumLength
|
||||
|| value.chars().anyMatch(character -> character < 0x20 || character > 0x7e)) {
|
||||
throw new IllegalArgumentException(label + " is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalid() {
|
||||
return new IllegalArgumentException("object-storage binding is incomplete or invalid");
|
||||
}
|
||||
|
||||
public record CompiledBindings(
|
||||
ObjectDestinationId defaultDestination,
|
||||
Map<String, CompiledObjectStorageProvider> providers,
|
||||
Map<String, CompiledObjectStorageDestination> destinations) {
|
||||
|
||||
public CompiledBindings {
|
||||
providers = Map.copyOf(providers);
|
||||
destinations = Map.copyOf(destinations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Compiles all bindings before constructing only the exact selected provider contributions. */
|
||||
public final class ObjectStorageCapabilityAssembler {
|
||||
|
||||
private final List<ObjectStorageProviderContribution> contributions;
|
||||
private final ObjectStorageBindingCompiler compiler;
|
||||
|
||||
public ObjectStorageCapabilityAssembler(
|
||||
List<ObjectStorageProviderContribution> contributions,
|
||||
ObjectStorageBindingCompiler compiler) {
|
||||
this.contributions = List.copyOf(contributions);
|
||||
this.compiler = Objects.requireNonNull(compiler, "compiler must be non-null");
|
||||
}
|
||||
|
||||
public AssembledCapability assemble(ObjectStorageCapabilitySettings settings) {
|
||||
ObjectStorageBindingCompiler.CompiledBindings bindings = compiler.compile(settings);
|
||||
if (bindings.destinations().isEmpty()) {
|
||||
throw new IllegalArgumentException("disabled capability cannot be assembled");
|
||||
}
|
||||
Map<String, ObjectStorageProviderContribution> byType = new LinkedHashMap<>();
|
||||
for (ObjectStorageProviderContribution contribution : contributions) {
|
||||
for (String providerType : contribution.providerTypes()) {
|
||||
byType.put(providerType, contribution);
|
||||
}
|
||||
}
|
||||
Map<String, SelectedObjectStorageProviderFactory> selected = new LinkedHashMap<>();
|
||||
List<SelectedObjectStorageProviderFactory> constructed = new ArrayList<>();
|
||||
try {
|
||||
for (CompiledObjectStorageDestination destination : bindings.destinations().values()) {
|
||||
if (selected.containsKey(destination.providerId())) {
|
||||
continue;
|
||||
}
|
||||
CompiledObjectStorageProvider provider =
|
||||
Objects.requireNonNull(bindings.providers().get(destination.providerId()));
|
||||
ObjectStorageProviderContribution contribution = byType.get(provider.providerType());
|
||||
if (contribution == null) {
|
||||
throw new IllegalArgumentException("selected provider contribution is unavailable");
|
||||
}
|
||||
SelectedObjectStorageProviderFactory factory = contribution.create(provider);
|
||||
constructed.add(factory);
|
||||
selected.put(provider.providerId(), factory);
|
||||
}
|
||||
return new AssembledCapability(bindings, selected);
|
||||
} catch (RuntimeException exception) {
|
||||
for (int index = constructed.size() - 1; index >= 0; index--) {
|
||||
constructed.get(index).close();
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable routing state plus owned provider lifecycle. */
|
||||
public static final class AssembledCapability implements AutoCloseable {
|
||||
|
||||
private final ObjectStorageBindingCompiler.CompiledBindings bindings;
|
||||
private final Map<String, SelectedObjectStorageProviderFactory> providers;
|
||||
private final Map<String, DestinationRuntime> byDestination;
|
||||
private final Map<String, DestinationRuntime> byRoute;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
private AssembledCapability(
|
||||
ObjectStorageBindingCompiler.CompiledBindings bindings,
|
||||
Map<String, SelectedObjectStorageProviderFactory> providers) {
|
||||
this.bindings = bindings;
|
||||
this.providers = Map.copyOf(providers);
|
||||
Map<String, DestinationRuntime> destinations = new LinkedHashMap<>();
|
||||
Map<String, DestinationRuntime> routes = new LinkedHashMap<>();
|
||||
for (CompiledObjectStorageDestination destination : bindings.destinations().values()) {
|
||||
DestinationRuntime runtime =
|
||||
new DestinationRuntime(
|
||||
destination,
|
||||
bindings.providers().get(destination.providerId()),
|
||||
providers.get(destination.providerId()));
|
||||
destinations.put(destination.destinationId().value(), runtime);
|
||||
routes.put(destination.route().canonicalText(), runtime);
|
||||
}
|
||||
byDestination = Map.copyOf(destinations);
|
||||
byRoute = Map.copyOf(routes);
|
||||
}
|
||||
|
||||
public DestinationRuntime destination(ObjectDestinationId destination) {
|
||||
DestinationRuntime runtime = byDestination.get(destination.value());
|
||||
if (runtime == null) {
|
||||
throw new IllegalArgumentException("unknown object-storage destination");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public DestinationRuntime route(String routeToken) {
|
||||
DestinationRuntime runtime = byRoute.get(routeToken);
|
||||
if (runtime == null) {
|
||||
throw new IllegalArgumentException("unknown or retired object-storage route");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public ObjectStorageBindingCompiler.CompiledBindings bindings() {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
List<SelectedObjectStorageProviderFactory> reverse = new ArrayList<>(providers.values());
|
||||
for (int index = reverse.size() - 1; index >= 0; index--) {
|
||||
reverse.get(index).close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record DestinationRuntime(
|
||||
CompiledObjectStorageDestination destination,
|
||||
CompiledObjectStorageProvider providerBinding,
|
||||
SelectedObjectStorageProviderFactory providerFactory) {
|
||||
|
||||
public DestinationRuntime {
|
||||
Objects.requireNonNull(destination, "destination must be non-null");
|
||||
Objects.requireNonNull(providerBinding, "providerBinding must be non-null");
|
||||
Objects.requireNonNull(providerFactory, "providerFactory must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.FilesystemObjectStorageAdapter;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.ObjectStorageConfig;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.ObjectStorageSettings;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.filesystem.FilesystemLocalDevProviderContribution;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPublicationHandoffService;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.s3.S3ObjectStorageProviderContribution;
|
||||
import dev.caskeleton.application.storage.ObjectStoragePort;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
/** Disabled-by-default canonical composition root for normal object-storage semantic ports. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ObjectStorageCapabilitySettings.class)
|
||||
@SuppressWarnings("removal")
|
||||
public class ObjectStorageCapabilityConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "filesystemLocalDevProviderContribution")
|
||||
public ObjectStorageProviderContribution filesystemLocalDevProviderContribution() {
|
||||
return new FilesystemLocalDevProviderContribution();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "s3ObjectStorageProviderContribution")
|
||||
public ObjectStorageProviderContribution s3ObjectStorageProviderContribution() {
|
||||
return new S3ObjectStorageProviderContribution();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ObjectStorageBindingCompiler objectStorageBindingCompiler(
|
||||
List<ObjectStorageProviderContribution> contributions, Environment environment) {
|
||||
Set<String> profiles =
|
||||
Set.copyOf(
|
||||
Arrays.asList(
|
||||
environment.getActiveProfiles().length == 0
|
||||
? environment.getDefaultProfiles()
|
||||
: environment.getActiveProfiles()));
|
||||
boolean scannerWorkflowEnabled =
|
||||
environment.getProperty(
|
||||
"app.object-storage.scan-maintenance.enabled", Boolean.class, false);
|
||||
return new ObjectStorageBindingCompiler(contributions, profiles, scannerWorkflowEnabled);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ObjectStorageCapabilityAssembler objectStorageCapabilityAssembler(
|
||||
List<ObjectStorageProviderContribution> contributions,
|
||||
ObjectStorageBindingCompiler compiler) {
|
||||
return new ObjectStorageCapabilityAssembler(contributions, compiler);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
|
||||
public ObjectStorageCapabilityAssembler.AssembledCapability objectStorageCapability(
|
||||
ObjectStorageCapabilitySettings settings, ObjectStorageCapabilityAssembler assembler) {
|
||||
return assembler.assemble(settings);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
|
||||
public RoutingObjectMutationAdapter routingObjectMutationAdapter(
|
||||
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
|
||||
return new RoutingObjectMutationAdapter(capability);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
|
||||
public ObjectPublicationHandoffService objectPublicationHandoffService(
|
||||
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
|
||||
return new ObjectPublicationHandoffService(capability, java.time.Clock.systemUTC());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "app.object-storage", name = "enabled", havingValue = "true")
|
||||
public RoutingObjectReadAdapter routingObjectReadAdapter(
|
||||
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
|
||||
return new RoutingObjectReadAdapter(capability);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnProperty(
|
||||
prefix = "app.object-storage.legacy",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
public LegacyRuntime canonicalLegacyObjectStorageRuntime(
|
||||
ObjectStorageCapabilitySettings settings) {
|
||||
ObjectStorageCapabilitySettings.Legacy legacy = settings.legacy();
|
||||
if ("filesystem".equals(legacy.backend())) {
|
||||
if (legacy.basePath() == null || legacy.basePath().isBlank()) {
|
||||
throw new IllegalArgumentException("legacy filesystem base path is required");
|
||||
}
|
||||
return new LegacyRuntime(new FilesystemObjectStorageAdapter(legacy.basePath()), null);
|
||||
}
|
||||
if ("s3".equals(legacy.backend())) {
|
||||
ObjectStorageSettings old = legacySettings(legacy);
|
||||
ObjectStorageConfig helper = new ObjectStorageConfig();
|
||||
S3Client client = helper.objectStorageS3Client(old);
|
||||
return new LegacyRuntime(helper.s3ObjectStoragePort(client, old), client);
|
||||
}
|
||||
throw new IllegalArgumentException("explicit legacy backend is required");
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
prefix = "app.object-storage.legacy",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
public ObjectStoragePort canonicalLegacyObjectStoragePort(LegacyRuntime runtime) {
|
||||
return runtime.port();
|
||||
}
|
||||
|
||||
@SuppressWarnings("removal")
|
||||
private static ObjectStorageSettings legacySettings(
|
||||
ObjectStorageCapabilitySettings.Legacy source) {
|
||||
ObjectStorageSettings target = new ObjectStorageSettings();
|
||||
target.setBackend(source.backend());
|
||||
target.setBasePath(source.basePath());
|
||||
target.setBucket(source.bucket());
|
||||
target.setEndpoint(source.endpoint());
|
||||
target.setRegion(source.region());
|
||||
target.setAccessKey(source.accessKey());
|
||||
target.setSecretKey(source.secretKey());
|
||||
target.setPathStyleAccess(source.pathStyleAccess());
|
||||
target.setAutoCreateBucket(source.autoCreateBucket());
|
||||
return target;
|
||||
}
|
||||
|
||||
public static final class LegacyRuntime implements AutoCloseable {
|
||||
|
||||
private final ObjectStoragePort port;
|
||||
private final S3Client client;
|
||||
|
||||
private LegacyRuntime(ObjectStoragePort port, S3Client client) {
|
||||
this.port = port;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public ObjectStoragePort port() {
|
||||
return port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Canonical disabled-by-default constructor-bound object-storage capability declaration. */
|
||||
@ConfigurationProperties(prefix = "app.object-storage")
|
||||
public record ObjectStorageCapabilitySettings(
|
||||
boolean enabled,
|
||||
String defaultDestination,
|
||||
Map<String, ObjectStorageProviderSettings> providers,
|
||||
Map<String, ObjectStorageDestinationSettings> destinations,
|
||||
Legacy legacy) {
|
||||
|
||||
public ObjectStorageCapabilitySettings {
|
||||
providers =
|
||||
providers == null
|
||||
? Map.of()
|
||||
: java.util.Collections.unmodifiableMap(new LinkedHashMap<>(providers));
|
||||
destinations =
|
||||
destinations == null
|
||||
? Map.of()
|
||||
: java.util.Collections.unmodifiableMap(new LinkedHashMap<>(destinations));
|
||||
legacy = legacy == null ? Legacy.disabled() : legacy;
|
||||
}
|
||||
|
||||
/** Explicit compatibility seam; it is not part of the new data/control namespace. */
|
||||
public record Legacy(
|
||||
boolean enabled,
|
||||
String backend,
|
||||
String basePath,
|
||||
String bucket,
|
||||
String endpoint,
|
||||
String region,
|
||||
String accessKey,
|
||||
String secretKey,
|
||||
Boolean pathStyleAccess,
|
||||
boolean autoCreateBucket) {
|
||||
|
||||
public Legacy {
|
||||
pathStyleAccess = pathStyleAccess == null ? Boolean.TRUE : pathStyleAccess;
|
||||
}
|
||||
|
||||
static Legacy disabled() {
|
||||
return new Legacy(false, null, null, null, null, null, null, null, true, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
|
||||
import java.time.Duration;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** Constructor-bound logical destination-to-route/provider/policy declaration. */
|
||||
public record ObjectStorageDestinationSettings(
|
||||
String providerRef,
|
||||
String routeToken,
|
||||
String bindingRevision,
|
||||
String policyRevision,
|
||||
ObjectPublicationRequirement publicationRequirement,
|
||||
ObjectRetentionRequirement retentionRequirement,
|
||||
ObjectEncryptionRequirement encryptionRequirement,
|
||||
Set<ObjectCapabilityRequirement> capabilityRequirements,
|
||||
Duration minimumReplayHorizon,
|
||||
String operationProfile) {
|
||||
|
||||
public ObjectStorageDestinationSettings {
|
||||
publicationRequirement =
|
||||
publicationRequirement == null
|
||||
? ObjectPublicationRequirement.INTEGRITY_VERIFIED
|
||||
: publicationRequirement;
|
||||
retentionRequirement =
|
||||
retentionRequirement == null ? ObjectRetentionRequirement.NONE : retentionRequirement;
|
||||
encryptionRequirement =
|
||||
encryptionRequirement == null
|
||||
? ObjectEncryptionRequirement.PROVIDER_MANAGED
|
||||
: encryptionRequirement;
|
||||
capabilityRequirements =
|
||||
capabilityRequirements == null
|
||||
? EnumSet.of(
|
||||
ObjectCapabilityRequirement.IMMUTABLE_CREATE,
|
||||
ObjectCapabilityRequirement.EXACT_VERSION_READ,
|
||||
ObjectCapabilityRequirement.CONDITIONAL_RETIREMENT,
|
||||
ObjectCapabilityRequirement.SHA_256_VERIFICATION,
|
||||
ObjectCapabilityRequirement.RESPONSE_LOSS_RECONCILIATION)
|
||||
: Set.copyOf(capabilityRequirements);
|
||||
minimumReplayHorizon = minimumReplayHorizon == null ? Duration.ofDays(7) : minimumReplayHorizon;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyAdoptionApprovalReplayStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyObjectAdoptionService;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.maintenance.LegacyObjectInspector;
|
||||
import dev.caskeleton.application.objectstorage.port.ManagedObjectPublicationPort;
|
||||
import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionPort;
|
||||
import java.time.Clock;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Explicit maintenance-only raw-locator composition.
|
||||
*
|
||||
* <p>Enabling without an isolated inspector and durable replay store fails startup.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(LegacyObjectAdoptionSettings.class)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "app.object-storage.legacy-adoption",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@SuppressWarnings("removal")
|
||||
public class ObjectStorageLegacyMigrationConfig {
|
||||
|
||||
@Bean
|
||||
public LegacyObjectAdoptionPort legacyObjectAdoptionPort(
|
||||
LegacyObjectInspector inspector,
|
||||
ManagedObjectPublicationPort publications,
|
||||
LegacyAdoptionApprovalReplayStore replayStore,
|
||||
Clock clock) {
|
||||
return new LegacyObjectAdoptionService(inspector, publications, replayStore, clock);
|
||||
}
|
||||
}
|
||||
+10
@@ -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 {}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Side-effect-free descriptor plus an explicitly selected provider construction seam.
|
||||
*
|
||||
* <p>{@link #describe} must not resolve credentials, create files, clients, threads, or schedulers.
|
||||
* {@link #create} owns cleanup of every partial allocation before it throws; after a successful
|
||||
* return the assembler owns the returned lifecycle exactly once.
|
||||
*/
|
||||
public interface ObjectStorageProviderContribution {
|
||||
|
||||
String providerType();
|
||||
|
||||
default Set<String> providerTypes() {
|
||||
return Set.of(providerType());
|
||||
}
|
||||
|
||||
ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings);
|
||||
|
||||
default Object compileProviderBinding(String providerId, ObjectStorageProviderSettings settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider);
|
||||
}
|
||||
+13
@@ -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) {}
|
||||
+20
@@ -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());
|
||||
}
|
||||
}
|
||||
+10
@@ -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() {}
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.CrockfordBase32;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectReferenceCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectRequestFingerprintCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectManifestRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectOperationRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferencePointerRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectEffectCertainty;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectOperationKernel;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectOperationKernelResult;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PendingObjectEffect;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublicationOperationState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
|
||||
import dev.caskeleton.application.idempotency.RequestFingerprint;
|
||||
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectOperationResolution;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectPublicationState;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt;
|
||||
import dev.caskeleton.application.objectstorage.port.ManagedObjectPublicationPort;
|
||||
import dev.caskeleton.application.objectstorage.port.ObjectOperationResolutionPort;
|
||||
import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort;
|
||||
import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectStageRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Normal mutation router. It never implements privileged purge or scanner-maintenance ports. */
|
||||
public final class RoutingObjectMutationAdapter
|
||||
implements ManagedObjectPublicationPort,
|
||||
ObjectRetirementPort,
|
||||
ObjectOperationResolutionPort,
|
||||
StagedObjectPublicationPort {
|
||||
|
||||
private final ObjectStorageCapabilityAssembler.AssembledCapability capability;
|
||||
private final Clock clock;
|
||||
|
||||
public RoutingObjectMutationAdapter(
|
||||
ObjectStorageCapabilityAssembler.AssembledCapability capability) {
|
||||
this(capability, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public RoutingObjectMutationAdapter(
|
||||
ObjectStorageCapabilityAssembler.AssembledCapability capability, Clock clock) {
|
||||
this.capability = java.util.Objects.requireNonNull(capability);
|
||||
this.clock = java.util.Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Override
|
||||
public dev.caskeleton.application.objectstorage.model.ObjectStageReceipt stage(
|
||||
ObjectStageRequest request, ObjectContentProducer producer) {
|
||||
return staged(request.operationKey()).stage(request, producer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt verifyIntegrity(
|
||||
ObjectVerifyRequest request) {
|
||||
return staged(request.operationKey()).verifyIntegrity(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request) {
|
||||
return staged(request.operationKey()).finalizePublication(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectMutationReceipt abort(ObjectAbortRequest request) {
|
||||
return staged(request.operationKey()).abort(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectPublishReceipt publish(
|
||||
ObjectPublishRequest request, ObjectContentProducer producer) {
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime =
|
||||
capability.destination(request.operationKey().destination());
|
||||
requirePolicy(request, runtime.destination());
|
||||
String fingerprint = fingerprint(request);
|
||||
ObjectId objectId = objectId(request, fingerprint);
|
||||
String dataKey =
|
||||
ObjectDataKeyCodec.encode(
|
||||
runtime.destination().route(), objectId, ObjectDataKeyCodec.Generation.of(1));
|
||||
ObjectReference reference =
|
||||
ObjectReferenceCodec.published(runtime.destination().route(), objectId);
|
||||
ObjectStorageProviderOperation providerOperation =
|
||||
new ObjectStorageProviderOperation(
|
||||
request.operationKey(),
|
||||
dataKey,
|
||||
request.contentIdentity(),
|
||||
request.declaredMediaType(),
|
||||
request.budget(),
|
||||
request.cancellation(),
|
||||
runtime.providerBinding().chunkBytes(),
|
||||
runtime.providerBinding().maximumObjectBytes());
|
||||
ObjectOperationKernel kernel =
|
||||
new ObjectOperationKernel(runtime.providerFactory().controlStore());
|
||||
ObjectOperationKernelResult reserved =
|
||||
kernel.reserve(
|
||||
request.operationKey(),
|
||||
fingerprint,
|
||||
"PUBLISH",
|
||||
runtime.destination().route(),
|
||||
runtime.destination().policySnapshot(),
|
||||
request.contentIdentity(),
|
||||
Instant.now());
|
||||
if (reserved.decision() == ObjectOperationKernelResult.Decision.CONFLICT) {
|
||||
throw new IllegalArgumentException("operation identity conflicts with a different intent");
|
||||
}
|
||||
boolean replay = reserved.decision() != ObjectOperationKernelResult.Decision.RESERVED;
|
||||
ObjectOperationRecord operation = reserved.record();
|
||||
ObjectStorageProvider.StoredData stored =
|
||||
continueDataPublication(
|
||||
kernel, operation, providerOperation, runtime.providerFactory().provider(), producer);
|
||||
operation =
|
||||
loadOperation(runtime, request)
|
||||
.orElseThrow(() -> new IllegalStateException("operation evidence disappeared"));
|
||||
if (operation.stablePhase() != PublicationOperationState.PUBLISHED) {
|
||||
operation = advanceToReferencePublication(kernel, operation);
|
||||
persistPublishedReference(
|
||||
runtime, request, reference, objectId, stored, operation.updatedAt());
|
||||
operation =
|
||||
loadOperation(runtime, request)
|
||||
.orElseThrow(() -> new IllegalStateException("operation evidence disappeared"));
|
||||
if (operation.stablePhase() == PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS) {
|
||||
kernel.advancePublication(
|
||||
operation,
|
||||
PublicationOperationState.PUBLISHED,
|
||||
stored.contentIdentity(),
|
||||
Instant.now());
|
||||
}
|
||||
}
|
||||
ObjectOperationRecord terminal =
|
||||
loadOperation(runtime, request)
|
||||
.orElseThrow(() -> new IllegalStateException("operation evidence disappeared"));
|
||||
return new ObjectPublishReceipt(
|
||||
request.operationKey(),
|
||||
new RequestFingerprint(fingerprint),
|
||||
reference,
|
||||
stored.exactVersion(),
|
||||
stored.contentIdentity(),
|
||||
request.declaredMediaType(),
|
||||
replay ? ObjectMutationOutcome.REPLAYED : ObjectMutationOutcome.APPLIED,
|
||||
terminal.updatedAt(),
|
||||
runtime.destination().policySnapshot().policyRevision().value());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectMutationReceipt retire(ObjectRetireRequest request) {
|
||||
String routeToken = routedToken(request.reference());
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime = capability.route(routeToken);
|
||||
if (!runtime.destination().destinationId().equals(request.operationKey().destination())) {
|
||||
throw new IllegalArgumentException("retirement destination does not match reference route");
|
||||
}
|
||||
String key =
|
||||
ObjectControlKeyCodec.referenceLifecycle(
|
||||
runtime.destination().route(), request.reference());
|
||||
ObjectControlStore store = runtime.providerFactory().controlStore();
|
||||
ObjectControlStore.StoredControlRecord current =
|
||||
store
|
||||
.read(key)
|
||||
.filter(stored -> stored.record() instanceof ObjectReferenceRecord)
|
||||
.orElseThrow(() -> new IllegalArgumentException("published reference is absent"));
|
||||
ObjectReferenceRecord record = (ObjectReferenceRecord) current.record();
|
||||
if (!record.immutableDataVersion().equals(request.exactVersion())) {
|
||||
return new ObjectMutationReceipt(
|
||||
request.operationKey(),
|
||||
ObjectMutationOutcome.REJECTED,
|
||||
ObjectOperationError.CONFLICT,
|
||||
Instant.now());
|
||||
}
|
||||
if (record.state() == PublishedReferenceState.RETIRED
|
||||
|| record.state() == PublishedReferenceState.PURGE_ELIGIBLE
|
||||
|| record.state() == PublishedReferenceState.PURGED) {
|
||||
return new ObjectMutationReceipt(
|
||||
request.operationKey(),
|
||||
ObjectMutationOutcome.REPLAYED,
|
||||
ObjectOperationError.NONE,
|
||||
Instant.now());
|
||||
}
|
||||
ObjectReferenceRecord pending =
|
||||
new ObjectReferenceRecord(
|
||||
1,
|
||||
record.recordId(),
|
||||
record.revision() + 1,
|
||||
record.reference(),
|
||||
record.objectId(),
|
||||
record.immutableDataVersion(),
|
||||
PublishedReferenceState.RETIREMENT_PENDING,
|
||||
Instant.now());
|
||||
current = store.compareAndSet(key, new ObjectControlMutation(current.version(), pending));
|
||||
ObjectReferenceRecord retired =
|
||||
new ObjectReferenceRecord(
|
||||
1,
|
||||
record.recordId(),
|
||||
pending.revision() + 1,
|
||||
record.reference(),
|
||||
record.objectId(),
|
||||
record.immutableDataVersion(),
|
||||
PublishedReferenceState.RETIRED,
|
||||
Instant.now());
|
||||
store.compareAndSet(key, new ObjectControlMutation(current.version(), retired));
|
||||
return new ObjectMutationReceipt(
|
||||
request.operationKey(),
|
||||
ObjectMutationOutcome.APPLIED,
|
||||
ObjectOperationError.NONE,
|
||||
Instant.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectOperationResolution resolve(
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey) {
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime =
|
||||
capability.destination(operationKey.destination());
|
||||
String key = ObjectControlKeyCodec.operation(runtime.destination().route(), operationKey);
|
||||
Optional<ObjectOperationRecord> record =
|
||||
runtime
|
||||
.providerFactory()
|
||||
.controlStore()
|
||||
.read(key)
|
||||
.map(ObjectControlStore.StoredControlRecord::record)
|
||||
.filter(ObjectOperationRecord.class::isInstance)
|
||||
.map(ObjectOperationRecord.class::cast);
|
||||
if (record.isEmpty()) {
|
||||
return new ObjectOperationResolution(
|
||||
operationKey,
|
||||
ObjectPublicationState.ABORTED,
|
||||
ObjectMutationOutcome.REJECTED,
|
||||
ObjectOperationError.NOT_FOUND,
|
||||
Optional.empty());
|
||||
}
|
||||
ObjectOperationRecord operation = record.get();
|
||||
ObjectPublicationState state = publicationState(operation.stablePhase());
|
||||
Optional<ObjectReference> reference =
|
||||
operation.stablePhase() == PublicationOperationState.PUBLISHED
|
||||
? Optional.of(
|
||||
ObjectReferenceCodec.published(
|
||||
operation.route(),
|
||||
objectId(operation.operationKey(), operation.requestFingerprint())))
|
||||
: Optional.empty();
|
||||
return new ObjectOperationResolution(
|
||||
operationKey,
|
||||
state,
|
||||
operation.stablePhase() == PublicationOperationState.PUBLISHED
|
||||
? ObjectMutationOutcome.APPLIED
|
||||
: ObjectMutationOutcome.INDETERMINATE,
|
||||
ObjectOperationError.NONE,
|
||||
reference);
|
||||
}
|
||||
|
||||
private static ObjectStorageProvider.StoredData continueDataPublication(
|
||||
ObjectOperationKernel kernel,
|
||||
ObjectOperationRecord initial,
|
||||
ObjectStorageProviderOperation providerOperation,
|
||||
ObjectStorageProvider provider,
|
||||
ObjectContentProducer producer) {
|
||||
ObjectOperationRecord operation = initial;
|
||||
if (operation.stablePhase() == PublicationOperationState.RESERVED) {
|
||||
operation =
|
||||
kernel.advancePublication(
|
||||
operation, PublicationOperationState.DATA_UPLOAD_IN_PROGRESS, null, Instant.now());
|
||||
}
|
||||
ObjectStorageProvider.StoredData stored;
|
||||
if (operation.stablePhase() == PublicationOperationState.DATA_UPLOAD_IN_PROGRESS) {
|
||||
if (operation.pendingEffect() == null) {
|
||||
operation =
|
||||
kernel.markEffectSent(
|
||||
operation,
|
||||
new PendingObjectEffect(
|
||||
PendingObjectEffect.Kind.DATA_PUT,
|
||||
operation.operationKey().operationId().value(),
|
||||
sha256Hex(providerOperation.canonicalDataKey()),
|
||||
PublicationOperationState.DATA_UPLOADED.name(),
|
||||
"create-if-absent",
|
||||
operation.requestFingerprint()));
|
||||
}
|
||||
ObjectStorageProvider.Resolution resolution = provider.resolveCreate(providerOperation);
|
||||
if (resolution == ObjectStorageProvider.Resolution.NOT_APPLIED) {
|
||||
stored = provider.create(providerOperation, producer);
|
||||
} else if (resolution == ObjectStorageProvider.Resolution.APPLIED) {
|
||||
stored =
|
||||
provider
|
||||
.inspect(providerOperation.canonicalDataKey())
|
||||
.orElseThrow(() -> new IllegalStateException("resolved local data disappeared"));
|
||||
} else {
|
||||
throw new IllegalStateException("provider create cannot be resolved safely");
|
||||
}
|
||||
if (operation.effectCertainty() != ObjectEffectCertainty.CONFIRMED) {
|
||||
operation = kernel.confirmEffect(operation, Instant.now());
|
||||
}
|
||||
returnStoredAdvance(kernel, operation, stored);
|
||||
return stored;
|
||||
}
|
||||
return provider
|
||||
.inspect(providerOperation.canonicalDataKey())
|
||||
.orElseThrow(() -> new IllegalStateException("published operation data is absent"));
|
||||
}
|
||||
|
||||
private static void returnStoredAdvance(
|
||||
ObjectOperationKernel kernel,
|
||||
ObjectOperationRecord operation,
|
||||
ObjectStorageProvider.StoredData stored) {
|
||||
kernel.advancePublication(
|
||||
operation,
|
||||
PublicationOperationState.DATA_UPLOADED,
|
||||
stored.contentIdentity(),
|
||||
Instant.now());
|
||||
}
|
||||
|
||||
private ObjectOperationRecord advanceToReferencePublication(
|
||||
ObjectOperationKernel kernel, ObjectOperationRecord initial) {
|
||||
ObjectOperationRecord operation = initial;
|
||||
if (operation.stablePhase() == PublicationOperationState.DATA_UPLOADED) {
|
||||
operation =
|
||||
kernel.advancePublication(
|
||||
operation,
|
||||
PublicationOperationState.INTEGRITY_VERIFIED,
|
||||
operation.observedContentIdentity(),
|
||||
Instant.now());
|
||||
}
|
||||
if (operation.stablePhase() == PublicationOperationState.INTEGRITY_VERIFIED) {
|
||||
operation =
|
||||
kernel.advancePublication(
|
||||
operation,
|
||||
PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS,
|
||||
operation.observedContentIdentity(),
|
||||
Instant.now());
|
||||
}
|
||||
return operation;
|
||||
}
|
||||
|
||||
private void persistPublishedReference(
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime,
|
||||
ObjectPublishRequest request,
|
||||
ObjectReference reference,
|
||||
ObjectId objectId,
|
||||
ObjectStorageProvider.StoredData stored,
|
||||
Instant now) {
|
||||
ObjectControlStore control = runtime.providerFactory().controlStore();
|
||||
ObjectManifestRecord manifest =
|
||||
new ObjectManifestRecord(
|
||||
1,
|
||||
"manifest:" + objectId.canonicalText(),
|
||||
1,
|
||||
objectId,
|
||||
runtime.destination().route(),
|
||||
runtime.providerBinding().providerType(),
|
||||
runtime.providerBinding().providerVersion(),
|
||||
runtime.destination().policySnapshot().bindingRevision(),
|
||||
stored.contentIdentity().exactLength(),
|
||||
stored.contentIdentity().fullDigest(),
|
||||
request.declaredMediaType(),
|
||||
stored.exactVersion(),
|
||||
request.encryptionRequirement().name(),
|
||||
request.retentionRequirement().name(),
|
||||
now);
|
||||
ObjectReferencePointerRecord pointer =
|
||||
new ObjectReferencePointerRecord(
|
||||
1, "pointer:" + reference.canonicalText(), 1, reference, 1, stored.exactVersion(), now);
|
||||
ObjectReferenceRecord lifecycle =
|
||||
new ObjectReferenceRecord(
|
||||
1,
|
||||
"reference:" + reference.canonicalText(),
|
||||
1,
|
||||
reference,
|
||||
objectId,
|
||||
stored.exactVersion(),
|
||||
PublishedReferenceState.PUBLISHED,
|
||||
now);
|
||||
createOrRequireSame(
|
||||
control,
|
||||
ObjectControlKeyCodec.manifest(
|
||||
runtime.destination().route(), objectId, ObjectControlKeyCodec.ManifestRevision.of(1)),
|
||||
manifest);
|
||||
createOrRequireSame(
|
||||
control,
|
||||
ObjectControlKeyCodec.reference(runtime.destination().route(), reference),
|
||||
pointer);
|
||||
createOrRequireSame(
|
||||
control,
|
||||
ObjectControlKeyCodec.referenceLifecycle(runtime.destination().route(), reference),
|
||||
lifecycle);
|
||||
}
|
||||
|
||||
private static void createOrRequireSame(
|
||||
ObjectControlStore control, String key, ObjectControlRecord record) {
|
||||
try {
|
||||
control.create(key, record);
|
||||
} catch (ObjectControlConflictException conflict) {
|
||||
ObjectControlRecord existing =
|
||||
control
|
||||
.read(key)
|
||||
.map(ObjectControlStore.StoredControlRecord::record)
|
||||
.orElseThrow(() -> conflict);
|
||||
if (!existing.equals(record)) {
|
||||
throw conflict;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<ObjectOperationRecord> loadOperation(
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime, ObjectPublishRequest request) {
|
||||
return runtime
|
||||
.providerFactory()
|
||||
.controlStore()
|
||||
.read(
|
||||
ObjectControlKeyCodec.operation(runtime.destination().route(), request.operationKey()))
|
||||
.map(ObjectControlStore.StoredControlRecord::record)
|
||||
.filter(ObjectOperationRecord.class::isInstance)
|
||||
.map(ObjectOperationRecord.class::cast);
|
||||
}
|
||||
|
||||
private static void requirePolicy(
|
||||
ObjectPublishRequest request, CompiledObjectStorageDestination destination) {
|
||||
if (request.publicationRequirement() != destination.policySnapshot().publicationRequirement()
|
||||
|| request.retentionRequirement() != destination.policySnapshot().retentionRequirement()
|
||||
|| request.encryptionRequirement() != destination.policySnapshot().encryptionRequirement()
|
||||
|| !destination
|
||||
.policySnapshot()
|
||||
.capabilityRequirements()
|
||||
.containsAll(request.capabilityRequirements())
|
||||
|| request.contentIdentity().exactLength()
|
||||
> destination.policySnapshot().maximumObjectBytes()) {
|
||||
throw new IllegalArgumentException(
|
||||
"request does not satisfy the compiled destination policy");
|
||||
}
|
||||
}
|
||||
|
||||
private static String fingerprint(ObjectPublishRequest request) {
|
||||
return ObjectRequestFingerprintCodec.sha256Hex(
|
||||
new ObjectRequestFingerprintCodec.RequestIntent(
|
||||
1,
|
||||
request.operationKey().destination(),
|
||||
ObjectRequestFingerprintCodec.OperationKind.PUBLISH,
|
||||
request.declaredMediaType(),
|
||||
request.contentIdentity().exactLength(),
|
||||
ObjectRequestFingerprintCodec.ContentMode.EXACT_R2_CONTENT,
|
||||
request.contentIdentity(),
|
||||
request.publicationRequirement(),
|
||||
request.retentionRequirement(),
|
||||
request.encryptionRequirement(),
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
private static ObjectId objectId(ObjectPublishRequest request, String fingerprint) {
|
||||
return objectId(request.operationKey(), fingerprint);
|
||||
}
|
||||
|
||||
private static ObjectId objectId(
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey,
|
||||
String fingerprint) {
|
||||
String seed =
|
||||
operationKey.destination().value()
|
||||
+ "\n"
|
||||
+ operationKey.epoch().value()
|
||||
+ "\n"
|
||||
+ operationKey.operationId().value()
|
||||
+ "\n"
|
||||
+ fingerprint;
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8));
|
||||
return ObjectId.parse(CrockfordBase32.encode(Arrays.copyOf(digest, 16), 26));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
return java.util.HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String routedToken(ObjectReference reference) {
|
||||
return reference.canonicalText().split("\\.", -1)[1];
|
||||
}
|
||||
|
||||
private static ObjectPublicationState publicationState(PublicationOperationState state) {
|
||||
return switch (state) {
|
||||
case PUBLISHED -> ObjectPublicationState.PUBLISHED;
|
||||
case INTEGRITY_VERIFIED, SCAN_PENDING, SCAN_CLEAN, REFERENCE_PUBLISH_IN_PROGRESS ->
|
||||
ObjectPublicationState.VERIFIED;
|
||||
case QUARANTINED -> ObjectPublicationState.QUARANTINED;
|
||||
case ABORTED, EXPIRED, FAILED, CORRUPT -> ObjectPublicationState.ABORTED;
|
||||
default -> ObjectPublicationState.STAGED;
|
||||
};
|
||||
}
|
||||
|
||||
private dev.caskeleton.adapter.outbound.objectstorage.kernel.StagedObjectPublicationKernel staged(
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation) {
|
||||
return new dev.caskeleton.adapter.outbound.objectstorage.kernel.StagedObjectPublicationKernel(
|
||||
capability.destination(operation.destination()), clock);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectManifestRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferencePointerRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectReferenceRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectDescriptor;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectDigestVerification;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectPublicationState;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectReadReceipt;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
|
||||
import dev.caskeleton.application.objectstorage.port.ObjectInspectionPort;
|
||||
import dev.caskeleton.application.objectstorage.port.ObjectTransferPort;
|
||||
import dev.caskeleton.application.objectstorage.request.ObjectReadRequest;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Published-reference-only read router over retained exact route revisions. */
|
||||
public final class RoutingObjectReadAdapter implements ObjectInspectionPort, ObjectTransferPort {
|
||||
|
||||
private final ObjectStorageCapabilityAssembler.AssembledCapability capability;
|
||||
|
||||
public RoutingObjectReadAdapter(ObjectStorageCapabilityAssembler.AssembledCapability capability) {
|
||||
this.capability = java.util.Objects.requireNonNull(capability);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ObjectDescriptor> inspect(ObjectReference reference) {
|
||||
Optional<LoadedReference> candidate = load(reference);
|
||||
if (candidate.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
LoadedReference loaded = candidate.orElseThrow();
|
||||
if (loaded.lifecycle().state() != PublishedReferenceState.PUBLISHED) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ObjectManifestRecord manifest = loaded.manifest();
|
||||
return Optional.of(
|
||||
new ObjectDescriptor(
|
||||
reference,
|
||||
manifest.immutableDataVersion(),
|
||||
new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity(
|
||||
manifest.logicalSize(), manifest.logicalDigest()),
|
||||
manifest.mediaType(),
|
||||
Optional.empty(),
|
||||
ObjectPublicationState.PUBLISHED,
|
||||
ObjectScanState.NOT_REQUIRED,
|
||||
loaded.runtime().destination().policySnapshot().encryptionRequirement(),
|
||||
loaded.runtime().destination().policySnapshot().retentionRequirement(),
|
||||
manifest.updatedAt(),
|
||||
Optional.of(manifest.updatedAt()),
|
||||
1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectReadReceipt transfer(ObjectReadRequest request, ObjectContentConsumer consumer) {
|
||||
LoadedReference loaded =
|
||||
load(request.reference())
|
||||
.orElseThrow(() -> new IllegalArgumentException("object reference is absent"));
|
||||
if (loaded.lifecycle().state() != PublishedReferenceState.PUBLISHED) {
|
||||
throw new IllegalArgumentException("object reference is not published");
|
||||
}
|
||||
ObjectManifestRecord manifest = loaded.manifest();
|
||||
if (request.expectedVersion().isPresent()
|
||||
&& !request.expectedVersion().orElseThrow().equals(manifest.immutableDataVersion())) {
|
||||
throw new IllegalArgumentException("object version precondition failed");
|
||||
}
|
||||
ObjectId objectId = loaded.lifecycle().objectId();
|
||||
String dataKey =
|
||||
ObjectDataKeyCodec.encode(
|
||||
loaded.runtime().destination().route(), objectId, ObjectDataKeyCodec.Generation.of(1));
|
||||
ObjectStorageProvider.ReadOperation providerRead =
|
||||
new ObjectStorageProvider.ReadOperation(
|
||||
dataKey,
|
||||
manifest.immutableDataVersion(),
|
||||
new dev.caskeleton.application.objectstorage.model.ObjectContentIdentity(
|
||||
manifest.logicalSize(), manifest.logicalDigest()),
|
||||
request.range(),
|
||||
request.budget(),
|
||||
request.cancellation(),
|
||||
loaded.runtime().providerBinding().chunkBytes(),
|
||||
request.maximumDeliveredBytes());
|
||||
loaded.runtime().providerFactory().provider().transfer(providerRead, consumer);
|
||||
boolean wholeObject =
|
||||
request.range().offset() == 0 && request.range().length() == manifest.logicalSize();
|
||||
return new ObjectReadReceipt(
|
||||
request.reference(),
|
||||
manifest.immutableDataVersion(),
|
||||
request.range(),
|
||||
request.range().length(),
|
||||
wholeObject && request.digestVerification() == ObjectDigestVerification.FULL_CONTENT
|
||||
? ObjectDigestVerification.FULL_CONTENT
|
||||
: ObjectDigestVerification.NONE);
|
||||
}
|
||||
|
||||
private Optional<LoadedReference> load(ObjectReference reference) {
|
||||
String routeToken = reference.canonicalText().split("\\.", -1)[1];
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime = capability.route(routeToken);
|
||||
ObjectControlStore control = runtime.providerFactory().controlStore();
|
||||
ObjectReferencePointerRecord pointer =
|
||||
control
|
||||
.read(ObjectControlKeyCodec.reference(runtime.destination().route(), reference))
|
||||
.map(ObjectControlStore.StoredControlRecord::record)
|
||||
.filter(ObjectReferencePointerRecord.class::isInstance)
|
||||
.map(ObjectReferencePointerRecord.class::cast)
|
||||
.orElse(null);
|
||||
if (pointer == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ObjectReferenceRecord lifecycle =
|
||||
control
|
||||
.read(
|
||||
ObjectControlKeyCodec.referenceLifecycle(runtime.destination().route(), reference))
|
||||
.map(ObjectControlStore.StoredControlRecord::record)
|
||||
.filter(ObjectReferenceRecord.class::isInstance)
|
||||
.map(ObjectReferenceRecord.class::cast)
|
||||
.orElseThrow(() -> new IllegalArgumentException("object lifecycle is absent"));
|
||||
ObjectManifestRecord manifest =
|
||||
control
|
||||
.read(
|
||||
ObjectControlKeyCodec.manifest(
|
||||
runtime.destination().route(),
|
||||
lifecycle.objectId(),
|
||||
ObjectControlKeyCodec.ManifestRevision.of(pointer.manifestRevision())))
|
||||
.map(ObjectControlStore.StoredControlRecord::record)
|
||||
.filter(ObjectManifestRecord.class::isInstance)
|
||||
.map(ObjectManifestRecord.class::cast)
|
||||
.orElseThrow(() -> new IllegalArgumentException("object manifest is absent"));
|
||||
if (!pointer.immutableDataVersion().equals(lifecycle.immutableDataVersion())
|
||||
|| !pointer.immutableDataVersion().equals(manifest.immutableDataVersion())) {
|
||||
throw new IllegalStateException("object reference evidence is corrupt");
|
||||
}
|
||||
return Optional.of(new LoadedReference(runtime, pointer, lifecycle, manifest));
|
||||
}
|
||||
|
||||
private record LoadedReference(
|
||||
ObjectStorageCapabilityAssembler.DestinationRuntime runtime,
|
||||
ObjectReferencePointerRecord pointer,
|
||||
ObjectReferenceRecord lifecycle,
|
||||
ObjectManifestRecord manifest) {}
|
||||
}
|
||||
+147
@@ -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) {}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Selected provider resources with exactly-once close semantics. */
|
||||
public final class SelectedObjectStorageProviderFactory implements AutoCloseable {
|
||||
|
||||
private final ObjectStorageProvider provider;
|
||||
private final ObjectControlStore controlStore;
|
||||
private final Object managedMultipartProvider;
|
||||
private final Object directTransferProvider;
|
||||
private final Object directMultipartProvider;
|
||||
private final Runnable closeAction;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
public SelectedObjectStorageProviderFactory(
|
||||
ObjectStorageProvider provider, ObjectControlStore controlStore, Runnable closeAction) {
|
||||
this(provider, controlStore, null, null, null, closeAction);
|
||||
}
|
||||
|
||||
public SelectedObjectStorageProviderFactory(
|
||||
ObjectStorageProvider provider,
|
||||
ObjectControlStore controlStore,
|
||||
Object managedMultipartProvider,
|
||||
Runnable closeAction) {
|
||||
this(provider, controlStore, managedMultipartProvider, null, null, closeAction);
|
||||
}
|
||||
|
||||
public SelectedObjectStorageProviderFactory(
|
||||
ObjectStorageProvider provider,
|
||||
ObjectControlStore controlStore,
|
||||
Object managedMultipartProvider,
|
||||
Object directTransferProvider,
|
||||
Runnable closeAction) {
|
||||
this(
|
||||
provider,
|
||||
controlStore,
|
||||
managedMultipartProvider,
|
||||
directTransferProvider,
|
||||
null,
|
||||
closeAction);
|
||||
}
|
||||
|
||||
public SelectedObjectStorageProviderFactory(
|
||||
ObjectStorageProvider provider,
|
||||
ObjectControlStore controlStore,
|
||||
Object managedMultipartProvider,
|
||||
Object directTransferProvider,
|
||||
Object directMultipartProvider,
|
||||
Runnable closeAction) {
|
||||
this.provider = Objects.requireNonNull(provider, "provider must be non-null");
|
||||
this.controlStore = Objects.requireNonNull(controlStore, "controlStore must be non-null");
|
||||
this.managedMultipartProvider = managedMultipartProvider;
|
||||
this.directTransferProvider = directTransferProvider;
|
||||
this.directMultipartProvider = directMultipartProvider;
|
||||
this.closeAction = Objects.requireNonNull(closeAction, "closeAction must be non-null");
|
||||
}
|
||||
|
||||
public ObjectStorageProvider provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public ObjectControlStore controlStore() {
|
||||
return controlStore;
|
||||
}
|
||||
|
||||
public <T> Optional<T> managedMultipartProvider(Class<T> providerType) {
|
||||
Objects.requireNonNull(providerType, "providerType must be non-null");
|
||||
return providerType.isInstance(managedMultipartProvider)
|
||||
? Optional.of(providerType.cast(managedMultipartProvider))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
public <T> Optional<T> directTransferProvider(Class<T> providerType) {
|
||||
Objects.requireNonNull(providerType, "providerType must be non-null");
|
||||
return providerType.isInstance(directTransferProvider)
|
||||
? Optional.of(providerType.cast(directTransferProvider))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
public <T> Optional<T> directMultipartProvider(Class<T> providerType) {
|
||||
Objects.requireNonNull(providerType, "providerType must be non-null");
|
||||
return providerType.isInstance(directMultipartProvider)
|
||||
? Optional.of(providerType.cast(directMultipartProvider))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
closeAction.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
+898
@@ -0,0 +1,898 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.control;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.direct.DirectTransferSessionRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectBindingRevision;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectEffectCertainty;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicyRevision;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectPolicySnapshot;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PendingObjectEffect;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublicationOperationState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.PublishedReferenceState;
|
||||
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
|
||||
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectOperationId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
|
||||
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** JDK-only canonical-json-v1 codec with an outer SHA-256 corruption digest. */
|
||||
public final class CanonicalJsonObjectControlRecordCodec implements ObjectControlRecordCodec {
|
||||
|
||||
private static final int MAXIMUM_ENVELOPE_BYTES = 64 * 1024;
|
||||
private static final int MAXIMUM_TERMINAL_RECEIPT_BYTES = 16 * 1024;
|
||||
private static final int MAXIMUM_PART_BYTES = 4 * 1024;
|
||||
|
||||
@Override
|
||||
public byte[] encode(ObjectControlRecord record) {
|
||||
Objects.requireNonNull(record, "record must be non-null");
|
||||
byte[] payload = payload(record);
|
||||
enforceFamilySize(record, payload.length);
|
||||
return envelope(payload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectControlRecord decode(byte[] encoded) {
|
||||
try {
|
||||
return decodeUnchecked(encoded);
|
||||
} catch (UnsupportedObjectControlSchemaException | ObjectControlCorruptionException exception) {
|
||||
throw exception;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ObjectControlCorruptionException(
|
||||
"control record violates schema invariants", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectControlRecord decodeUnchecked(byte[] encoded) {
|
||||
if (encoded == null || encoded.length == 0 || encoded.length > MAXIMUM_ENVELOPE_BYTES) {
|
||||
throw corrupt();
|
||||
}
|
||||
CanonicalJsonReader outer = new CanonicalJsonReader(encoded);
|
||||
String digest = outer.string("digest");
|
||||
String base64Payload = outer.string("payload");
|
||||
outer.end();
|
||||
if (!digest.matches("[0-9a-f]{64}")) {
|
||||
throw corrupt();
|
||||
}
|
||||
byte[] payload;
|
||||
try {
|
||||
payload = Base64.getDecoder().decode(base64Payload);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ObjectControlCorruptionException(
|
||||
"control payload is not canonical Base64", exception);
|
||||
}
|
||||
if (!Base64.getEncoder().encodeToString(payload).equals(base64Payload)
|
||||
|| !MessageDigest.isEqual(
|
||||
digest.getBytes(StandardCharsets.US_ASCII),
|
||||
sha256Hex(payload).getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw corrupt();
|
||||
}
|
||||
ObjectControlRecord record = decodePayload(payload);
|
||||
enforceFamilySize(record, payload.length);
|
||||
return record;
|
||||
}
|
||||
|
||||
public byte[] envelope(byte[] canonicalPayload) {
|
||||
Objects.requireNonNull(canonicalPayload, "canonicalPayload must be non-null");
|
||||
byte[] encoded =
|
||||
new CanonicalJsonWriter()
|
||||
.string("digest", sha256Hex(canonicalPayload))
|
||||
.string("payload", Base64.getEncoder().encodeToString(canonicalPayload))
|
||||
.bytes();
|
||||
if (encoded.length > MAXIMUM_ENVELOPE_BYTES) {
|
||||
throw corrupt();
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
public String sha256Hex(byte[] value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] payload(ObjectControlRecord record) {
|
||||
CanonicalJsonWriter writer =
|
||||
new CanonicalJsonWriter()
|
||||
.string("family", record.family().name())
|
||||
.number("schema", record.schemaVersion())
|
||||
.string("recordId", record.recordId())
|
||||
.number("revision", record.revision());
|
||||
return switch (record) {
|
||||
case ObjectDirectSessionRecord direct -> directSession(writer, direct);
|
||||
case ObjectDirectMultipartSessionRecord session -> directMultipartSession(writer, session);
|
||||
case ObjectDirectMultipartGrantRecord grant -> directMultipartGrant(writer, grant);
|
||||
case ObjectOperationRecord operation -> operation(writer, operation);
|
||||
case ObjectManifestRecord manifest -> manifest(writer, manifest);
|
||||
case ObjectReferencePointerRecord pointer -> pointer(writer, pointer);
|
||||
case ObjectReferenceRecord reference -> reference(writer, reference);
|
||||
case ObjectStagedObjectRecord staged -> staged(writer, staged);
|
||||
case ObjectPublicationHandoffRecord handoff -> handoff(writer, handoff);
|
||||
case ObjectMultipartSessionRecord session -> session(writer, session);
|
||||
case ObjectMultipartPartRecord part -> part(writer, part);
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] staged(CanonicalJsonWriter writer, ObjectStagedObjectRecord record) {
|
||||
return writer
|
||||
.string("destination", record.operationKey().destination().value())
|
||||
.string("operationEpoch", record.operationKey().epoch().value())
|
||||
.string("operationId", record.operationKey().operationId().value())
|
||||
.string("stageHandle", record.stageHandle().canonicalText())
|
||||
.string("objectId", record.objectId().canonicalText())
|
||||
.string("canonicalDataKey", record.canonicalDataKey())
|
||||
.string("exactVersion", record.exactVersion().canonicalText())
|
||||
.number("contentSize", record.contentIdentity().exactLength())
|
||||
.string("digestAlgorithm", record.contentIdentity().fullDigest().algorithm().name())
|
||||
.string("digestValue", record.contentIdentity().fullDigest().base64Value())
|
||||
.string("mediaType", record.mediaType().canonicalText())
|
||||
.string("publicationRequirement", record.publicationRequirement().name())
|
||||
.bool("integrityVerified", record.integrityVerified())
|
||||
.nullableString(
|
||||
"scanOperationId",
|
||||
record.scanOperationId() == null ? null : record.scanOperationId().value())
|
||||
.nullableString("scannerPolicyRevision", record.scannerPolicyRevision())
|
||||
.string("scanState", record.scanState().name())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] handoff(CanonicalJsonWriter writer, ObjectPublicationHandoffRecord record) {
|
||||
return writer
|
||||
.string("destination", record.operationKey().destination().value())
|
||||
.string("operationEpoch", record.operationKey().epoch().value())
|
||||
.string("operationId", record.operationKey().operationId().value())
|
||||
.string("stageHandle", record.stageHandle().canonicalText())
|
||||
.string("exactVersion", record.exactVersion().canonicalText())
|
||||
.number("fence", record.fence())
|
||||
.string("claimantDigest", record.claimantDigest())
|
||||
.string("leaseExpiresAt", record.leaseExpiresAt().toString())
|
||||
.bool("released", record.released())
|
||||
.bool("abortAuthorized", record.abortAuthorized())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] directMultipartSession(
|
||||
CanonicalJsonWriter writer, ObjectDirectMultipartSessionRecord record) {
|
||||
return writer
|
||||
.string("destination", record.operationKey().destination().value())
|
||||
.string("operationEpoch", record.operationKey().epoch().value())
|
||||
.string("operationId", record.operationKey().operationId().value())
|
||||
.string("sessionId", record.sessionId().canonicalText())
|
||||
.string("multipartState", record.multipartState().name())
|
||||
.string("canonicalDataKey", record.canonicalDataKey())
|
||||
.number("expectedSize", record.expectedContent().exactLength())
|
||||
.string("expectedDigestAlgorithm", record.expectedContent().fullDigest().algorithm().name())
|
||||
.string("expectedDigestValue", record.expectedContent().fullDigest().base64Value())
|
||||
.string("mediaType", record.mediaType().canonicalText())
|
||||
.nullableString("providerSessionEvidence", record.providerSessionEvidence())
|
||||
.number("maximumParts", record.maximumParts())
|
||||
.number("acknowledgedPartCount", record.acknowledgedPartCount())
|
||||
.string("sessionExpiresAt", record.sessionExpiresAt().toString())
|
||||
.nullableString(
|
||||
"latestGrantExpiresAt",
|
||||
record.latestGrantExpiresAt() == null ? null : record.latestGrantExpiresAt().toString())
|
||||
.nullableString(
|
||||
"admissionClosedAt",
|
||||
record.admissionClosedAt() == null ? null : record.admissionClosedAt().toString())
|
||||
.nullableString(
|
||||
"completedVersion",
|
||||
record.completedVersion() == null ? null : record.completedVersion().canonicalText())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] directMultipartGrant(
|
||||
CanonicalJsonWriter writer, ObjectDirectMultipartGrantRecord record) {
|
||||
return writer
|
||||
.string("sessionId", record.sessionId().canonicalText())
|
||||
.number("partNumber", record.partNumber().value())
|
||||
.number("grantGeneration", record.grantGeneration())
|
||||
.string("grantState", record.grantState().name())
|
||||
.number("exactPartLength", record.exactPartLength())
|
||||
.string("expectedDigestAlgorithm", record.expectedPartDigest().algorithm().name())
|
||||
.string("expectedDigestValue", record.expectedPartDigest().base64Value())
|
||||
.string("signingTime", record.signingTime().toString())
|
||||
.string("expiresAt", record.expiresAt().toString())
|
||||
.nullableString("providerPartEvidence", record.providerPartEvidence())
|
||||
.nullableString(
|
||||
"receiptToken",
|
||||
record.receiptToken() == null ? null : record.receiptToken().canonicalText())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] directSession(
|
||||
CanonicalJsonWriter writer, ObjectDirectSessionRecord record) {
|
||||
DirectTransferSessionRecord session = record.session();
|
||||
return writer
|
||||
.string("destination", session.operationKey().destination().value())
|
||||
.string("operationEpoch", session.operationKey().epoch().value())
|
||||
.string("operationId", session.operationKey().operationId().value())
|
||||
.string("sessionId", session.sessionId().canonicalText())
|
||||
.string("state", session.state().name())
|
||||
.number("grantGeneration", session.grantGeneration())
|
||||
.nullableString("constraintsDigest", session.constraintsDigest())
|
||||
.nullableString(
|
||||
"signingTime", session.signingTime() == null ? null : session.signingTime().toString())
|
||||
.nullableString(
|
||||
"expiresAt", session.expiresAt() == null ? null : session.expiresAt().toString())
|
||||
.nullableString("credentialRevision", session.credentialRevision())
|
||||
.nullableString("referenceRevision", session.referenceRevision())
|
||||
.string("canonicalDataKey", session.canonicalDataKey())
|
||||
.number("expectedSize", session.expectedContent().exactLength())
|
||||
.string(
|
||||
"expectedDigestAlgorithm", session.expectedContent().fullDigest().algorithm().name())
|
||||
.string("expectedDigestValue", session.expectedContent().fullDigest().base64Value())
|
||||
.string("mediaType", session.mediaType().canonicalText())
|
||||
.nullableString(
|
||||
"exactVersion",
|
||||
session.exactVersion() == null ? null : session.exactVersion().canonicalText())
|
||||
.string("updatedAt", session.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] operation(CanonicalJsonWriter writer, ObjectOperationRecord record) {
|
||||
PendingObjectEffect effect = record.pendingEffect();
|
||||
return writer
|
||||
.string("destination", record.operationKey().destination().value())
|
||||
.string("operationEpoch", record.operationKey().epoch().value())
|
||||
.string("operationId", record.operationKey().operationId().value())
|
||||
.string("requestFingerprint", record.requestFingerprint())
|
||||
.string("operationKind", record.operationKind())
|
||||
.string("route", record.route().canonicalText())
|
||||
.string("bindingRevision", record.bindingRevision().value())
|
||||
.string("policyRevision", record.policyRevision().value())
|
||||
.string("publicationRequirement", record.policySnapshot().publicationRequirement().name())
|
||||
.string("retentionRequirement", record.policySnapshot().retentionRequirement().name())
|
||||
.string("encryptionRequirement", record.policySnapshot().encryptionRequirement().name())
|
||||
.string(
|
||||
"capabilityRequirements",
|
||||
record.policySnapshot().capabilityRequirements().stream()
|
||||
.sorted(Comparator.comparing(Enum::name))
|
||||
.map(Enum::name)
|
||||
.collect(Collectors.joining(",")))
|
||||
.number("maximumObjectBytes", record.policySnapshot().maximumObjectBytes())
|
||||
.number("chunkBytes", record.policySnapshot().chunkBytes())
|
||||
.number("minimumReplaySeconds", record.policySnapshot().minimumReplayHorizon().toSeconds())
|
||||
.string("policySnapshotDigest", record.policySnapshotDigest())
|
||||
.nullableNumber(
|
||||
"expectedSize",
|
||||
record.expectedContentIdentity() == null
|
||||
? null
|
||||
: record.expectedContentIdentity().exactLength())
|
||||
.nullableString(
|
||||
"expectedDigestAlgorithm",
|
||||
record.expectedContentIdentity() == null
|
||||
? null
|
||||
: record.expectedContentIdentity().fullDigest().algorithm().name())
|
||||
.nullableString(
|
||||
"expectedDigestValue",
|
||||
record.expectedContentIdentity() == null
|
||||
? null
|
||||
: record.expectedContentIdentity().fullDigest().base64Value())
|
||||
.nullableNumber(
|
||||
"observedSize",
|
||||
record.observedContentIdentity() == null
|
||||
? null
|
||||
: record.observedContentIdentity().exactLength())
|
||||
.nullableString(
|
||||
"observedDigestAlgorithm",
|
||||
record.observedContentIdentity() == null
|
||||
? null
|
||||
: record.observedContentIdentity().fullDigest().algorithm().name())
|
||||
.nullableString(
|
||||
"observedDigestValue",
|
||||
record.observedContentIdentity() == null
|
||||
? null
|
||||
: record.observedContentIdentity().fullDigest().base64Value())
|
||||
.string("stablePhase", record.stablePhase().name())
|
||||
.number("stateVersion", record.stateVersion())
|
||||
.nullableString("pendingKind", effect == null ? null : effect.kind().name())
|
||||
.nullableString("attemptId", effect == null ? null : effect.attemptId())
|
||||
.nullableString("exactTargetEvidence", effect == null ? null : effect.exactTargetEvidence())
|
||||
.nullableString("desiredState", effect == null ? null : effect.desiredState())
|
||||
.nullableString("precondition", effect == null ? null : effect.precondition())
|
||||
.nullableString(
|
||||
"requestEvidenceDigest", effect == null ? null : effect.requestEvidenceDigest())
|
||||
.string("effectCertainty", record.effectCertainty().name())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] manifest(CanonicalJsonWriter writer, ObjectManifestRecord record) {
|
||||
return writer
|
||||
.string("objectId", record.objectId().canonicalText())
|
||||
.string("route", record.route().canonicalText())
|
||||
.string("providerType", record.providerType())
|
||||
.string("providerVersion", record.providerVersion())
|
||||
.string("bindingRevision", record.bindingRevision().value())
|
||||
.number("logicalSize", record.logicalSize())
|
||||
.string("digestAlgorithm", record.logicalDigest().algorithm().name())
|
||||
.string("digestValue", record.logicalDigest().base64Value())
|
||||
.string("mediaType", record.mediaType().canonicalText())
|
||||
.string("immutableDataVersion", record.immutableDataVersion().canonicalText())
|
||||
.string("encryptionEvidence", record.encryptionEvidence())
|
||||
.string("retentionEvidence", record.retentionEvidence())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] pointer(CanonicalJsonWriter writer, ObjectReferencePointerRecord record) {
|
||||
return writer
|
||||
.string("reference", record.reference().canonicalText())
|
||||
.number("manifestRevision", record.manifestRevision())
|
||||
.string("immutableDataVersion", record.immutableDataVersion().canonicalText())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] reference(CanonicalJsonWriter writer, ObjectReferenceRecord record) {
|
||||
return writer
|
||||
.string("reference", record.reference().canonicalText())
|
||||
.string("objectId", record.objectId().canonicalText())
|
||||
.string("immutableDataVersion", record.immutableDataVersion().canonicalText())
|
||||
.string("state", record.state().name())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] session(CanonicalJsonWriter writer, ObjectMultipartSessionRecord record) {
|
||||
return writer
|
||||
.string("sessionId", record.sessionId().canonicalText())
|
||||
.string("multipartState", record.multipartState().name())
|
||||
.string("grantState", record.grantState().name())
|
||||
.string("providerSessionEvidence", record.providerSessionEvidence())
|
||||
.number("partCount", record.partCount())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static byte[] part(CanonicalJsonWriter writer, ObjectMultipartPartRecord record) {
|
||||
return writer
|
||||
.string("sessionId", record.sessionId().canonicalText())
|
||||
.number("partNumber", record.partNumber().value())
|
||||
.string("receiptToken", record.receiptToken().canonicalText())
|
||||
.number("logicalSize", record.logicalSize())
|
||||
.string("digestAlgorithm", record.digest().algorithm().name())
|
||||
.string("digestValue", record.digest().base64Value())
|
||||
.string("providerPartEvidence", record.providerPartEvidence())
|
||||
.string("updatedAt", record.updatedAt().toString())
|
||||
.bytes();
|
||||
}
|
||||
|
||||
private static ObjectControlRecord decodePayload(byte[] payload) {
|
||||
CanonicalJsonReader reader = new CanonicalJsonReader(payload);
|
||||
ObjectControlRecord.Family family;
|
||||
try {
|
||||
family = ObjectControlRecord.Family.valueOf(reader.string("family"));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new UnsupportedObjectControlSchemaException("control record family is unsupported");
|
||||
}
|
||||
long schema = reader.number("schema");
|
||||
if (schema != 1) {
|
||||
throw new UnsupportedObjectControlSchemaException("control schema is unsupported");
|
||||
}
|
||||
String recordId = reader.string("recordId");
|
||||
long revision = reader.number("revision");
|
||||
return switch (family) {
|
||||
case OPERATION -> readOperation(reader, recordId, revision);
|
||||
case MANIFEST -> readManifest(reader, recordId, revision);
|
||||
case REFERENCE_POINTER -> readPointer(reader, recordId, revision);
|
||||
case REFERENCE -> readReference(reader, recordId, revision);
|
||||
case DIRECT_SESSION -> readDirectSession(reader, recordId, revision);
|
||||
case DIRECT_MULTIPART_SESSION -> readDirectMultipartSession(reader, recordId, revision);
|
||||
case DIRECT_MULTIPART_GRANT -> readDirectMultipartGrant(reader, recordId, revision);
|
||||
case STAGED_OBJECT -> readStaged(reader, recordId, revision);
|
||||
case PUBLICATION_HANDOFF -> readHandoff(reader, recordId, revision);
|
||||
case MULTIPART_SESSION -> readSession(reader, recordId, revision);
|
||||
case MULTIPART_PART -> readPart(reader, recordId, revision);
|
||||
};
|
||||
}
|
||||
|
||||
private static ObjectStagedObjectRecord readStaged(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectOperationKey operation =
|
||||
new ObjectOperationKey(
|
||||
ObjectDestinationId.of(reader.string("destination")),
|
||||
ObjectOperationEpoch.of(reader.string("operationEpoch")),
|
||||
ObjectOperationId.of(reader.string("operationId")));
|
||||
ObjectStageHandle stage = ObjectStageHandle.parse(reader.string("stageHandle"));
|
||||
ObjectId objectId = ObjectId.parse(reader.string("objectId"));
|
||||
String dataKey = reader.string("canonicalDataKey");
|
||||
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("exactVersion"));
|
||||
ObjectContentIdentity content =
|
||||
readContentIdentity(
|
||||
reader.number("contentSize"),
|
||||
reader.string("digestAlgorithm"),
|
||||
reader.string("digestValue"));
|
||||
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
|
||||
ObjectPublicationRequirement requirement =
|
||||
enumValue(ObjectPublicationRequirement.class, reader.string("publicationRequirement"));
|
||||
boolean verified = reader.bool("integrityVerified");
|
||||
String scanOperation = reader.nullableString("scanOperationId");
|
||||
String scannerPolicy = reader.nullableString("scannerPolicyRevision");
|
||||
ObjectScanState scanState = enumValue(ObjectScanState.class, reader.string("scanState"));
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectStagedObjectRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
operation,
|
||||
stage,
|
||||
objectId,
|
||||
dataKey,
|
||||
version,
|
||||
content,
|
||||
mediaType,
|
||||
requirement,
|
||||
verified,
|
||||
scanOperation == null ? null : ObjectOperationId.of(scanOperation),
|
||||
scannerPolicy,
|
||||
scanState,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectPublicationHandoffRecord readHandoff(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectOperationKey operation =
|
||||
new ObjectOperationKey(
|
||||
ObjectDestinationId.of(reader.string("destination")),
|
||||
ObjectOperationEpoch.of(reader.string("operationEpoch")),
|
||||
ObjectOperationId.of(reader.string("operationId")));
|
||||
ObjectStageHandle stage = ObjectStageHandle.parse(reader.string("stageHandle"));
|
||||
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("exactVersion"));
|
||||
long fence = reader.number("fence");
|
||||
String claimantDigest = reader.string("claimantDigest");
|
||||
Instant expiry = instant(reader.string("leaseExpiresAt"));
|
||||
boolean released = reader.bool("released");
|
||||
boolean abortAuthorized = reader.bool("abortAuthorized");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectPublicationHandoffRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
operation,
|
||||
stage,
|
||||
version,
|
||||
fence,
|
||||
claimantDigest,
|
||||
expiry,
|
||||
released,
|
||||
abortAuthorized,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectDirectMultipartSessionRecord readDirectMultipartSession(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectOperationKey operationKey =
|
||||
new ObjectOperationKey(
|
||||
ObjectDestinationId.of(reader.string("destination")),
|
||||
ObjectOperationEpoch.of(reader.string("operationEpoch")),
|
||||
ObjectOperationId.of(reader.string("operationId")));
|
||||
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
|
||||
MultipartUploadState state =
|
||||
enumValue(MultipartUploadState.class, reader.string("multipartState"));
|
||||
String dataKey = reader.string("canonicalDataKey");
|
||||
ObjectContentIdentity expected =
|
||||
readContentIdentity(
|
||||
reader.number("expectedSize"),
|
||||
reader.string("expectedDigestAlgorithm"),
|
||||
reader.string("expectedDigestValue"));
|
||||
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
|
||||
String provider = reader.nullableString("providerSessionEvidence");
|
||||
long rawMaximumParts = reader.number("maximumParts");
|
||||
long acknowledged = reader.number("acknowledgedPartCount");
|
||||
if (rawMaximumParts > Integer.MAX_VALUE || rawMaximumParts < Integer.MIN_VALUE) {
|
||||
throw corrupt();
|
||||
}
|
||||
Instant sessionExpiry = instant(reader.string("sessionExpiresAt"));
|
||||
String latestGrant = reader.nullableString("latestGrantExpiresAt");
|
||||
String closed = reader.nullableString("admissionClosedAt");
|
||||
String completedVersion = reader.nullableString("completedVersion");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectDirectMultipartSessionRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
operationKey,
|
||||
sessionId,
|
||||
state,
|
||||
dataKey,
|
||||
expected,
|
||||
mediaType,
|
||||
provider,
|
||||
Math.toIntExact(rawMaximumParts),
|
||||
acknowledged,
|
||||
sessionExpiry,
|
||||
latestGrant == null ? null : instant(latestGrant),
|
||||
closed == null ? null : instant(closed),
|
||||
completedVersion == null ? null : ObjectVersionToken.parse(completedVersion),
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectDirectMultipartGrantRecord readDirectMultipartGrant(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
|
||||
long rawPart = reader.number("partNumber");
|
||||
long generation = reader.number("grantGeneration");
|
||||
DirectGrantSessionState state =
|
||||
enumValue(DirectGrantSessionState.class, reader.string("grantState"));
|
||||
long length = reader.number("exactPartLength");
|
||||
ObjectDigest digest =
|
||||
ObjectDigest.of(
|
||||
enumValue(ObjectDigestAlgorithm.class, reader.string("expectedDigestAlgorithm")),
|
||||
reader.string("expectedDigestValue"));
|
||||
Instant signing = instant(reader.string("signingTime"));
|
||||
Instant expiry = instant(reader.string("expiresAt"));
|
||||
String provider = reader.nullableString("providerPartEvidence");
|
||||
String receipt = reader.nullableString("receiptToken");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
if (rawPart > Integer.MAX_VALUE || rawPart < Integer.MIN_VALUE) {
|
||||
throw corrupt();
|
||||
}
|
||||
return new ObjectDirectMultipartGrantRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
sessionId,
|
||||
MultipartPartNumber.of((int) rawPart),
|
||||
generation,
|
||||
state,
|
||||
length,
|
||||
digest,
|
||||
signing,
|
||||
expiry,
|
||||
provider,
|
||||
receipt == null ? null : PartReceiptToken.parse(receipt),
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectDirectSessionRecord readDirectSession(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectOperationKey operationKey =
|
||||
new ObjectOperationKey(
|
||||
ObjectDestinationId.of(reader.string("destination")),
|
||||
ObjectOperationEpoch.of(reader.string("operationEpoch")),
|
||||
ObjectOperationId.of(reader.string("operationId")));
|
||||
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
|
||||
DirectGrantSessionState state =
|
||||
enumValue(DirectGrantSessionState.class, reader.string("state"));
|
||||
long generation = reader.number("grantGeneration");
|
||||
String constraints = reader.nullableString("constraintsDigest");
|
||||
String signing = reader.nullableString("signingTime");
|
||||
String expiry = reader.nullableString("expiresAt");
|
||||
String credentials = reader.nullableString("credentialRevision");
|
||||
String reference = reader.nullableString("referenceRevision");
|
||||
String dataKey = reader.string("canonicalDataKey");
|
||||
ObjectContentIdentity expected =
|
||||
readContentIdentity(
|
||||
reader.number("expectedSize"),
|
||||
reader.string("expectedDigestAlgorithm"),
|
||||
reader.string("expectedDigestValue"));
|
||||
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
|
||||
String exactVersion = reader.nullableString("exactVersion");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
if ((signing == null) != (expiry == null)) {
|
||||
throw corrupt();
|
||||
}
|
||||
DirectTransferSessionRecord session =
|
||||
new DirectTransferSessionRecord(
|
||||
operationKey,
|
||||
sessionId,
|
||||
state,
|
||||
generation,
|
||||
constraints,
|
||||
signing == null ? null : instant(signing),
|
||||
expiry == null ? null : instant(expiry),
|
||||
credentials,
|
||||
reference,
|
||||
dataKey,
|
||||
expected,
|
||||
mediaType,
|
||||
exactVersion == null ? null : ObjectVersionToken.parse(exactVersion),
|
||||
updatedAt);
|
||||
return new ObjectDirectSessionRecord(1, recordId, revision, session);
|
||||
}
|
||||
|
||||
private static ObjectOperationRecord readOperation(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectOperationKey operationKey =
|
||||
new ObjectOperationKey(
|
||||
ObjectDestinationId.of(reader.string("destination")),
|
||||
ObjectOperationEpoch.of(reader.string("operationEpoch")),
|
||||
ObjectOperationId.of(reader.string("operationId")));
|
||||
String requestFingerprint = reader.string("requestFingerprint");
|
||||
String operationKind = reader.string("operationKind");
|
||||
ObjectRouteToken route = ObjectRouteToken.parse(reader.string("route"));
|
||||
ObjectBindingRevision binding = ObjectBindingRevision.of(reader.string("bindingRevision"));
|
||||
ObjectPolicyRevision policy = ObjectPolicyRevision.of(reader.string("policyRevision"));
|
||||
ObjectPublicationRequirement publication =
|
||||
enumValue(ObjectPublicationRequirement.class, reader.string("publicationRequirement"));
|
||||
ObjectRetentionRequirement retention =
|
||||
enumValue(ObjectRetentionRequirement.class, reader.string("retentionRequirement"));
|
||||
ObjectEncryptionRequirement encryption =
|
||||
enumValue(ObjectEncryptionRequirement.class, reader.string("encryptionRequirement"));
|
||||
EnumSet<dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement>
|
||||
capabilities = readCapabilities(reader.string("capabilityRequirements"));
|
||||
long maximumObjectBytes = reader.number("maximumObjectBytes");
|
||||
long rawChunkBytes = reader.number("chunkBytes");
|
||||
if (rawChunkBytes > Integer.MAX_VALUE || rawChunkBytes < Integer.MIN_VALUE) {
|
||||
throw corrupt();
|
||||
}
|
||||
long minimumReplaySeconds = reader.number("minimumReplaySeconds");
|
||||
ObjectPolicySnapshot policySnapshot =
|
||||
new ObjectPolicySnapshot(
|
||||
binding,
|
||||
policy,
|
||||
publication,
|
||||
retention,
|
||||
encryption,
|
||||
capabilities,
|
||||
maximumObjectBytes,
|
||||
(int) rawChunkBytes,
|
||||
java.time.Duration.ofSeconds(minimumReplaySeconds));
|
||||
String policyDigest = reader.string("policySnapshotDigest");
|
||||
ObjectContentIdentity expected =
|
||||
readContentIdentity(
|
||||
reader.nullableNumber("expectedSize"),
|
||||
reader.nullableString("expectedDigestAlgorithm"),
|
||||
reader.nullableString("expectedDigestValue"));
|
||||
ObjectContentIdentity observed =
|
||||
readContentIdentity(
|
||||
reader.nullableNumber("observedSize"),
|
||||
reader.nullableString("observedDigestAlgorithm"),
|
||||
reader.nullableString("observedDigestValue"));
|
||||
PublicationOperationState state =
|
||||
enumValue(PublicationOperationState.class, reader.string("stablePhase"));
|
||||
long stateVersion = reader.number("stateVersion");
|
||||
String pendingKind = reader.nullableString("pendingKind");
|
||||
String attemptId = reader.nullableString("attemptId");
|
||||
String target = reader.nullableString("exactTargetEvidence");
|
||||
String desired = reader.nullableString("desiredState");
|
||||
String precondition = reader.nullableString("precondition");
|
||||
String evidence = reader.nullableString("requestEvidenceDigest");
|
||||
ObjectEffectCertainty certainty =
|
||||
enumValue(ObjectEffectCertainty.class, reader.string("effectCertainty"));
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
PendingObjectEffect pending =
|
||||
pendingKind == null
|
||||
? requireAllNull(attemptId, target, desired, precondition, evidence)
|
||||
: new PendingObjectEffect(
|
||||
enumValue(PendingObjectEffect.Kind.class, pendingKind),
|
||||
attemptId,
|
||||
target,
|
||||
desired,
|
||||
precondition,
|
||||
evidence);
|
||||
return new ObjectOperationRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
operationKey,
|
||||
requestFingerprint,
|
||||
operationKind,
|
||||
route,
|
||||
policySnapshot,
|
||||
policyDigest,
|
||||
expected,
|
||||
observed,
|
||||
state,
|
||||
stateVersion,
|
||||
pending,
|
||||
certainty,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectManifestRecord readManifest(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectId objectId = ObjectId.parse(reader.string("objectId"));
|
||||
ObjectRouteToken route = ObjectRouteToken.parse(reader.string("route"));
|
||||
String providerType = reader.string("providerType");
|
||||
String providerVersion = reader.string("providerVersion");
|
||||
ObjectBindingRevision binding = ObjectBindingRevision.of(reader.string("bindingRevision"));
|
||||
long logicalSize = reader.number("logicalSize");
|
||||
ObjectDigest digest =
|
||||
ObjectDigest.of(
|
||||
enumValue(ObjectDigestAlgorithm.class, reader.string("digestAlgorithm")),
|
||||
reader.string("digestValue"));
|
||||
ObjectMediaType mediaType = ObjectMediaType.of(reader.string("mediaType"));
|
||||
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("immutableDataVersion"));
|
||||
String encryption = reader.string("encryptionEvidence");
|
||||
String retention = reader.string("retentionEvidence");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectManifestRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
objectId,
|
||||
route,
|
||||
providerType,
|
||||
providerVersion,
|
||||
binding,
|
||||
logicalSize,
|
||||
digest,
|
||||
mediaType,
|
||||
version,
|
||||
encryption,
|
||||
retention,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectReferencePointerRecord readPointer(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectReference reference = ObjectReference.parse(reader.string("reference"));
|
||||
long manifestRevision = reader.number("manifestRevision");
|
||||
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("immutableDataVersion"));
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectReferencePointerRecord(
|
||||
1, recordId, revision, reference, manifestRevision, version, updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectReferenceRecord readReference(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
ObjectReference reference = ObjectReference.parse(reader.string("reference"));
|
||||
ObjectId objectId = ObjectId.parse(reader.string("objectId"));
|
||||
ObjectVersionToken version = ObjectVersionToken.parse(reader.string("immutableDataVersion"));
|
||||
PublishedReferenceState state =
|
||||
enumValue(PublishedReferenceState.class, reader.string("state"));
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectReferenceRecord(
|
||||
1, recordId, revision, reference, objectId, version, state, updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectMultipartSessionRecord readSession(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
|
||||
MultipartUploadState multipart =
|
||||
enumValue(MultipartUploadState.class, reader.string("multipartState"));
|
||||
DirectGrantSessionState grant =
|
||||
enumValue(DirectGrantSessionState.class, reader.string("grantState"));
|
||||
String provider = reader.string("providerSessionEvidence");
|
||||
long count = reader.number("partCount");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectMultipartSessionRecord(
|
||||
1, recordId, revision, sessionId, multipart, grant, provider, count, updatedAt);
|
||||
}
|
||||
|
||||
private static ObjectMultipartPartRecord readPart(
|
||||
CanonicalJsonReader reader, String recordId, long revision) {
|
||||
DirectTransferSessionId sessionId = DirectTransferSessionId.parse(reader.string("sessionId"));
|
||||
long rawPartNumber = reader.number("partNumber");
|
||||
if (rawPartNumber > Integer.MAX_VALUE || rawPartNumber < Integer.MIN_VALUE) {
|
||||
throw corrupt();
|
||||
}
|
||||
MultipartPartNumber partNumber = MultipartPartNumber.of((int) rawPartNumber);
|
||||
PartReceiptToken token = PartReceiptToken.parse(reader.string("receiptToken"));
|
||||
long logicalSize = reader.number("logicalSize");
|
||||
ObjectDigest digest =
|
||||
ObjectDigest.of(
|
||||
enumValue(ObjectDigestAlgorithm.class, reader.string("digestAlgorithm")),
|
||||
reader.string("digestValue"));
|
||||
String provider = reader.string("providerPartEvidence");
|
||||
Instant updatedAt = instant(reader.string("updatedAt"));
|
||||
reader.end();
|
||||
return new ObjectMultipartPartRecord(
|
||||
1,
|
||||
recordId,
|
||||
revision,
|
||||
sessionId,
|
||||
partNumber,
|
||||
token,
|
||||
logicalSize,
|
||||
digest,
|
||||
provider,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static PendingObjectEffect requireAllNull(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null) {
|
||||
throw corrupt();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ObjectContentIdentity readContentIdentity(
|
||||
Long size, String algorithm, String digestValue) {
|
||||
if (size == null && algorithm == null && digestValue == null) {
|
||||
return null;
|
||||
}
|
||||
if (size == null || algorithm == null || digestValue == null) {
|
||||
throw corrupt();
|
||||
}
|
||||
return new ObjectContentIdentity(
|
||||
size, ObjectDigest.of(enumValue(ObjectDigestAlgorithm.class, algorithm), digestValue));
|
||||
}
|
||||
|
||||
private static EnumSet<dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement>
|
||||
readCapabilities(String canonical) {
|
||||
EnumSet<dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement> result =
|
||||
EnumSet.noneOf(
|
||||
dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement.class);
|
||||
if (canonical.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
String previous = null;
|
||||
for (String value : canonical.split(",", -1)) {
|
||||
if (previous != null && previous.compareTo(value) >= 0) {
|
||||
throw corrupt();
|
||||
}
|
||||
result.add(
|
||||
enumValue(
|
||||
dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement.class,
|
||||
value));
|
||||
previous = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Instant instant(String value) {
|
||||
try {
|
||||
return Instant.parse(value);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ObjectControlCorruptionException("control timestamp is invalid", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static <E extends Enum<E>> E enumValue(Class<E> type, String value) {
|
||||
try {
|
||||
return Enum.valueOf(type, value);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ObjectControlCorruptionException("control enum value is invalid", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void enforceFamilySize(ObjectControlRecord record, int payloadLength) {
|
||||
int maximum =
|
||||
record instanceof ObjectMultipartPartRecord
|
||||
? MAXIMUM_PART_BYTES
|
||||
: record.terminal() ? MAXIMUM_TERMINAL_RECEIPT_BYTES : MAXIMUM_ENVELOPE_BYTES;
|
||||
if (payloadLength > maximum) {
|
||||
throw corrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectControlCorruptionException corrupt() {
|
||||
return new ObjectControlCorruptionException("control record is corrupt");
|
||||
}
|
||||
}
|
||||
+144
@@ -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");
|
||||
}
|
||||
}
|
||||
+90
@@ -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('"');
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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");
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
+45
@@ -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]";
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.control;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Provider-neutral exact-lookup/create/CAS control storage. LIST is deliberately absent. */
|
||||
public interface ObjectControlStore {
|
||||
|
||||
Optional<StoredControlRecord> read(String key);
|
||||
|
||||
StoredControlRecord create(String key, ObjectControlRecord record);
|
||||
|
||||
StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation);
|
||||
|
||||
record StoredControlRecord(ObjectControlVersion version, ObjectControlRecord record) {
|
||||
|
||||
public StoredControlRecord {
|
||||
Objects.requireNonNull(version, "version must be non-null");
|
||||
Objects.requireNonNull(record, "record must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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]";
|
||||
}
|
||||
}
|
||||
+96
@@ -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);
|
||||
}
|
||||
}
|
||||
+198
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+210
@@ -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();
|
||||
}
|
||||
}
|
||||
+188
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
|
||||
import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest;
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Provider-private signer, verifier, and published-object resolution seam. */
|
||||
public interface DirectGrantProvider {
|
||||
|
||||
DirectGrantMaterial signUpload(DirectTransferSessionRecord session);
|
||||
|
||||
DirectGrantMaterial signDownload(DirectTransferSessionRecord session, PublishedObject published);
|
||||
|
||||
VerifiedUpload verifyUpload(DirectTransferSessionRecord session);
|
||||
|
||||
PublishedObject resolvePublished(DirectDownloadGrantRequest request);
|
||||
|
||||
final class DirectGrantMaterial {
|
||||
|
||||
private final URI requestUri;
|
||||
private final Map<String, String> signedHeaders;
|
||||
private final Instant expiresAt;
|
||||
|
||||
public DirectGrantMaterial(
|
||||
URI requestUri, Map<String, String> signedHeaders, Instant expiresAt) {
|
||||
this.requestUri = Objects.requireNonNull(requestUri, "requestUri must be non-null");
|
||||
this.signedHeaders =
|
||||
Map.copyOf(Objects.requireNonNull(signedHeaders, "signedHeaders must be non-null"));
|
||||
this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt must be non-null");
|
||||
}
|
||||
|
||||
public URI requestUri() {
|
||||
return requestUri;
|
||||
}
|
||||
|
||||
public Map<String, String> signedHeaders() {
|
||||
return signedHeaders;
|
||||
}
|
||||
|
||||
public Instant expiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DirectGrantMaterial[uri="
|
||||
+ PresignedGrantRedactor.redact(requestUri)
|
||||
+ ", headers="
|
||||
+ PresignedGrantRedactor.describeHeaders(signedHeaders)
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
record VerifiedUpload(ObjectContentIdentity contentIdentity, ObjectVersionToken exactVersion) {
|
||||
|
||||
public VerifiedUpload {
|
||||
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
|
||||
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record PublishedObject(
|
||||
String canonicalDataKey,
|
||||
ObjectContentIdentity contentIdentity,
|
||||
ObjectMediaType mediaType,
|
||||
ObjectVersionToken exactVersion,
|
||||
String referenceRevision,
|
||||
boolean active) {
|
||||
|
||||
public PublishedObject {
|
||||
if (canonicalDataKey == null
|
||||
|| canonicalDataKey.length() > 1024
|
||||
|| !canonicalDataKey.startsWith("data/v1/")) {
|
||||
throw new IllegalArgumentException("published canonical data key is invalid");
|
||||
}
|
||||
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
|
||||
Objects.requireNonNull(mediaType, "mediaType must be non-null");
|
||||
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
|
||||
if (referenceRevision == null
|
||||
|| referenceRevision.isBlank()
|
||||
|| referenceRevision.length() > 128) {
|
||||
throw new IllegalArgumentException("published reference revision is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord;
|
||||
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Admission-horizon and exact server-issued token checks before provider completion. */
|
||||
public final class DirectMultipartCompletionVerifier {
|
||||
|
||||
private DirectMultipartCompletionVerifier() {}
|
||||
|
||||
public static void requireAdmissionDrained(
|
||||
Instant now,
|
||||
Instant latestGrantExpiresAt,
|
||||
Duration qualifiedClockSkew,
|
||||
Duration maximumInFlightHorizon,
|
||||
boolean controlledIngressDrained) {
|
||||
Objects.requireNonNull(now, "now must be non-null");
|
||||
Objects.requireNonNull(qualifiedClockSkew, "qualifiedClockSkew must be non-null");
|
||||
Objects.requireNonNull(maximumInFlightHorizon, "maximumInFlightHorizon must be non-null");
|
||||
if (controlledIngressDrained || latestGrantExpiresAt == null) {
|
||||
return;
|
||||
}
|
||||
Instant safeAfter = latestGrantExpiresAt.plus(qualifiedClockSkew).plus(maximumInFlightHorizon);
|
||||
if (now.isBefore(safeAfter)) {
|
||||
throw new IllegalStateException("direct multipart part requests may still be in flight");
|
||||
}
|
||||
}
|
||||
|
||||
public static void requireExactLedger(
|
||||
List<PartReceiptToken> requested,
|
||||
List<ObjectMultipartPartRecord> records,
|
||||
ObjectContentIdentity expected) {
|
||||
Objects.requireNonNull(requested, "requested tokens must be non-null");
|
||||
Objects.requireNonNull(records, "part records must be non-null");
|
||||
Objects.requireNonNull(expected, "expected content must be non-null");
|
||||
if (requested.size() != records.size()) {
|
||||
throw new IllegalArgumentException("direct multipart token ledger size conflicts");
|
||||
}
|
||||
long total = 0;
|
||||
for (int index = 0; index < records.size(); index++) {
|
||||
ObjectMultipartPartRecord part = records.get(index);
|
||||
if (part.partNumber().value() != index + 1
|
||||
|| !part.receiptToken().equals(requested.get(index))) {
|
||||
throw new IllegalArgumentException("direct multipart token ledger is not exact");
|
||||
}
|
||||
total = Math.addExact(total, part.logicalSize());
|
||||
}
|
||||
if (total != expected.exactLength()) {
|
||||
throw new IllegalArgumentException("direct multipart logical length conflicts");
|
||||
}
|
||||
}
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.CrockfordBase32;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartSessionRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.MultipartUploadState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.multipart.MultipartPartLedger;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.s3.S3ClientPolicy;
|
||||
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectId;
|
||||
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
|
||||
import dev.caskeleton.application.objectstorage.model.MultipartReceipt;
|
||||
import dev.caskeleton.application.objectstorage.model.MultipartSession;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
|
||||
import dev.caskeleton.application.objectstorage.model.PartUploadGrant;
|
||||
import dev.caskeleton.application.objectstorage.port.DirectMultipartUploadPort;
|
||||
import dev.caskeleton.application.objectstorage.request.MultipartAbortRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.MultipartCompleteRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement;
|
||||
import dev.caskeleton.application.objectstorage.request.MultipartStartRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.PartUploadGrantRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Durable direct multipart coordinator with explicit admission-close and provider fences. */
|
||||
public final class DirectMultipartCoordinator implements DirectMultipartUploadPort {
|
||||
|
||||
private final ObjectRouteToken route;
|
||||
private final ObjectControlStore store;
|
||||
private final DirectMultipartProvider provider;
|
||||
private final Clock clock;
|
||||
private final Duration qualifiedClockSkew;
|
||||
private final Duration maximumInFlightHorizon;
|
||||
private final Map<String, DirectGrantProvider.DirectGrantMaterial> issued =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
public DirectMultipartCoordinator(
|
||||
ObjectRouteToken route,
|
||||
ObjectControlStore store,
|
||||
DirectMultipartProvider provider,
|
||||
Clock clock,
|
||||
Duration qualifiedClockSkew,
|
||||
Duration maximumInFlightHorizon) {
|
||||
this.route = Objects.requireNonNull(route, "route must be non-null");
|
||||
this.store = Objects.requireNonNull(store, "store must be non-null");
|
||||
this.provider = Objects.requireNonNull(provider, "provider must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
this.qualifiedClockSkew = requirePositive(qualifiedClockSkew, "qualifiedClockSkew");
|
||||
this.maximumInFlightHorizon = requirePositive(maximumInFlightHorizon, "maximumInFlightHorizon");
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultipartSession startMultipart(MultipartStartRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
Instant now = clock.instant();
|
||||
String fingerprint = fingerprint(request);
|
||||
ObjectId objectId = objectId(request.operationKey().toString() + fingerprint);
|
||||
DirectTransferSessionId sessionId = ObjectHandleCodec.multipart(route, objectId);
|
||||
String key = ObjectControlKeyCodec.multipart(route, sessionId);
|
||||
ObjectDirectMultipartSessionRecord initiating =
|
||||
new ObjectDirectMultipartSessionRecord(
|
||||
1,
|
||||
sessionId.canonicalText(),
|
||||
1,
|
||||
request.operationKey(),
|
||||
sessionId,
|
||||
MultipartUploadState.INITIATE_IN_PROGRESS,
|
||||
ObjectDataKeyCodec.encode(route, objectId, ObjectDataKeyCodec.Generation.of(1)),
|
||||
request.contentIdentity(),
|
||||
request.declaredMediaType(),
|
||||
null,
|
||||
request.maximumParts(),
|
||||
0,
|
||||
now.plus(request.sessionTtl()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
now);
|
||||
ObjectControlStore.StoredControlRecord stored;
|
||||
boolean created = true;
|
||||
try {
|
||||
stored = store.create(key, initiating);
|
||||
} catch (ObjectControlConflictException conflict) {
|
||||
created = false;
|
||||
stored =
|
||||
store
|
||||
.read(key)
|
||||
.orElseThrow(() -> new IllegalStateException("multipart start conflict vanished"));
|
||||
initiating = session(stored);
|
||||
requireSameStart(initiating, request);
|
||||
}
|
||||
ObjectDirectMultipartSessionRecord active = initiating;
|
||||
if (active.multipartState() == MultipartUploadState.INITIATE_IN_PROGRESS) {
|
||||
DirectMultipartProvider.InitiateResult initiated;
|
||||
if (created) {
|
||||
try {
|
||||
initiated = provider.initiate(active);
|
||||
} catch (RuntimeException responseLoss) {
|
||||
DirectMultipartProvider.InitiateResolution resolution = provider.resolveInitiate(active);
|
||||
if (resolution.resolution() != DirectMultipartProvider.Resolution.APPLIED) {
|
||||
throw new IllegalStateException(
|
||||
"direct multipart initiation remains indeterminate", responseLoss);
|
||||
}
|
||||
initiated =
|
||||
new DirectMultipartProvider.InitiateResult(resolution.providerSessionEvidence());
|
||||
}
|
||||
} else {
|
||||
DirectMultipartProvider.InitiateResolution resolution = provider.resolveInitiate(active);
|
||||
if (resolution.resolution() != DirectMultipartProvider.Resolution.APPLIED) {
|
||||
throw new IllegalStateException(
|
||||
"durable direct multipart initiation remains indeterminate");
|
||||
}
|
||||
initiated =
|
||||
new DirectMultipartProvider.InitiateResult(resolution.providerSessionEvidence());
|
||||
}
|
||||
ObjectDirectMultipartSessionRecord accepting =
|
||||
active.withProviderSession(initiated.providerSessionEvidence(), clock.instant());
|
||||
stored = store.compareAndSet(key, new ObjectControlMutation(stored.version(), accepting));
|
||||
active = session(stored);
|
||||
}
|
||||
if (active.multipartState() != MultipartUploadState.ACCEPTING_PARTS) {
|
||||
throw new IllegalStateException("direct multipart session admission is closed");
|
||||
}
|
||||
return new MultipartSession(
|
||||
request.operationKey(),
|
||||
sessionId,
|
||||
active.sessionExpiresAt(),
|
||||
active.maximumParts(),
|
||||
created ? ObjectMutationOutcome.APPLIED : ObjectMutationOutcome.REPLAYED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PartUploadGrant createPartGrant(PartUploadGrantRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
String sessionKey = ObjectControlKeyCodec.multipart(route, request.sessionId());
|
||||
ObjectControlStore.StoredControlRecord stored = load(sessionKey);
|
||||
ObjectDirectMultipartSessionRecord session = session(stored);
|
||||
requireSameOperation(session, request.operationKey());
|
||||
requireAcceptingParts(session, session.multipartState());
|
||||
if (request.partNumber().value() > session.maximumParts()) {
|
||||
throw new IllegalArgumentException("direct multipart part exceeds the session bound");
|
||||
}
|
||||
S3ClientPolicy.requirePartSize(request.exactPartLength(), false);
|
||||
Instant now = clock.instant();
|
||||
Instant expiresAt = now.plus(request.requestedTtl());
|
||||
if (!expiresAt.isBefore(session.sessionExpiresAt())) {
|
||||
throw new IllegalArgumentException("part grant exceeds the multipart session horizon");
|
||||
}
|
||||
DirectMultipartGrantLedger ledger =
|
||||
new DirectMultipartGrantLedger(store, route, request.sessionId());
|
||||
DirectMultipartGrantLedger.Stored grant =
|
||||
ledger.prepare(
|
||||
request.partNumber(),
|
||||
request.exactPartLength(),
|
||||
request.expectedPartDigest(),
|
||||
now,
|
||||
expiresAt);
|
||||
requireSameGrant(grant.record(), request);
|
||||
if (grant.record().grantState() == DirectGrantSessionState.GRANT_ISSUED) {
|
||||
DirectGrantProvider.DirectGrantMaterial material = issued.get(grantCacheKey(grant.record()));
|
||||
if (material == null) {
|
||||
throw new IllegalStateException(
|
||||
"issued direct multipart bearer is unavailable after process restart");
|
||||
}
|
||||
return partGrant(grant.record(), material);
|
||||
}
|
||||
ObjectDirectMultipartSessionRecord withExpiry = session.recordGrantExpiry(expiresAt, now);
|
||||
stored =
|
||||
store.compareAndSet(sessionKey, new ObjectControlMutation(stored.version(), withExpiry));
|
||||
session = session(stored);
|
||||
DirectGrantProvider.DirectGrantMaterial material = provider.signPart(session, grant.record());
|
||||
grant = ledger.issue(grant, clock.instant());
|
||||
issued.put(grantCacheKey(grant.record()), material);
|
||||
return partGrant(grant.record(), material);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PartReceiptToken acknowledgePart(MultipartPartAcknowledgement request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
String sessionKey = ObjectControlKeyCodec.multipart(route, request.sessionId());
|
||||
ObjectControlStore.StoredControlRecord stored = load(sessionKey);
|
||||
ObjectDirectMultipartSessionRecord session = session(stored);
|
||||
requireSameOperation(session, request.operationKey());
|
||||
requireAcceptingParts(session, session.multipartState());
|
||||
DirectMultipartGrantLedger ledger =
|
||||
new DirectMultipartGrantLedger(store, route, request.sessionId());
|
||||
DirectMultipartGrantLedger.Stored grant = ledger.load(request.partNumber());
|
||||
if (grant.record().grantState() == DirectGrantSessionState.DATA_UPLOADED) {
|
||||
return Objects.requireNonNull(grant.record().receiptToken());
|
||||
}
|
||||
if (grant.record().grantState() != DirectGrantSessionState.GRANT_ISSUED) {
|
||||
throw new IllegalStateException("direct multipart part grant is not issued");
|
||||
}
|
||||
DirectMultipartProvider.VerifiedPart verified =
|
||||
DirectPartAcknowledgementVerifier.verify(
|
||||
grant.record(), request, provider.verifyPart(session, request));
|
||||
PartReceiptToken token =
|
||||
ObjectHandleCodec.partReceipt(
|
||||
request.sessionId(), request.partNumber(), request.observedDigest());
|
||||
ObjectMultipartPartRecord part =
|
||||
new ObjectMultipartPartRecord(
|
||||
1,
|
||||
request.sessionId().canonicalText() + ":" + request.partNumber().value(),
|
||||
1,
|
||||
request.sessionId(),
|
||||
request.partNumber(),
|
||||
token,
|
||||
request.observedLength(),
|
||||
request.observedDigest(),
|
||||
verified.boundedProviderEvidence(),
|
||||
clock.instant());
|
||||
new MultipartPartLedger(store, route, request.sessionId()).record(part);
|
||||
ledger.acknowledge(grant, verified.boundedProviderEvidence(), token, clock.instant());
|
||||
store.compareAndSet(
|
||||
sessionKey,
|
||||
new ObjectControlMutation(stored.version(), session.acknowledgePart(clock.instant())));
|
||||
issued.remove(grantCacheKey(grant.record()));
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultipartReceipt completeMultipart(MultipartCompleteRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
String key = ObjectControlKeyCodec.multipart(route, request.sessionId());
|
||||
ObjectControlStore.StoredControlRecord stored = load(key);
|
||||
ObjectDirectMultipartSessionRecord session = session(stored);
|
||||
requireSameOperation(session, request.operationKey());
|
||||
if (!session.expectedContent().equals(request.expectedContent())) {
|
||||
throw new IllegalArgumentException("direct multipart full identity conflicts");
|
||||
}
|
||||
if (session.multipartState() == MultipartUploadState.COMPLETED) {
|
||||
return receipt(
|
||||
request.operationKey(),
|
||||
request.sessionId(),
|
||||
session,
|
||||
Objects.requireNonNull(session.completedVersion()),
|
||||
ObjectMutationOutcome.REPLAYED);
|
||||
}
|
||||
if (session.multipartState() == MultipartUploadState.COMPLETE_IN_PROGRESS) {
|
||||
List<ObjectMultipartPartRecord> replayParts =
|
||||
new MultipartPartLedger(store, route, request.sessionId())
|
||||
.ordered(request.partTokens().size());
|
||||
DirectMultipartCompletionVerifier.requireExactLedger(
|
||||
request.partTokens(), replayParts, request.expectedContent());
|
||||
DirectGrantProvider.VerifiedUpload replayVerified =
|
||||
provider.resolveComplete(session, replayParts);
|
||||
return persistCompleted(
|
||||
key,
|
||||
stored,
|
||||
session,
|
||||
replayVerified,
|
||||
request.operationKey(),
|
||||
request.sessionId(),
|
||||
ObjectMutationOutcome.REPLAYED);
|
||||
}
|
||||
requireAcceptingParts(session, session.multipartState());
|
||||
DirectMultipartCompletionVerifier.requireAdmissionDrained(
|
||||
clock.instant(),
|
||||
session.latestGrantExpiresAt(),
|
||||
qualifiedClockSkew,
|
||||
maximumInFlightHorizon,
|
||||
provider.controlledIngressDrained(session));
|
||||
List<ObjectMultipartPartRecord> parts =
|
||||
new MultipartPartLedger(store, route, request.sessionId())
|
||||
.ordered(request.partTokens().size());
|
||||
DirectMultipartCompletionVerifier.requireExactLedger(
|
||||
request.partTokens(), parts, request.expectedContent());
|
||||
ObjectDirectMultipartSessionRecord completing =
|
||||
session.fence(MultipartUploadState.COMPLETE_IN_PROGRESS, clock.instant());
|
||||
stored = store.compareAndSet(key, new ObjectControlMutation(stored.version(), completing));
|
||||
DirectGrantProvider.VerifiedUpload verified;
|
||||
try {
|
||||
verified = provider.complete(completing, parts);
|
||||
} catch (RuntimeException responseLoss) {
|
||||
try {
|
||||
verified = provider.resolveComplete(completing, parts);
|
||||
} catch (RuntimeException unresolved) {
|
||||
unresolved.addSuppressed(responseLoss);
|
||||
throw unresolved;
|
||||
}
|
||||
}
|
||||
if (!verified.contentIdentity().equals(request.expectedContent())) {
|
||||
throw new IllegalStateException("direct multipart final verification conflicts");
|
||||
}
|
||||
return persistCompleted(
|
||||
key,
|
||||
stored,
|
||||
completing,
|
||||
verified,
|
||||
request.operationKey(),
|
||||
request.sessionId(),
|
||||
ObjectMutationOutcome.APPLIED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectMutationReceipt abortMultipart(MultipartAbortRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
String key = ObjectControlKeyCodec.multipart(route, request.sessionId());
|
||||
ObjectControlStore.StoredControlRecord stored = load(key);
|
||||
ObjectDirectMultipartSessionRecord session = session(stored);
|
||||
requireSameOperation(session, request.operationKey());
|
||||
if (session.multipartState() == MultipartUploadState.ABORTED) {
|
||||
return mutation(request.operationKey(), ObjectMutationOutcome.REPLAYED);
|
||||
}
|
||||
if (session.multipartState() == MultipartUploadState.ABORT_IN_PROGRESS) {
|
||||
if (provider.resolveAbort(session) != DirectMultipartProvider.Resolution.APPLIED) {
|
||||
throw new IllegalStateException("direct multipart abort remains indeterminate");
|
||||
}
|
||||
store.compareAndSet(
|
||||
key,
|
||||
new ObjectControlMutation(
|
||||
stored.version(),
|
||||
session.finish(MultipartUploadState.ABORTED, null, clock.instant())));
|
||||
return mutation(request.operationKey(), ObjectMutationOutcome.REPLAYED);
|
||||
}
|
||||
requireAcceptingParts(session, session.multipartState());
|
||||
ObjectDirectMultipartSessionRecord aborting =
|
||||
session.fence(MultipartUploadState.ABORT_IN_PROGRESS, clock.instant());
|
||||
stored = store.compareAndSet(key, new ObjectControlMutation(stored.version(), aborting));
|
||||
try {
|
||||
provider.abort(aborting);
|
||||
} catch (RuntimeException responseLoss) {
|
||||
if (provider.resolveAbort(aborting) != DirectMultipartProvider.Resolution.APPLIED) {
|
||||
throw new IllegalStateException(
|
||||
"direct multipart abort remains indeterminate", responseLoss);
|
||||
}
|
||||
}
|
||||
store.compareAndSet(
|
||||
key,
|
||||
new ObjectControlMutation(
|
||||
stored.version(),
|
||||
aborting.finish(MultipartUploadState.ABORTED, null, clock.instant())));
|
||||
return mutation(request.operationKey(), ObjectMutationOutcome.APPLIED);
|
||||
}
|
||||
|
||||
public static void requireAcceptingParts(
|
||||
ObjectDirectMultipartSessionRecord session, MultipartUploadState observedState) {
|
||||
Objects.requireNonNull(session, "session must be non-null");
|
||||
if (observedState != MultipartUploadState.ACCEPTING_PARTS) {
|
||||
throw new IllegalStateException("direct multipart part admission is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectControlStore.StoredControlRecord load(String key) {
|
||||
return store
|
||||
.read(key)
|
||||
.orElseThrow(() -> new IllegalArgumentException("multipart session is absent"));
|
||||
}
|
||||
|
||||
private static ObjectDirectMultipartSessionRecord session(
|
||||
ObjectControlStore.StoredControlRecord stored) {
|
||||
if (!(stored.record() instanceof ObjectDirectMultipartSessionRecord session)) {
|
||||
throw new IllegalStateException("direct multipart session family is corrupt");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private static void requireSameStart(
|
||||
ObjectDirectMultipartSessionRecord session, MultipartStartRequest request) {
|
||||
requireSameOperation(session, request.operationKey());
|
||||
if (!session.expectedContent().equals(request.contentIdentity())
|
||||
|| !session.mediaType().equals(request.declaredMediaType())
|
||||
|| session.maximumParts() != request.maximumParts()) {
|
||||
throw new IllegalArgumentException("direct multipart start intent conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameGrant(
|
||||
ObjectDirectMultipartGrantRecord grant, PartUploadGrantRequest request) {
|
||||
if (!grant.sessionId().equals(request.sessionId())
|
||||
|| !grant.partNumber().equals(request.partNumber())
|
||||
|| grant.exactPartLength() != request.exactPartLength()
|
||||
|| !grant.expectedPartDigest().equals(request.expectedPartDigest())) {
|
||||
throw new IllegalArgumentException("direct multipart part grant intent conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameOperation(
|
||||
ObjectDirectMultipartSessionRecord session,
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey) {
|
||||
if (!session.operationKey().equals(operationKey)) {
|
||||
throw new IllegalArgumentException("direct multipart operation identity conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
private static PartUploadGrant partGrant(
|
||||
ObjectDirectMultipartGrantRecord grant, DirectGrantProvider.DirectGrantMaterial material) {
|
||||
return new PartUploadGrant(
|
||||
grant.sessionId(),
|
||||
grant.partNumber(),
|
||||
material.requestUri(),
|
||||
material.signedHeaders(),
|
||||
grant.expiresAt());
|
||||
}
|
||||
|
||||
private static String grantCacheKey(ObjectDirectMultipartGrantRecord grant) {
|
||||
return grant.sessionId().canonicalText() + ":" + grant.partNumber().value();
|
||||
}
|
||||
|
||||
private ObjectMutationReceipt mutation(
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
|
||||
ObjectMutationOutcome outcome) {
|
||||
return new ObjectMutationReceipt(
|
||||
operation, outcome, ObjectOperationError.NONE, clock.instant());
|
||||
}
|
||||
|
||||
private MultipartReceipt receipt(
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
|
||||
DirectTransferSessionId sessionId,
|
||||
ObjectDirectMultipartSessionRecord session,
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectVersionToken exactVersion,
|
||||
ObjectMutationOutcome outcome) {
|
||||
ObjectId objectId = objectIdFromDataKey(session.canonicalDataKey());
|
||||
return new MultipartReceipt(
|
||||
operation,
|
||||
sessionId,
|
||||
ObjectHandleCodec.stage(route, objectId),
|
||||
exactVersion,
|
||||
session.expectedContent(),
|
||||
outcome);
|
||||
}
|
||||
|
||||
private MultipartReceipt persistCompleted(
|
||||
String key,
|
||||
ObjectControlStore.StoredControlRecord stored,
|
||||
ObjectDirectMultipartSessionRecord completing,
|
||||
DirectGrantProvider.VerifiedUpload verified,
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation,
|
||||
DirectTransferSessionId sessionId,
|
||||
ObjectMutationOutcome outcome) {
|
||||
if (!verified.contentIdentity().equals(completing.expectedContent())) {
|
||||
throw new IllegalStateException("direct multipart final verification conflicts");
|
||||
}
|
||||
ObjectDirectMultipartSessionRecord completed =
|
||||
completing.finish(MultipartUploadState.COMPLETED, verified.exactVersion(), clock.instant());
|
||||
store.compareAndSet(key, new ObjectControlMutation(stored.version(), completed));
|
||||
return receipt(operation, sessionId, completed, verified.exactVersion(), outcome);
|
||||
}
|
||||
|
||||
private static String fingerprint(MultipartStartRequest request) {
|
||||
return sha256(
|
||||
request.operationKey()
|
||||
+ "\n"
|
||||
+ request.declaredMediaType().canonicalText()
|
||||
+ "\n"
|
||||
+ request.contentIdentity().exactLength()
|
||||
+ "\n"
|
||||
+ request.contentIdentity().fullDigest().base64Value()
|
||||
+ "\n"
|
||||
+ request.maximumParts()
|
||||
+ "\n"
|
||||
+ request.sessionTtl());
|
||||
}
|
||||
|
||||
private static ObjectId objectId(String seed) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8));
|
||||
return ObjectId.parse(CrockfordBase32.encode(Arrays.copyOf(digest, 16), 26));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectId objectIdFromDataKey(String key) {
|
||||
String[] segments = key.split("/", -1);
|
||||
if (segments.length != 6) {
|
||||
throw new IllegalStateException("direct multipart data key is corrupt");
|
||||
}
|
||||
return ObjectId.parse(segments[4]);
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return java.util.HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration requirePositive(Duration value, String label) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalArgumentException(label + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+107
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartGrantRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectMultipartSessionRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectMultipartPartRecord;
|
||||
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
|
||||
import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Provider-private direct multipart calls and authoritative acknowledgement verification. */
|
||||
public interface DirectMultipartProvider {
|
||||
|
||||
InitiateResult initiate(ObjectDirectMultipartSessionRecord session);
|
||||
|
||||
InitiateResolution resolveInitiate(ObjectDirectMultipartSessionRecord session);
|
||||
|
||||
DirectGrantProvider.DirectGrantMaterial signPart(
|
||||
ObjectDirectMultipartSessionRecord session, ObjectDirectMultipartGrantRecord grant);
|
||||
|
||||
VerifiedPart verifyPart(
|
||||
ObjectDirectMultipartSessionRecord session, MultipartPartAcknowledgement acknowledgement);
|
||||
|
||||
boolean controlledIngressDrained(ObjectDirectMultipartSessionRecord session);
|
||||
|
||||
DirectGrantProvider.VerifiedUpload complete(
|
||||
ObjectDirectMultipartSessionRecord session, List<ObjectMultipartPartRecord> parts);
|
||||
|
||||
DirectGrantProvider.VerifiedUpload resolveComplete(
|
||||
ObjectDirectMultipartSessionRecord session, List<ObjectMultipartPartRecord> parts);
|
||||
|
||||
void abort(ObjectDirectMultipartSessionRecord session);
|
||||
|
||||
Resolution resolveAbort(ObjectDirectMultipartSessionRecord session);
|
||||
|
||||
record InitiateResult(String providerSessionEvidence) {
|
||||
|
||||
public InitiateResult {
|
||||
providerSessionEvidence =
|
||||
requireEvidence("providerSessionEvidence", providerSessionEvidence, 512);
|
||||
}
|
||||
}
|
||||
|
||||
record InitiateResolution(Resolution resolution, String providerSessionEvidence) {
|
||||
|
||||
public InitiateResolution {
|
||||
Objects.requireNonNull(resolution, "resolution must be non-null");
|
||||
if (resolution == Resolution.APPLIED) {
|
||||
providerSessionEvidence =
|
||||
requireEvidence("providerSessionEvidence", providerSessionEvidence, 512);
|
||||
} else if (providerSessionEvidence != null) {
|
||||
throw new IllegalArgumentException("unapplied initiate resolution contains evidence");
|
||||
}
|
||||
}
|
||||
|
||||
public static InitiateResolution applied(String evidence) {
|
||||
return new InitiateResolution(Resolution.APPLIED, evidence);
|
||||
}
|
||||
|
||||
public static InitiateResolution indeterminate() {
|
||||
return new InitiateResolution(Resolution.INDETERMINATE, null);
|
||||
}
|
||||
}
|
||||
|
||||
record VerifiedPart(
|
||||
MultipartPartNumber partNumber,
|
||||
long exactLength,
|
||||
ObjectDigest digest,
|
||||
String privateEtag,
|
||||
String providerChecksum) {
|
||||
|
||||
public VerifiedPart {
|
||||
Objects.requireNonNull(partNumber, "partNumber must be non-null");
|
||||
if (exactLength < 1) {
|
||||
throw new IllegalArgumentException("verified direct part length must be positive");
|
||||
}
|
||||
Objects.requireNonNull(digest, "digest must be non-null");
|
||||
privateEtag = requireEvidence("privateEtag", privateEtag, 256);
|
||||
providerChecksum = requireEvidence("providerChecksum", providerChecksum, 256);
|
||||
}
|
||||
|
||||
public String boundedProviderEvidence() {
|
||||
return privateEtag + ":" + providerChecksum;
|
||||
}
|
||||
}
|
||||
|
||||
enum Resolution {
|
||||
APPLIED,
|
||||
NOT_APPLIED,
|
||||
INDETERMINATE
|
||||
}
|
||||
|
||||
private static String requireEvidence(String label, String value, int maximum) {
|
||||
if (value == null || value.isBlank() || value.length() > maximum) {
|
||||
throw new IllegalArgumentException(label + " is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.CrockfordBase32;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectControlKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectDataKeyCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectDirectSessionRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.DirectGrantSessionState;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
|
||||
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectId;
|
||||
import dev.caskeleton.application.objectstorage.model.DirectDownloadGrant;
|
||||
import dev.caskeleton.application.objectstorage.model.DirectUploadCompletionReceipt;
|
||||
import dev.caskeleton.application.objectstorage.model.DirectUploadGrant;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
|
||||
import dev.caskeleton.application.objectstorage.port.DirectObjectDownloadGrantPort;
|
||||
import dev.caskeleton.application.objectstorage.port.DirectObjectUploadPort;
|
||||
import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.DirectUploadCompletionRequest;
|
||||
import dev.caskeleton.application.objectstorage.request.DirectUploadGrantRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Direct grant state machine. It persists only non-secret generation evidence and retains bearer
|
||||
* material in a bounded process-local replay cache.
|
||||
*/
|
||||
public final class DirectTransferCoordinator
|
||||
implements DirectObjectUploadPort, DirectObjectDownloadGrantPort {
|
||||
|
||||
private final ObjectRouteToken route;
|
||||
private final ObjectControlStore store;
|
||||
private final DirectTransferPolicy policy;
|
||||
private final DirectGrantProvider provider;
|
||||
private final Clock clock;
|
||||
private final Map<String, DirectGrantProvider.DirectGrantMaterial> issued =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
public DirectTransferCoordinator(
|
||||
ObjectRouteToken route,
|
||||
ObjectControlStore store,
|
||||
DirectTransferPolicy policy,
|
||||
DirectGrantProvider provider,
|
||||
Clock clock) {
|
||||
this.route = Objects.requireNonNull(route, "route must be non-null");
|
||||
this.store = Objects.requireNonNull(store, "store must be non-null");
|
||||
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
|
||||
this.provider = Objects.requireNonNull(provider, "provider must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public DirectUploadGrant createUploadGrant(DirectUploadGrantRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
Instant now = clock.instant();
|
||||
DirectGrantGeneration planned =
|
||||
policy.planGrant(
|
||||
policy.planningEndpoint(),
|
||||
request.requestedTtl(),
|
||||
request.contentIdentity().exactLength(),
|
||||
now);
|
||||
String constraints = constraints(request);
|
||||
ObjectId objectId = objectId(request.operationKey().toString() + constraints);
|
||||
DirectTransferSessionId sessionId = ObjectHandleCodec.directUpload(route, objectId);
|
||||
String key = ObjectControlKeyCodec.directSession(route, sessionId);
|
||||
ObjectDirectSessionRecord current = reserveUpload(key, sessionId, objectId, request, now);
|
||||
requireSameUpload(current.session(), request);
|
||||
if (current.session().state() == DirectGrantSessionState.SESSION_RESERVED) {
|
||||
DirectTransferSessionRecord prepared =
|
||||
current.session().prepare(planned, constraints, "upload-unpublished");
|
||||
current = replace(key, current, prepared);
|
||||
}
|
||||
if (current.session().state() == DirectGrantSessionState.GRANT_ISSUED) {
|
||||
DirectGrantProvider.DirectGrantMaterial cached = issued.get(sessionId.canonicalText());
|
||||
if (cached == null) {
|
||||
throw new IllegalStateException(
|
||||
"issued direct grant bearer material is unavailable after process restart");
|
||||
}
|
||||
return uploadGrant(current.session(), cached);
|
||||
}
|
||||
if (current.session().state() != DirectGrantSessionState.GRANT_PREPARED) {
|
||||
throw new IllegalStateException("direct upload grant admission is closed");
|
||||
}
|
||||
DirectGrantProvider.DirectGrantMaterial material = provider.signUpload(current.session());
|
||||
policy.validateSignedGrant(material.requestUri(), current.session().expiresAt());
|
||||
ObjectDirectSessionRecord issuedRecord =
|
||||
replace(key, current, current.session().issue(clock.instant()));
|
||||
issued.put(sessionId.canonicalText(), material);
|
||||
return uploadGrant(issuedRecord.session(), material);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DirectUploadCompletionReceipt completeUpload(DirectUploadCompletionRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
String key = ObjectControlKeyCodec.directSession(route, request.sessionId());
|
||||
ObjectControlStore.StoredControlRecord stored =
|
||||
store.read(key).orElseThrow(() -> new IllegalArgumentException("direct session is absent"));
|
||||
ObjectDirectSessionRecord current = direct(stored);
|
||||
requireSameOperation(current.session(), request.operationKey());
|
||||
if (!current.session().expectedContent().equals(request.expectedContent())) {
|
||||
throw new IllegalArgumentException("direct upload completion identity conflicts");
|
||||
}
|
||||
if (current.session().state() == DirectGrantSessionState.DATA_UPLOADED) {
|
||||
return completion(current.session(), ObjectMutationOutcome.REPLAYED);
|
||||
}
|
||||
if (current.session().state() != DirectGrantSessionState.GRANT_ISSUED) {
|
||||
throw new IllegalStateException("direct upload is not ready for verification");
|
||||
}
|
||||
DirectGrantProvider.VerifiedUpload verified = provider.verifyUpload(current.session());
|
||||
if (!verified.contentIdentity().equals(request.expectedContent())) {
|
||||
throw new IllegalStateException("direct upload provider evidence does not match");
|
||||
}
|
||||
DirectTransferSessionRecord uploaded =
|
||||
current.session().uploaded(verified.exactVersion(), clock.instant());
|
||||
replace(key, stored, current.replace(uploaded));
|
||||
issued.remove(request.sessionId().canonicalText());
|
||||
return completion(uploaded, ObjectMutationOutcome.APPLIED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DirectDownloadGrant createDownloadGrant(DirectDownloadGrantRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
DirectGrantProvider.PublishedObject published = provider.resolvePublished(request);
|
||||
if (!published.active()
|
||||
|| (request.expectedVersion().isPresent()
|
||||
&& !request.expectedVersion().orElseThrow().equals(published.exactVersion()))) {
|
||||
throw new IllegalStateException("direct download requires an active exact publication");
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
DirectGrantGeneration planned =
|
||||
policy.planGrant(
|
||||
policy.planningEndpoint(),
|
||||
request.requestedTtl(),
|
||||
published.contentIdentity().exactLength(),
|
||||
now);
|
||||
String constraints = constraints(request, published);
|
||||
ObjectId objectId = objectId(request.operationKey().toString() + constraints);
|
||||
DirectTransferSessionId sessionId = ObjectHandleCodec.directUpload(route, objectId);
|
||||
String key = ObjectControlKeyCodec.directSession(route, sessionId);
|
||||
DirectTransferSessionRecord reserved =
|
||||
new DirectTransferSessionRecord(
|
||||
request.operationKey(),
|
||||
sessionId,
|
||||
DirectGrantSessionState.SESSION_RESERVED,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
published.canonicalDataKey(),
|
||||
published.contentIdentity(),
|
||||
published.mediaType(),
|
||||
published.exactVersion(),
|
||||
now)
|
||||
.prepare(planned, constraints, published.referenceRevision());
|
||||
ObjectDirectSessionRecord prepared =
|
||||
new ObjectDirectSessionRecord(1, sessionId.canonicalText(), 1, reserved);
|
||||
ObjectControlStore.StoredControlRecord stored;
|
||||
try {
|
||||
stored = store.create(key, prepared);
|
||||
} catch (ObjectControlConflictException conflict) {
|
||||
stored =
|
||||
store
|
||||
.read(key)
|
||||
.orElseThrow(() -> new IllegalStateException("direct download conflict vanished"));
|
||||
prepared = direct(stored);
|
||||
}
|
||||
DirectGrantProvider.DirectGrantMaterial material =
|
||||
provider.signDownload(prepared.session(), published);
|
||||
DirectGrantProvider.PublishedObject fence = provider.resolvePublished(request);
|
||||
if (!fence.active()
|
||||
|| !fence.referenceRevision().equals(prepared.session().referenceRevision())) {
|
||||
throw new IllegalStateException("publication retired while issuing direct download");
|
||||
}
|
||||
ObjectDirectSessionRecord issuedRecord =
|
||||
replace(key, stored, prepared.replace(prepared.session().issue(clock.instant())));
|
||||
issued.put(sessionId.canonicalText(), material);
|
||||
return new DirectDownloadGrant(
|
||||
sessionId,
|
||||
material.requestUri(),
|
||||
material.signedHeaders(),
|
||||
issuedRecord.session().expiresAt());
|
||||
}
|
||||
|
||||
private ObjectDirectSessionRecord reserveUpload(
|
||||
String key,
|
||||
DirectTransferSessionId sessionId,
|
||||
ObjectId objectId,
|
||||
DirectUploadGrantRequest request,
|
||||
Instant now) {
|
||||
DirectTransferSessionRecord reserved =
|
||||
DirectTransferSessionRecord.reserved(
|
||||
request.operationKey(),
|
||||
sessionId,
|
||||
ObjectDataKeyCodec.encode(route, objectId, ObjectDataKeyCodec.Generation.of(1)),
|
||||
request.contentIdentity(),
|
||||
request.declaredMediaType(),
|
||||
now);
|
||||
ObjectDirectSessionRecord record =
|
||||
new ObjectDirectSessionRecord(1, sessionId.canonicalText(), 1, reserved);
|
||||
try {
|
||||
return direct(store.create(key, record));
|
||||
} catch (ObjectControlConflictException conflict) {
|
||||
return direct(
|
||||
store
|
||||
.read(key)
|
||||
.orElseThrow(() -> new IllegalStateException("direct upload conflict vanished")));
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectDirectSessionRecord replace(
|
||||
String key, ObjectDirectSessionRecord current, DirectTransferSessionRecord replacement) {
|
||||
ObjectControlStore.StoredControlRecord stored =
|
||||
store.read(key).orElseThrow(() -> new IllegalStateException("direct session disappeared"));
|
||||
return replace(key, stored, current.replace(replacement));
|
||||
}
|
||||
|
||||
private ObjectDirectSessionRecord replace(
|
||||
String key,
|
||||
ObjectControlStore.StoredControlRecord current,
|
||||
ObjectDirectSessionRecord replacement) {
|
||||
return direct(
|
||||
store.compareAndSet(key, new ObjectControlMutation(current.version(), replacement)));
|
||||
}
|
||||
|
||||
private static ObjectDirectSessionRecord direct(ObjectControlStore.StoredControlRecord stored) {
|
||||
if (!(stored.record() instanceof ObjectDirectSessionRecord direct)) {
|
||||
throw new IllegalStateException("direct session control family is corrupt");
|
||||
}
|
||||
return direct;
|
||||
}
|
||||
|
||||
private static DirectUploadGrant uploadGrant(
|
||||
DirectTransferSessionRecord session, DirectGrantProvider.DirectGrantMaterial material) {
|
||||
return new DirectUploadGrant(
|
||||
session.sessionId(), material.requestUri(), material.signedHeaders(), session.expiresAt());
|
||||
}
|
||||
|
||||
private DirectUploadCompletionReceipt completion(
|
||||
DirectTransferSessionRecord session, ObjectMutationOutcome outcome) {
|
||||
ObjectId objectId = objectIdFromDataKey(session.canonicalDataKey());
|
||||
return new DirectUploadCompletionReceipt(
|
||||
session.operationKey(),
|
||||
session.sessionId(),
|
||||
ObjectHandleCodec.stage(route, objectId),
|
||||
Objects.requireNonNull(session.exactVersion()),
|
||||
session.expectedContent(),
|
||||
outcome);
|
||||
}
|
||||
|
||||
private static void requireSameUpload(
|
||||
DirectTransferSessionRecord session, DirectUploadGrantRequest request) {
|
||||
requireSameOperation(session, request.operationKey());
|
||||
if (!session.expectedContent().equals(request.contentIdentity())
|
||||
|| !session.mediaType().equals(request.declaredMediaType())) {
|
||||
throw new IllegalArgumentException("direct upload operation conflicts with reserved intent");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameOperation(
|
||||
DirectTransferSessionRecord session,
|
||||
dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operationKey) {
|
||||
if (!session.operationKey().equals(operationKey)) {
|
||||
throw new IllegalArgumentException("direct operation identity conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
private static String constraints(DirectUploadGrantRequest request) {
|
||||
return sha256(
|
||||
request.operationKey()
|
||||
+ "\n"
|
||||
+ request.declaredMediaType().canonicalText()
|
||||
+ "\n"
|
||||
+ request.contentIdentity().exactLength()
|
||||
+ "\n"
|
||||
+ request.contentIdentity().fullDigest().base64Value()
|
||||
+ "\n"
|
||||
+ request.publicationRequirement()
|
||||
+ "\n"
|
||||
+ request.requestedTtl());
|
||||
}
|
||||
|
||||
private static String constraints(
|
||||
DirectDownloadGrantRequest request, DirectGrantProvider.PublishedObject published) {
|
||||
return sha256(
|
||||
request.operationKey()
|
||||
+ "\n"
|
||||
+ request.reference().canonicalText()
|
||||
+ "\n"
|
||||
+ published.exactVersion().canonicalText()
|
||||
+ "\n"
|
||||
+ published.referenceRevision()
|
||||
+ "\n"
|
||||
+ request.requestedTtl());
|
||||
}
|
||||
|
||||
private static ObjectId objectId(String seed) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8));
|
||||
return ObjectId.parse(CrockfordBase32.encode(Arrays.copyOf(digest, 16), 26));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectId objectIdFromDataKey(String key) {
|
||||
String[] segments = key.split("/", -1);
|
||||
if (segments.length != 6) {
|
||||
throw new IllegalStateException("direct session data key is corrupt");
|
||||
}
|
||||
return ObjectId.parse(segments[4]);
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return java.util.HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Browser-visible CORS contract that infrastructure must apply before direct admission. */
|
||||
public final class DirectTransferCorsPolicy {
|
||||
|
||||
private DirectTransferCorsPolicy() {}
|
||||
|
||||
public static Set<String> allowedMethods() {
|
||||
return Set.of("GET", "HEAD", "PUT");
|
||||
}
|
||||
|
||||
public static Set<String> allowedRequestHeaders() {
|
||||
return Set.of(
|
||||
"content-length",
|
||||
"content-type",
|
||||
"if-none-match",
|
||||
"x-amz-checksum-sha256",
|
||||
"x-amz-server-side-encryption");
|
||||
}
|
||||
|
||||
public static Set<String> exposedResponseHeaders() {
|
||||
return Set.of("etag", "x-amz-checksum-sha256", "x-amz-version-id");
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fail-closed admission policy for short-lived direct-transfer bearer grants. */
|
||||
public record DirectTransferPolicy(
|
||||
UploadProfile uploadProfile,
|
||||
Set<String> allowedHosts,
|
||||
Duration maximumGrantTtl,
|
||||
Duration signatureAgeCeiling,
|
||||
Duration qualifiedMaximumClockSkew,
|
||||
Instant credentialHorizon,
|
||||
String credentialRevision,
|
||||
int maximumOutstandingGenerations,
|
||||
long maximumExposureBytes,
|
||||
boolean providerHardCeilingEnforced,
|
||||
ClockHealth clockHealth) {
|
||||
|
||||
public DirectTransferPolicy {
|
||||
Objects.requireNonNull(uploadProfile, "uploadProfile must be non-null");
|
||||
allowedHosts =
|
||||
Set.copyOf(Objects.requireNonNull(allowedHosts, "allowedHosts must be non-null"));
|
||||
if (allowedHosts.isEmpty()
|
||||
|| allowedHosts.stream()
|
||||
.anyMatch(
|
||||
host ->
|
||||
host == null
|
||||
|| host.isBlank()
|
||||
|| host.length() > 253
|
||||
|| !host.matches("[a-zA-Z0-9.-]+"))) {
|
||||
throw new IllegalArgumentException("direct-transfer host allowlist is invalid");
|
||||
}
|
||||
requirePositive("maximumGrantTtl", maximumGrantTtl);
|
||||
requirePositive("signatureAgeCeiling", signatureAgeCeiling);
|
||||
requirePositive("qualifiedMaximumClockSkew", qualifiedMaximumClockSkew);
|
||||
Objects.requireNonNull(credentialHorizon, "credentialHorizon must be non-null");
|
||||
if (credentialRevision == null
|
||||
|| credentialRevision.isBlank()
|
||||
|| credentialRevision.length() > 128) {
|
||||
throw new IllegalArgumentException("credential revision is invalid");
|
||||
}
|
||||
if (maximumOutstandingGenerations < 1 || maximumOutstandingGenerations > 16) {
|
||||
throw new IllegalArgumentException("outstanding direct generation bound is invalid");
|
||||
}
|
||||
if (maximumExposureBytes < 1) {
|
||||
throw new IllegalArgumentException("direct maximum exposure must be positive");
|
||||
}
|
||||
Objects.requireNonNull(clockHealth, "clockHealth must be non-null");
|
||||
}
|
||||
|
||||
public DirectGrantGeneration planGrant(
|
||||
URI configuredEndpoint, Duration requestedTtl, long exactBytes, Instant now) {
|
||||
validateEndpoint(configuredEndpoint);
|
||||
Objects.requireNonNull(requestedTtl, "requestedTtl must be non-null");
|
||||
Objects.requireNonNull(now, "now must be non-null");
|
||||
if (!clockHealth.qualifiedAt(now)) {
|
||||
throw new IllegalStateException("qualified clock health is unavailable");
|
||||
}
|
||||
if (requestedTtl.isZero()
|
||||
|| requestedTtl.isNegative()
|
||||
|| requestedTtl.compareTo(maximumGrantTtl) > 0
|
||||
|| requestedTtl.compareTo(signatureAgeCeiling) > 0) {
|
||||
throw new IllegalArgumentException("direct grant TTL exceeds its qualified bound");
|
||||
}
|
||||
if (exactBytes < 1 || exactBytes > maximumExposureBytes) {
|
||||
throw new IllegalArgumentException("direct transfer exceeds its exposure bound");
|
||||
}
|
||||
if (uploadProfile == UploadProfile.DIRECT_SINGLE_HARD_CEILING && !providerHardCeilingEnforced) {
|
||||
throw new IllegalArgumentException("provider-enforced direct upload ceiling is unavailable");
|
||||
}
|
||||
Instant expiresAt = now.plus(requestedTtl);
|
||||
Instant latestAllowed = credentialHorizon.minus(qualifiedMaximumClockSkew);
|
||||
if (!expiresAt.isBefore(latestAllowed)) {
|
||||
throw new IllegalArgumentException("direct grant exceeds the credential session horizon");
|
||||
}
|
||||
return new DirectGrantGeneration(
|
||||
1, null, now, expiresAt, credentialRevision, "unbound-reference");
|
||||
}
|
||||
|
||||
public void validateSignedGrant(URI uri, Instant expectedExpiry) {
|
||||
validateEndpoint(uri);
|
||||
Objects.requireNonNull(expectedExpiry, "expectedExpiry must be non-null");
|
||||
}
|
||||
|
||||
public URI planningEndpoint() {
|
||||
return URI.create("https://" + allowedHosts.stream().sorted().findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
private void validateEndpoint(URI endpoint) {
|
||||
if (endpoint == null
|
||||
|| !"https".equalsIgnoreCase(endpoint.getScheme())
|
||||
|| endpoint.getHost() == null
|
||||
|| endpoint.getUserInfo() != null
|
||||
|| endpoint.getFragment() != null
|
||||
|| !allowedHosts.contains(endpoint.getHost())) {
|
||||
throw new IllegalArgumentException("direct-transfer endpoint is not qualified");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePositive(String label, Duration value) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalArgumentException(label + " must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public enum UploadProfile {
|
||||
DIRECT_SINGLE_HARD_CEILING,
|
||||
DIRECT_SINGLE_SOFT_LIMIT_R1
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ClockHealth {
|
||||
|
||||
boolean qualifiedAt(Instant instant);
|
||||
}
|
||||
}
|
||||
+166
@@ -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
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.direct;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Central redaction helper for presigned bearer material. */
|
||||
public final class PresignedGrantRedactor {
|
||||
|
||||
private PresignedGrantRedactor() {}
|
||||
|
||||
public static String redact(URI ignored) {
|
||||
Objects.requireNonNull(ignored, "presigned URI must be non-null");
|
||||
return "[REDACTED_PRESIGNED_URI]";
|
||||
}
|
||||
|
||||
public static String describeHeaders(Map<String, String> headers) {
|
||||
Objects.requireNonNull(headers, "signed headers must be non-null");
|
||||
return headers.keySet().stream().sorted().collect(Collectors.joining(",", "[", "]"));
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.config.CompiledObjectStorageProvider;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageProviderContribution;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.config.ObjectStorageProviderSettings;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.config.SelectedObjectStorageProviderFactory;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.CanonicalJsonObjectControlRecordCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
|
||||
import java.util.EnumMap;
|
||||
|
||||
/** Side-effect-free local descriptor; filesystem access occurs only in {@link #create}. */
|
||||
public final class FilesystemLocalDevProviderContribution
|
||||
implements ObjectStorageProviderContribution {
|
||||
|
||||
@Override
|
||||
public String providerType() {
|
||||
return "filesystem-local-dev";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectStorageProviderDescriptor describe(ObjectStorageProviderSettings settings) {
|
||||
if (settings.root() == null
|
||||
|| settings.maximumObjectBytes() == null
|
||||
|| settings.chunkBytes() == null) {
|
||||
throw new IllegalArgumentException("filesystem-local-dev settings are incomplete");
|
||||
}
|
||||
String actualVersion = "jdk-" + Runtime.version().feature();
|
||||
if (!actualVersion.equals(settings.version())) {
|
||||
throw new IllegalArgumentException("filesystem-local-dev version does not match runtime");
|
||||
}
|
||||
long maximumBytes = settings.maximumObjectBytes().toBytes();
|
||||
long rawChunkBytes = settings.chunkBytes().toBytes();
|
||||
if (rawChunkBytes > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("filesystem-local-dev chunk bound is invalid");
|
||||
}
|
||||
return new ObjectStorageProviderDescriptor(
|
||||
providerType(), actualVersion, maximumBytes, (int) rawChunkBytes, capabilitySupport());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SelectedObjectStorageProviderFactory create(CompiledObjectStorageProvider provider) {
|
||||
if (provider.localRoot() == null) {
|
||||
throw new IllegalArgumentException("filesystem-local-dev root is required");
|
||||
}
|
||||
LocalObjectPathGuard pathGuard = new LocalObjectPathGuard(provider.localRoot());
|
||||
LocalDevObjectStorageProvider dataProvider =
|
||||
new LocalDevObjectStorageProvider(
|
||||
provider.localRoot(),
|
||||
provider.maximumObjectBytes(),
|
||||
provider.chunkBytes(),
|
||||
LocalObjectStreamTransfer.FaultInjector.none());
|
||||
LocalDevObjectControlStore controlStore =
|
||||
new LocalDevObjectControlStore(pathGuard, new CanonicalJsonObjectControlRecordCodec());
|
||||
return new SelectedObjectStorageProviderFactory(dataProvider, controlStore, () -> {});
|
||||
}
|
||||
|
||||
private static EnumMap<ObjectStorageProvider.Capability, ObjectStorageProvider.Support>
|
||||
capabilitySupport() {
|
||||
EnumMap<ObjectStorageProvider.Capability, ObjectStorageProvider.Support> support =
|
||||
new EnumMap<>(ObjectStorageProvider.Capability.class);
|
||||
for (ObjectStorageProvider.Capability capability : ObjectStorageProvider.Capability.values()) {
|
||||
support.put(capability, ObjectStorageProvider.Support.UNSUPPORTED);
|
||||
}
|
||||
support.put(
|
||||
ObjectStorageProvider.Capability.IMMUTABLE_CREATE, ObjectStorageProvider.Support.SUPPORTED);
|
||||
support.put(
|
||||
ObjectStorageProvider.Capability.EXACT_INSPECT, ObjectStorageProvider.Support.SUPPORTED);
|
||||
support.put(
|
||||
ObjectStorageProvider.Capability.FULL_READ, ObjectStorageProvider.Support.SUPPORTED);
|
||||
support.put(
|
||||
ObjectStorageProvider.Capability.RANGE_READ, ObjectStorageProvider.Support.SUPPORTED);
|
||||
support.put(
|
||||
ObjectStorageProvider.Capability.CONDITIONAL_RETIREMENT,
|
||||
ObjectStorageProvider.Support.SUPPORTED);
|
||||
support.put(
|
||||
ObjectStorageProvider.Capability.RESPONSE_LOSS_RESOLUTION,
|
||||
ObjectStorageProvider.Support.SUPPORTED);
|
||||
return support;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.CanonicalJsonObjectControlRecordCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlConflictException;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlMutation;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlRecord;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlStore;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.control.ObjectControlVersion;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Single-process local create/CAS store; it deliberately does not claim multi-node linearizability.
|
||||
*/
|
||||
public final class LocalDevObjectControlStore implements ObjectControlStore {
|
||||
|
||||
private final LocalObjectPathGuard pathGuard;
|
||||
private final CanonicalJsonObjectControlRecordCodec codec;
|
||||
private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
|
||||
|
||||
public LocalDevObjectControlStore(
|
||||
LocalObjectPathGuard pathGuard, CanonicalJsonObjectControlRecordCodec codec) {
|
||||
this.pathGuard = java.util.Objects.requireNonNull(pathGuard, "pathGuard must be non-null");
|
||||
this.codec = java.util.Objects.requireNonNull(codec, "codec must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<StoredControlRecord> read(String key) {
|
||||
Path target = pathGuard.resolveControl(key, false);
|
||||
if (!Files.isRegularFile(target, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
byte[] encoded = Files.readAllBytes(target);
|
||||
ObjectControlRecord record = codec.decode(encoded);
|
||||
return Optional.of(
|
||||
new StoredControlRecord(ObjectControlVersion.of(codec.sha256Hex(encoded)), record));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("local control read failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoredControlRecord create(String key, ObjectControlRecord record) {
|
||||
Path target = pathGuard.resolveControl(key, true);
|
||||
byte[] encoded = codec.encode(record);
|
||||
try (FileChannel channel =
|
||||
FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
writeFully(channel, encoded);
|
||||
channel.force(true);
|
||||
LocalObjectPathGuard.applyFilePermissions(target, false);
|
||||
return new StoredControlRecord(ObjectControlVersion.of(codec.sha256Hex(encoded)), record);
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
throw new ObjectControlConflictException("local control record already exists");
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("local control create failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoredControlRecord compareAndSet(String key, ObjectControlMutation mutation) {
|
||||
Object lock = locks.computeIfAbsent(key, ignored -> new Object());
|
||||
synchronized (lock) {
|
||||
StoredControlRecord current =
|
||||
read(key)
|
||||
.orElseThrow(() -> new ObjectControlConflictException("control record is absent"));
|
||||
if (!current.version().equals(mutation.expectedVersion())) {
|
||||
throw new ObjectControlConflictException("local control version conflict");
|
||||
}
|
||||
byte[] replacement = codec.encode(mutation.replacement());
|
||||
Path target = pathGuard.resolveControl(key, false);
|
||||
Path temporary = null;
|
||||
try {
|
||||
temporary = pathGuard.createTemporaryFile();
|
||||
try (FileChannel channel =
|
||||
FileChannel.open(
|
||||
temporary, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
|
||||
writeFully(channel, replacement);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(
|
||||
temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
LocalObjectPathGuard.applyFilePermissions(target, false);
|
||||
return new StoredControlRecord(
|
||||
ObjectControlVersion.of(codec.sha256Hex(replacement)), mutation.replacement());
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("local control CAS failed", exception);
|
||||
} finally {
|
||||
if (temporary != null) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException ignored) {
|
||||
// Report-first local reconciliation owns any bounded .tmp orphan.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeFully(FileChannel channel, byte[] bytes) throws IOException {
|
||||
java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) {
|
||||
if (channel.write(buffer) <= 0) {
|
||||
throw new IOException("local control write made no progress");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.codec.ObjectHandleCodec;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.kernel.ObjectRouteToken;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
|
||||
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
|
||||
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectId;
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Immutable local data objects using staged writes and exclusive hard-link publication. */
|
||||
public final class LocalDevObjectDataStore {
|
||||
|
||||
private final LocalObjectPathGuard pathGuard;
|
||||
private final LocalObjectStreamTransfer streamTransfer;
|
||||
private final long maximumObjectBytes;
|
||||
|
||||
public LocalDevObjectDataStore(
|
||||
LocalObjectPathGuard pathGuard,
|
||||
LocalObjectStreamTransfer streamTransfer,
|
||||
long maximumObjectBytes) {
|
||||
this.pathGuard = java.util.Objects.requireNonNull(pathGuard, "pathGuard must be non-null");
|
||||
this.streamTransfer =
|
||||
java.util.Objects.requireNonNull(streamTransfer, "streamTransfer must be non-null");
|
||||
if (maximumObjectBytes < 1) {
|
||||
throw new IllegalArgumentException("maximumObjectBytes must be positive");
|
||||
}
|
||||
this.maximumObjectBytes = maximumObjectBytes;
|
||||
}
|
||||
|
||||
public ObjectStorageProvider.StoredData create(
|
||||
ObjectStorageProviderOperation operation, ObjectContentProducer producer) {
|
||||
Path target = pathGuard.resolveData(operation.canonicalDataKey(), true);
|
||||
Path temporary = null;
|
||||
try {
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw conflict();
|
||||
}
|
||||
temporary = pathGuard.createTemporaryFile();
|
||||
ObjectContentIdentity identity = streamTransfer.write(temporary, operation, producer);
|
||||
try {
|
||||
Files.createLink(target, temporary);
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
throw conflict();
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.UNSUPPORTED,
|
||||
"local filesystem cannot prove immutable create",
|
||||
exception);
|
||||
}
|
||||
LocalObjectPathGuard.applyFilePermissions(target, false);
|
||||
return stored(operation.canonicalDataKey(), identity);
|
||||
} catch (ObjectStorageProviderException exception) {
|
||||
throw exception;
|
||||
} catch (IOException exception) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.IO_FAILURE,
|
||||
"local immutable create failed",
|
||||
exception);
|
||||
} finally {
|
||||
if (temporary != null) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException ignored) {
|
||||
// A bounded local-dev orphan remains under .tmp for report-first startup reconciliation.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<ObjectStorageProvider.StoredData> inspect(String canonicalDataKey) {
|
||||
Path source = pathGuard.resolveData(canonicalDataKey, false);
|
||||
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = sha256();
|
||||
long size = 0;
|
||||
ByteBuffer buffer = ByteBuffer.allocate(64 * 1024);
|
||||
try (FileChannel channel = FileChannel.open(source, StandardOpenOption.READ)) {
|
||||
for (int count = channel.read(buffer); count >= 0; count = channel.read(buffer)) {
|
||||
if (count == 0) {
|
||||
continue;
|
||||
}
|
||||
size = Math.addExact(size, count);
|
||||
if (size > maximumObjectBytes) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.CORRUPT_DATA,
|
||||
"local object exceeds its configured bound");
|
||||
}
|
||||
digest.update(buffer.array(), 0, count);
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
ObjectContentIdentity identity =
|
||||
new ObjectContentIdentity(
|
||||
size,
|
||||
ObjectDigest.of(
|
||||
dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm.SHA_256,
|
||||
Base64.getEncoder().encodeToString(digest.digest())));
|
||||
return Optional.of(stored(canonicalDataKey, identity));
|
||||
} catch (ObjectStorageProviderException exception) {
|
||||
throw exception;
|
||||
} catch (IOException | ArithmeticException exception) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.IO_FAILURE,
|
||||
"local exact inspect failed",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void transfer(
|
||||
ObjectStorageProvider.ReadOperation operation, ObjectContentConsumer consumer) {
|
||||
ObjectStorageProvider.StoredData actual =
|
||||
inspect(operation.canonicalDataKey())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.NOT_FOUND,
|
||||
"local object is absent"));
|
||||
if (!actual.exactVersion().equals(operation.exactVersion())
|
||||
|| !actual.contentIdentity().equals(operation.contentIdentity())) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.PRECONDITION_FAILED,
|
||||
"local read precondition failed");
|
||||
}
|
||||
Path source = pathGuard.resolveData(operation.canonicalDataKey(), false);
|
||||
streamTransfer.read(source, operation, consumer);
|
||||
}
|
||||
|
||||
public boolean retire(ObjectStorageProvider.RetireOperation operation) {
|
||||
ObjectVersionToken actualVersion = version(operation.canonicalDataKey());
|
||||
if (!actualVersion.equals(operation.exactVersion())) {
|
||||
return false;
|
||||
}
|
||||
Path target = pathGuard.resolveData(operation.canonicalDataKey(), false);
|
||||
try {
|
||||
return Files.deleteIfExists(target);
|
||||
} catch (IOException exception) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.IO_FAILURE,
|
||||
"local conditional retirement failed",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectStorageProvider.StoredData stored(
|
||||
String canonicalDataKey, ObjectContentIdentity identity) {
|
||||
return new ObjectStorageProvider.StoredData(
|
||||
canonicalDataKey, identity, version(canonicalDataKey));
|
||||
}
|
||||
|
||||
private static ObjectVersionToken version(String canonicalDataKey) {
|
||||
ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey);
|
||||
String[] segments = canonicalDataKey.split("/", -1);
|
||||
return ObjectHandleCodec.version(
|
||||
ObjectRouteToken.parse(segments[2]), ObjectId.parse(segments[4]));
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 must be available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectStorageProviderException conflict() {
|
||||
return new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.CONFLICT, "local immutable object already exists");
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProvider;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderDescriptor;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderException;
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
|
||||
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
|
||||
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Bounded single-process filesystem provider for explicit local-development profiles only. */
|
||||
public final class LocalDevObjectStorageProvider implements ObjectStorageProvider {
|
||||
|
||||
private final ObjectStorageProviderDescriptor descriptor;
|
||||
private final LocalDevObjectDataStore dataStore;
|
||||
|
||||
public LocalDevObjectStorageProvider(
|
||||
Path root,
|
||||
long maximumObjectBytes,
|
||||
int maximumChunkBytes,
|
||||
LocalObjectStreamTransfer.FaultInjector faultInjector) {
|
||||
LocalObjectPathGuard pathGuard = new LocalObjectPathGuard(root);
|
||||
dataStore =
|
||||
new LocalDevObjectDataStore(
|
||||
pathGuard, new LocalObjectStreamTransfer(faultInjector), maximumObjectBytes);
|
||||
descriptor =
|
||||
new ObjectStorageProviderDescriptor(
|
||||
"filesystem-local-dev",
|
||||
"jdk-" + Runtime.version().feature(),
|
||||
maximumObjectBytes,
|
||||
maximumChunkBytes,
|
||||
capabilitySupport());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectStorageProviderDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoredData create(
|
||||
ObjectStorageProviderOperation operation, ObjectContentProducer producer) {
|
||||
requireWithinDescriptor(operation);
|
||||
return dataStore.create(operation, producer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<StoredData> inspect(String canonicalDataKey) {
|
||||
return dataStore.inspect(canonicalDataKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void transfer(ReadOperation operation, ObjectContentConsumer consumer) {
|
||||
if (operation.maximumChunkBytes() > descriptor.maximumChunkBytes()
|
||||
|| operation.maximumDeliveredBytes() > descriptor.maximumObjectBytes()) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.UNSUPPORTED, "read exceeds local provider bounds");
|
||||
}
|
||||
dataStore.transfer(operation, consumer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retire(RetireOperation operation) {
|
||||
return dataStore.retire(operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resolution resolveCreate(ObjectStorageProviderOperation operation) {
|
||||
try {
|
||||
Optional<StoredData> existing = inspect(operation.canonicalDataKey());
|
||||
if (existing.isEmpty()) {
|
||||
return Resolution.NOT_APPLIED;
|
||||
}
|
||||
return existing.get().contentIdentity().equals(operation.expectedContentIdentity())
|
||||
? Resolution.APPLIED
|
||||
: Resolution.PRECONDITION_FAILED;
|
||||
} catch (ObjectStorageProviderException exception) {
|
||||
return Resolution.INDETERMINATE;
|
||||
}
|
||||
}
|
||||
|
||||
private void requireWithinDescriptor(ObjectStorageProviderOperation operation) {
|
||||
if (operation.maximumObjectBytes() > descriptor.maximumObjectBytes()
|
||||
|| operation.maximumChunkBytes() > descriptor.maximumChunkBytes()) {
|
||||
throw new ObjectStorageProviderException(
|
||||
ObjectStorageProviderException.Failure.UNSUPPORTED,
|
||||
"operation exceeds local provider bounds");
|
||||
}
|
||||
}
|
||||
|
||||
private static EnumMap<Capability, Support> capabilitySupport() {
|
||||
EnumMap<Capability, Support> support = new EnumMap<>(Capability.class);
|
||||
for (Capability capability : Capability.values()) {
|
||||
support.put(capability, Support.UNSUPPORTED);
|
||||
}
|
||||
support.put(Capability.IMMUTABLE_CREATE, Support.SUPPORTED);
|
||||
support.put(Capability.EXACT_INSPECT, Support.SUPPORTED);
|
||||
support.put(Capability.FULL_READ, Support.SUPPORTED);
|
||||
support.put(Capability.RANGE_READ, Support.SUPPORTED);
|
||||
support.put(Capability.CONDITIONAL_RETIREMENT, Support.SUPPORTED);
|
||||
support.put(Capability.RESPONSE_LOSS_RESOLUTION, Support.SUPPORTED);
|
||||
return support;
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.filesystem;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.objectstorage.provider.ObjectStorageProviderOperation;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Strict root confinement and symlink rejection for the local-development provider. */
|
||||
public final class LocalObjectPathGuard {
|
||||
|
||||
private static final int MAXIMUM_CONTROL_KEY_LENGTH = 512;
|
||||
|
||||
private final Path root;
|
||||
|
||||
public LocalObjectPathGuard(Path configuredRoot) {
|
||||
Objects.requireNonNull(configuredRoot, "configuredRoot must be non-null");
|
||||
root = configuredRoot.toAbsolutePath().normalize();
|
||||
try {
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IllegalArgumentException("local object-storage root cannot be a symbolic link");
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalArgumentException("local object-storage root must be a directory");
|
||||
}
|
||||
applyDirectoryPermissions(root);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("local object-storage root is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Path resolveData(String canonicalDataKey, boolean createParents) {
|
||||
ObjectStorageProviderOperation.requireCanonicalDataKey(canonicalDataKey);
|
||||
return resolve(canonicalDataKey, createParents);
|
||||
}
|
||||
|
||||
public Path resolveControl(String canonicalControlKey, boolean createParents) {
|
||||
requireCanonicalControlKey(canonicalControlKey);
|
||||
/*
|
||||
* Object stores permit both an object named "reference" and another named
|
||||
* "reference/lifecycle". A filesystem does not. Keep the provider-neutral logical key exact
|
||||
* while giving every local control record a leaf-only physical filename.
|
||||
*/
|
||||
return resolve(canonicalControlKey + ".record", createParents);
|
||||
}
|
||||
|
||||
public Path createTemporaryFile() throws IOException {
|
||||
Path temporaryRoot = resolve(".tmp/object", true).getParent();
|
||||
Path temporary = Files.createTempFile(temporaryRoot, "pending-", ".tmp");
|
||||
applyFilePermissions(temporary, true);
|
||||
return temporary;
|
||||
}
|
||||
|
||||
public Path root() {
|
||||
return root;
|
||||
}
|
||||
|
||||
private Path resolve(String canonicalKey, boolean createParents) {
|
||||
Path target = root.resolve(canonicalKey).normalize();
|
||||
if (!target.startsWith(root) || target.equals(root)) {
|
||||
throw new IllegalArgumentException("local object key escapes the configured root");
|
||||
}
|
||||
Path parent = target.getParent();
|
||||
try {
|
||||
if (createParents) {
|
||||
createParentsWithoutLinks(parent);
|
||||
}
|
||||
rejectExistingLinks(parent);
|
||||
if (Files.isSymbolicLink(target)) {
|
||||
throw new IllegalArgumentException("local object target cannot be a symbolic link");
|
||||
}
|
||||
return target;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("local object path cannot be resolved safely", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void createParentsWithoutLinks(Path parent) throws IOException {
|
||||
Path current = root;
|
||||
for (Path segment : root.relativize(parent)) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IllegalArgumentException("local object parent cannot be a symbolic link");
|
||||
}
|
||||
if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
try {
|
||||
Files.createDirectory(current);
|
||||
applyDirectoryPermissions(current);
|
||||
} catch (java.nio.file.FileAlreadyExistsException race) {
|
||||
if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.isSymbolicLink(current)) {
|
||||
throw new IllegalArgumentException("local object parent must be a directory", race);
|
||||
}
|
||||
}
|
||||
} else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalArgumentException("local object parent must be a directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void rejectExistingLinks(Path parent) throws IOException {
|
||||
Path current = root;
|
||||
for (Path segment : root.relativize(parent)) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IllegalArgumentException("local object parent cannot be a symbolic link");
|
||||
}
|
||||
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalArgumentException("local object parent must be a directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireCanonicalControlKey(String key) {
|
||||
if (key == null
|
||||
|| key.length() > MAXIMUM_CONTROL_KEY_LENGTH
|
||||
|| !key.startsWith("control/v1/")
|
||||
|| key.contains("//")
|
||||
|| key.contains("/./")
|
||||
|| key.contains("/../")
|
||||
|| key.startsWith("/")
|
||||
|| key.endsWith("/")
|
||||
|| !key.matches("[a-z0-9._/-]+")) {
|
||||
throw new IllegalArgumentException("control key is not canonical");
|
||||
}
|
||||
for (String segment : key.split("/", -1)) {
|
||||
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
|
||||
throw new IllegalArgumentException("control key is not canonical");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void applyFilePermissions(Path file, boolean writable) throws IOException {
|
||||
if (Files.getFileStore(file).supportsFileAttributeView("posix")) {
|
||||
EnumSet<PosixFilePermission> permissions = EnumSet.of(PosixFilePermission.OWNER_READ);
|
||||
if (writable) {
|
||||
permissions.add(PosixFilePermission.OWNER_WRITE);
|
||||
}
|
||||
Files.setPosixFilePermissions(file, permissions);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyDirectoryPermissions(Path directory) throws IOException {
|
||||
if (Files.getFileStore(directory).supportsFileAttributeView("posix")) {
|
||||
Files.setPosixFilePermissions(
|
||||
directory,
|
||||
EnumSet.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE));
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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) -> {};
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
|
||||
|
||||
import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch;
|
||||
import dev.caskeleton.application.objectstorage.model.ObjectOperationError;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Finite replay and compaction boundary that prevents expired operation identity reuse. */
|
||||
public record ObjectOperationEpochRecord(
|
||||
ObjectOperationEpoch epoch,
|
||||
ObjectOperationEpochState state,
|
||||
Instant createdAt,
|
||||
Duration maximumAge,
|
||||
long maximumOperations,
|
||||
long maximumControlBytes,
|
||||
int maximumReadableOldEpochs,
|
||||
Duration minimumReplayHorizon,
|
||||
Instant sealedAt,
|
||||
boolean rejectionRecord) {
|
||||
|
||||
public ObjectOperationEpochRecord {
|
||||
Objects.requireNonNull(epoch, "epoch must be non-null");
|
||||
Objects.requireNonNull(state, "state must be non-null");
|
||||
Objects.requireNonNull(createdAt, "createdAt must be non-null");
|
||||
requirePositive(maximumAge, "maximumAge");
|
||||
if (maximumOperations < 1 || maximumControlBytes < 1 || maximumReadableOldEpochs < 1) {
|
||||
throw new IllegalArgumentException("epoch bounds must be positive");
|
||||
}
|
||||
requirePositive(minimumReplayHorizon, "minimumReplayHorizon");
|
||||
if ((state == ObjectOperationEpochState.SEALED || state == ObjectOperationEpochState.COMPACTED)
|
||||
!= (sealedAt != null)) {
|
||||
throw new IllegalArgumentException("sealed state and sealedAt must agree");
|
||||
}
|
||||
if (state == ObjectOperationEpochState.COMPACTED && !rejectionRecord) {
|
||||
throw new IllegalArgumentException("compacted epoch requires a rejection record");
|
||||
}
|
||||
}
|
||||
|
||||
public static ObjectOperationEpochRecord warm(
|
||||
ObjectOperationEpoch epoch,
|
||||
Instant createdAt,
|
||||
Duration maximumAge,
|
||||
long maximumOperations,
|
||||
long maximumControlBytes,
|
||||
int maximumReadableOldEpochs,
|
||||
Duration minimumReplayHorizon,
|
||||
Set<ObjectOperationEpoch> previouslyIssued) {
|
||||
Objects.requireNonNull(previouslyIssued, "previouslyIssued must be non-null");
|
||||
if (previouslyIssued.contains(epoch)) {
|
||||
throw new IllegalArgumentException("operation epoch token was already issued");
|
||||
}
|
||||
return new ObjectOperationEpochRecord(
|
||||
epoch,
|
||||
ObjectOperationEpochState.WARM,
|
||||
createdAt,
|
||||
maximumAge,
|
||||
maximumOperations,
|
||||
maximumControlBytes,
|
||||
maximumReadableOldEpochs,
|
||||
minimumReplayHorizon,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
|
||||
public ObjectOperationEpochRecord activate() {
|
||||
return transition(
|
||||
ObjectOperationEpochState.WARM, ObjectOperationEpochState.ACTIVE, null, false);
|
||||
}
|
||||
|
||||
public ObjectOperationEpochRecord startDraining() {
|
||||
return transition(
|
||||
ObjectOperationEpochState.ACTIVE, ObjectOperationEpochState.DRAINING, null, false);
|
||||
}
|
||||
|
||||
public ObjectOperationEpochRecord seal(Instant sealedAt, boolean hasUnresolvedOperation) {
|
||||
if (hasUnresolvedOperation) {
|
||||
throw new IllegalStateException("unresolved operation prevents epoch sealing");
|
||||
}
|
||||
Objects.requireNonNull(sealedAt, "sealedAt must be non-null");
|
||||
if (sealedAt.isBefore(createdAt)) {
|
||||
throw new IllegalArgumentException("sealedAt precedes createdAt");
|
||||
}
|
||||
return transition(
|
||||
ObjectOperationEpochState.DRAINING, ObjectOperationEpochState.SEALED, sealedAt, true);
|
||||
}
|
||||
|
||||
public ObjectOperationEpochRecord compact(Instant now) {
|
||||
requireState(ObjectOperationEpochState.SEALED);
|
||||
Objects.requireNonNull(now, "now must be non-null");
|
||||
Instant earliestCompaction = sealedAt.plus(minimumReplayHorizon);
|
||||
if (now.isBefore(earliestCompaction)) {
|
||||
throw new IllegalStateException("minimum replay horizon has not elapsed");
|
||||
}
|
||||
return new ObjectOperationEpochRecord(
|
||||
epoch,
|
||||
ObjectOperationEpochState.COMPACTED,
|
||||
createdAt,
|
||||
maximumAge,
|
||||
maximumOperations,
|
||||
maximumControlBytes,
|
||||
maximumReadableOldEpochs,
|
||||
minimumReplayHorizon,
|
||||
sealedAt,
|
||||
true);
|
||||
}
|
||||
|
||||
public Optional<ObjectOperationError> admissionRejection() {
|
||||
if (state == ObjectOperationEpochState.SEALED || state == ObjectOperationEpochState.COMPACTED) {
|
||||
return Optional.of(ObjectOperationError.OPERATION_EXPIRED);
|
||||
}
|
||||
return state == ObjectOperationEpochState.ACTIVE
|
||||
? Optional.empty()
|
||||
: Optional.of(ObjectOperationError.OPERATION_EPOCH_NOT_ACTIVE);
|
||||
}
|
||||
|
||||
private ObjectOperationEpochRecord transition(
|
||||
ObjectOperationEpochState expected,
|
||||
ObjectOperationEpochState next,
|
||||
Instant nextSealedAt,
|
||||
boolean nextRejectionRecord) {
|
||||
requireState(expected);
|
||||
return new ObjectOperationEpochRecord(
|
||||
epoch,
|
||||
next,
|
||||
createdAt,
|
||||
maximumAge,
|
||||
maximumOperations,
|
||||
maximumControlBytes,
|
||||
maximumReadableOldEpochs,
|
||||
minimumReplayHorizon,
|
||||
nextSealedAt,
|
||||
nextRejectionRecord);
|
||||
}
|
||||
|
||||
private void requireState(ObjectOperationEpochState expected) {
|
||||
if (state != expected) {
|
||||
throw new IllegalStateException("operation epoch transition is not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePositive(Duration value, String label) {
|
||||
Objects.requireNonNull(value, label + " must be non-null");
|
||||
if (value.isNegative() || value.isZero()) {
|
||||
throw new IllegalArgumentException(label + " must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
+127
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage.kernel;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Closed transition tables shared by provider-neutral operation orchestration. */
|
||||
public final class ObjectOperationStateMachine {
|
||||
|
||||
private ObjectOperationStateMachine() {}
|
||||
|
||||
public static PublicationOperationState advancePublication(
|
||||
PublicationOperationState current, PublicationOperationState next) {
|
||||
if (current.terminal()) {
|
||||
throw invalid();
|
||||
}
|
||||
boolean allowed =
|
||||
switch (current) {
|
||||
case RESERVED -> next == PublicationOperationState.DATA_UPLOAD_IN_PROGRESS;
|
||||
case DATA_UPLOAD_IN_PROGRESS -> next == PublicationOperationState.DATA_UPLOADED;
|
||||
case DATA_UPLOADED -> next == PublicationOperationState.INTEGRITY_VERIFIED;
|
||||
case INTEGRITY_VERIFIED ->
|
||||
Set.of(
|
||||
PublicationOperationState.SCAN_PENDING,
|
||||
PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS)
|
||||
.contains(next);
|
||||
case SCAN_PENDING ->
|
||||
Set.of(PublicationOperationState.SCAN_CLEAN, PublicationOperationState.QUARANTINED)
|
||||
.contains(next);
|
||||
case SCAN_CLEAN -> next == PublicationOperationState.REFERENCE_PUBLISH_IN_PROGRESS;
|
||||
case REFERENCE_PUBLISH_IN_PROGRESS -> next == PublicationOperationState.PUBLISHED;
|
||||
default -> false;
|
||||
};
|
||||
return requireAllowed(next, allowed);
|
||||
}
|
||||
|
||||
public static ScanOperationState advanceScan(
|
||||
ScanOperationState current, ScanOperationState next) {
|
||||
boolean allowed =
|
||||
switch (current) {
|
||||
case NOT_REQUIRED -> false;
|
||||
case PENDING ->
|
||||
Set.of(
|
||||
ScanOperationState.CLEAN,
|
||||
ScanOperationState.MALICIOUS,
|
||||
ScanOperationState.INDETERMINATE)
|
||||
.contains(next);
|
||||
case INDETERMINATE -> next == ScanOperationState.PENDING;
|
||||
case CLEAN, MALICIOUS -> false;
|
||||
};
|
||||
return requireAllowed(next, allowed);
|
||||
}
|
||||
|
||||
public static PublishedReferenceState advanceReference(
|
||||
PublishedReferenceState current, PublishedReferenceState next) {
|
||||
boolean allowed =
|
||||
switch (current) {
|
||||
case PUBLISHED -> next == PublishedReferenceState.RETIREMENT_PENDING;
|
||||
case RETIREMENT_PENDING -> next == PublishedReferenceState.RETIRED;
|
||||
case RETIRED -> next == PublishedReferenceState.PURGE_ELIGIBLE;
|
||||
case PURGE_ELIGIBLE -> next == PublishedReferenceState.PURGED;
|
||||
case PURGED -> false;
|
||||
};
|
||||
return requireAllowed(next, allowed);
|
||||
}
|
||||
|
||||
public static DirectGrantSessionState advanceDirectGrant(
|
||||
DirectGrantSessionState current, DirectGrantSessionState next) {
|
||||
boolean currentTerminal =
|
||||
Set.of(
|
||||
DirectGrantSessionState.DATA_UPLOADED,
|
||||
DirectGrantSessionState.EXPIRED,
|
||||
DirectGrantSessionState.ABORTED,
|
||||
DirectGrantSessionState.FAILED)
|
||||
.contains(current);
|
||||
boolean branch =
|
||||
Set.of(
|
||||
DirectGrantSessionState.EXPIRED,
|
||||
DirectGrantSessionState.ABORTED,
|
||||
DirectGrantSessionState.FAILED)
|
||||
.contains(next);
|
||||
boolean allowed =
|
||||
!currentTerminal
|
||||
&& (branch
|
||||
|| switch (current) {
|
||||
case SESSION_RESERVED -> next == DirectGrantSessionState.GRANT_PREPARED;
|
||||
case GRANT_PREPARED -> next == DirectGrantSessionState.GRANT_ISSUED;
|
||||
case GRANT_ISSUED ->
|
||||
next == DirectGrantSessionState.UPLOAD_VERIFICATION_IN_PROGRESS;
|
||||
case UPLOAD_VERIFICATION_IN_PROGRESS ->
|
||||
next == DirectGrantSessionState.DATA_UPLOADED;
|
||||
default -> false;
|
||||
});
|
||||
return requireAllowed(next, allowed);
|
||||
}
|
||||
|
||||
public static MultipartUploadState advanceMultipart(
|
||||
MultipartUploadState current, MultipartUploadState next) {
|
||||
boolean currentTerminal =
|
||||
Set.of(
|
||||
MultipartUploadState.COMPLETED,
|
||||
MultipartUploadState.ABORTED,
|
||||
MultipartUploadState.EXPIRED,
|
||||
MultipartUploadState.FAILED,
|
||||
MultipartUploadState.CORRUPT)
|
||||
.contains(current);
|
||||
boolean branch =
|
||||
Set.of(
|
||||
MultipartUploadState.ABORTED,
|
||||
MultipartUploadState.EXPIRED,
|
||||
MultipartUploadState.FAILED,
|
||||
MultipartUploadState.CORRUPT)
|
||||
.contains(next);
|
||||
boolean allowed =
|
||||
!currentTerminal
|
||||
&& (branch
|
||||
|| switch (current) {
|
||||
case SESSION_RESERVED -> next == MultipartUploadState.INITIATE_IN_PROGRESS;
|
||||
case INITIATE_IN_PROGRESS -> next == MultipartUploadState.ACCEPTING_PARTS;
|
||||
case ACCEPTING_PARTS -> next == MultipartUploadState.COMPLETE_IN_PROGRESS;
|
||||
case COMPLETE_IN_PROGRESS -> next == MultipartUploadState.COMPLETED;
|
||||
default -> false;
|
||||
});
|
||||
return requireAllowed(next, allowed);
|
||||
}
|
||||
|
||||
public static long requireNextRevision(long current, long next) {
|
||||
if (current < 0 || next != current + 1) {
|
||||
throw invalid();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private static <T> T requireAllowed(T next, boolean allowed) {
|
||||
if (!allowed) {
|
||||
throw invalid();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private static IllegalStateException invalid() {
|
||||
return new IllegalStateException("object-storage state transition is not allowed");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user