merge: integrate object storage production capability

This commit is contained in:
donghyeon-ka
2026-08-01 00:57:40 +09:00
445 changed files with 32429 additions and 796 deletions
+3
View File
@@ -2,6 +2,9 @@
// composition and diagnostic rendering belong to adapters/bootstrap.
dependencies {
implementation project(':shared-contract')
// Property-based verification for bounded object-storage identity and value contracts.
testImplementation 'net.jqwik:jqwik:1.9.1'
}
sourceSets {
+6 -1
View File
@@ -35,6 +35,11 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisPolicyCo
javax.inject:javax.inject:1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy:1.17.8=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-api:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-engine:1.9.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath
net.jqwik:jqwik-time:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-web:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
@@ -50,7 +55,7 @@ org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,testCompileClasspath
org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
@@ -0,0 +1,12 @@
package dev.caskeleton.application.objectstorage.content;
/** Read-only, process-local cancellation signal for a synchronous content callback. */
@FunctionalInterface
public interface CancellationView {
boolean isCancelled();
static CancellationView never() {
return () -> false;
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.content;
/** Failure while a consumer reads one bounded chunk from the adapter-owned source. */
public class ObjectChunkReadException extends ObjectContentConsumptionException {
public ObjectChunkReadException(String message) {
super(message);
}
public ObjectChunkReadException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.objectstorage.content;
import java.util.Objects;
/** Adapter-owned bounded sink; producer arrays are valid only until {@link #write} returns. */
@FunctionalInterface
public interface ObjectChunkSink {
void write(byte[] bytes, int offset, int length) throws ObjectChunkWriteException;
static ObjectChunkSink scoped(ObjectContentProductionContext context, ObjectChunkSink delegate) {
Objects.requireNonNull(context, "context must be non-null");
Objects.requireNonNull(delegate, "delegate must be non-null");
return (bytes, offset, length) -> {
context.requireActive();
Objects.requireNonNull(bytes, "bytes must be non-null");
Objects.checkFromIndexSize(offset, length, bytes.length);
if (length > context.maximumChunkBytes()) {
throw new IllegalArgumentException("chunk exceeds maximumChunkBytes");
}
if (length > 0) {
delegate.write(bytes, offset, length);
}
};
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.application.objectstorage.content;
import java.util.Objects;
/** Adapter-owned bounded source with strict EOF and progress semantics. */
@FunctionalInterface
public interface ObjectChunkSource {
int EOF = -1;
int MAXIMUM_ZERO_PROGRESS_READS = 3;
int read(byte[] destination, int offset, int length) throws ObjectChunkReadException;
static ObjectChunkSource scoped(ObjectContentReadContext context, ObjectChunkSource delegate) {
Objects.requireNonNull(context, "context must be non-null");
Objects.requireNonNull(delegate, "delegate must be non-null");
return new ObjectChunkSource() {
private int consecutiveZeroProgressReads;
@Override
public int read(byte[] destination, int offset, int length) throws ObjectChunkReadException {
context.requireActive();
Objects.requireNonNull(destination, "destination must be non-null");
Objects.checkFromIndexSize(offset, length, destination.length);
if (length > context.maximumChunkBytes()) {
throw new IllegalArgumentException("read exceeds maximumChunkBytes");
}
if (length == 0) {
return 0;
}
int count = delegate.read(destination, offset, length);
if (count == 0) {
consecutiveZeroProgressReads++;
if (consecutiveZeroProgressReads > MAXIMUM_ZERO_PROGRESS_READS) {
throw new ObjectChunkReadException("source made no bounded progress");
}
return 0;
}
consecutiveZeroProgressReads = 0;
if (count == EOF) {
return EOF;
}
if (count < 1 || count > length) {
throw new ObjectChunkReadException("source returned an invalid byte count");
}
return count;
}
};
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.content;
/** Failure while a producer writes one bounded chunk to the adapter-owned sink. */
public class ObjectChunkWriteException extends ObjectContentProductionException {
public ObjectChunkWriteException(String message) {
super(message);
}
public ObjectChunkWriteException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.objectstorage.content;
/**
* Synchronous blocking consumer callback. Adapters own and close the provider resource and
* invalidate the scoped source when this callback returns.
*/
@FunctionalInterface
public interface ObjectContentConsumer {
void consume(ObjectContentReadContext context, ObjectChunkSource source)
throws ObjectContentConsumptionException;
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.content;
/** Application consumer failure, distinct from a provider read failure. */
public class ObjectContentConsumptionException extends Exception {
public ObjectContentConsumptionException(String message) {
super(message);
}
public ObjectContentConsumptionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.objectstorage.content;
/**
* Synchronous blocking producer callback. Adapters must not invoke it on an SDK event-loop thread
* and must invalidate its scoped sink when the callback returns.
*/
@FunctionalInterface
public interface ObjectContentProducer {
void produce(ObjectContentProductionContext context, ObjectChunkSink sink)
throws ObjectContentProductionException;
}
@@ -0,0 +1,56 @@
package dev.caskeleton.application.objectstorage.content;
import dev.caskeleton.application.outbound.CallBudget;
import java.util.Objects;
/** Process-local callback scope for a bounded content producer. */
public final class ObjectContentProductionContext {
public static final int MAXIMUM_CHUNK_BYTES = 16 * 1024 * 1024;
private final CallBudget budget;
private final CancellationView cancellation;
private final int maximumChunkBytes;
private volatile boolean active = true;
private ObjectContentProductionContext(
CallBudget budget, CancellationView cancellation, int maximumChunkBytes) {
this.budget = Objects.requireNonNull(budget, "budget must be non-null");
this.cancellation = Objects.requireNonNull(cancellation, "cancellation must be non-null");
if (maximumChunkBytes < 1 || maximumChunkBytes > MAXIMUM_CHUNK_BYTES) {
throw new IllegalArgumentException("maximumChunkBytes is outside the supported range");
}
this.maximumChunkBytes = maximumChunkBytes;
}
public static ObjectContentProductionContext open(
CallBudget budget, CancellationView cancellation, int maximumChunkBytes) {
return new ObjectContentProductionContext(budget, cancellation, maximumChunkBytes);
}
public CallBudget budget() {
return budget;
}
public CancellationView cancellation() {
return cancellation;
}
public int maximumChunkBytes() {
return maximumChunkBytes;
}
/** Adapter lifecycle hook; scoped sinks reject every subsequent call. */
public void invalidate() {
active = false;
}
void requireActive() {
if (!active) {
throw new IllegalStateException("content production callback scope is closed");
}
if (cancellation.isCancelled()) {
throw new IllegalStateException("content production callback is cancelled");
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.content;
/** Application producer failure, distinct from a provider write failure. */
public class ObjectContentProductionException extends Exception {
public ObjectContentProductionException(String message) {
super(message);
}
public ObjectContentProductionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,91 @@
package dev.caskeleton.application.objectstorage.content;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectReadRange;
import dev.caskeleton.application.outbound.CallBudget;
import java.util.Objects;
/** Process-local callback scope with the validated exact read facts visible to a consumer. */
public final class ObjectContentReadContext {
private final CallBudget budget;
private final CancellationView cancellation;
private final int maximumChunkBytes;
private final ObjectContentIdentity contentIdentity;
private final ObjectVersionToken exactVersion;
private final ObjectReadRange deliveredRange;
private volatile boolean active = true;
private ObjectContentReadContext(
CallBudget budget,
CancellationView cancellation,
int maximumChunkBytes,
ObjectContentIdentity contentIdentity,
ObjectVersionToken exactVersion,
ObjectReadRange deliveredRange) {
this.budget = Objects.requireNonNull(budget, "budget must be non-null");
this.cancellation = Objects.requireNonNull(cancellation, "cancellation must be non-null");
if (maximumChunkBytes < 1
|| maximumChunkBytes > ObjectContentProductionContext.MAXIMUM_CHUNK_BYTES) {
throw new IllegalArgumentException("maximumChunkBytes is outside the supported range");
}
this.maximumChunkBytes = maximumChunkBytes;
this.contentIdentity =
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
this.exactVersion = Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
this.deliveredRange = Objects.requireNonNull(deliveredRange, "deliveredRange must be non-null");
if (deliveredRange.endExclusive() > contentIdentity.exactLength()) {
throw new IllegalArgumentException("deliveredRange exceeds exact content length");
}
}
public static ObjectContentReadContext open(
CallBudget budget,
CancellationView cancellation,
int maximumChunkBytes,
ObjectContentIdentity contentIdentity,
ObjectVersionToken exactVersion,
ObjectReadRange deliveredRange) {
return new ObjectContentReadContext(
budget, cancellation, maximumChunkBytes, contentIdentity, exactVersion, deliveredRange);
}
public CallBudget budget() {
return budget;
}
public CancellationView cancellation() {
return cancellation;
}
public int maximumChunkBytes() {
return maximumChunkBytes;
}
public ObjectContentIdentity contentIdentity() {
return contentIdentity;
}
public ObjectVersionToken exactVersion() {
return exactVersion;
}
public ObjectReadRange deliveredRange() {
return deliveredRange;
}
/** Adapter lifecycle hook; scoped sources reject every subsequent call. */
public void invalidate() {
active = false;
}
void requireActive() {
if (!active) {
throw new IllegalStateException("content read callback scope is closed");
}
if (cancellation.isCancelled()) {
throw new IllegalStateException("content read callback is cancelled");
}
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Opaque direct-transfer workflow identity for single-part or multipart sessions. */
public final class DirectTransferSessionId {
private final String canonicalText;
private DirectTransferSessionId(String canonicalText) {
this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osu1", "osm1");
}
public static DirectTransferSessionId parse(String canonicalText) {
return new DirectTransferSessionId(canonicalText);
}
public String canonicalText() {
return canonicalText;
}
public String redactedLogToken() {
return ObjectIdentitySupport.redactedLogToken(canonicalText);
}
@Override
public String toString() {
return redactedLogToken();
}
@Override
public boolean equals(Object other) {
return other instanceof DirectTransferSessionId that
&& canonicalText.equals(that.canonicalText);
}
@Override
public int hashCode() {
return Objects.hash(canonicalText);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.objectstorage.identity;
/** One-based multipart part number, bounded by the S3-compatible maximum. */
public record MultipartPartNumber(int value) {
public MultipartPartNumber {
if (value < 1 || value > 10_000) {
throw new IllegalArgumentException("multipart part number must be between 1 and 10000");
}
}
public static MultipartPartNumber of(int value) {
return new MultipartPartNumber(value);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.identity;
/** Logical storage/security destination; never a provider bucket, path, or endpoint. */
public record ObjectDestinationId(String value) {
public ObjectDestinationId {
value = ObjectIdentitySupport.requireSimple("object destination id", value, 64);
}
public static ObjectDestinationId of(String value) {
return new ObjectDestinationId(value);
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Adapter-minted immutable data-object identity with no provider coordinates. */
public final class ObjectId {
private final String canonicalText;
private ObjectId(String canonicalText) {
this.canonicalText = ObjectIdentitySupport.requireObjectId(canonicalText);
}
public static ObjectId parse(String canonicalText) {
return new ObjectId(canonicalText);
}
public String canonicalText() {
return canonicalText;
}
public String redactedLogToken() {
return ObjectIdentitySupport.redactedLogToken(canonicalText);
}
@Override
public String toString() {
return redactedLogToken();
}
@Override
public boolean equals(Object other) {
return other instanceof ObjectId that && canonicalText.equals(that.canonicalText);
}
@Override
public int hashCode() {
return Objects.hash(canonicalText);
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.application.objectstorage.identity;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
final class ObjectIdentitySupport {
private static final String CROCKFORD = "[0-9abcdefghjkmnpqrstvwxyz]+";
private static final String SIMPLE = "[a-z0-9][a-z0-9_-]*";
private ObjectIdentitySupport() {}
static String requireSimple(String label, String value, int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength
|| !value.matches(SIMPLE)) {
throw invalid(label);
}
return value;
}
static String requireObjectId(String value) {
if (value == null || value.length() != 26 || !value.matches(CROCKFORD)) {
throw invalid("object id");
}
return value;
}
static String requireRouted(String value, String... acceptedPrefixes) {
if (value == null || value.length() > 64) {
throw invalid("opaque object identity");
}
String[] parts = value.split("\\.", -1);
if (parts.length != 4
|| !accepted(parts[0], acceptedPrefixes)
|| parts[1].length() != 12
|| !parts[1].matches(CROCKFORD)
|| parts[2].length() != 26
|| !parts[2].matches(CROCKFORD)
|| !parts[3].matches("[0-9a-f]{10}")) {
throw invalid("opaque object identity");
}
String payload = parts[0] + "." + parts[1] + "." + parts[2];
requireCheck(payload, parts[3]);
return value;
}
static String requirePartReceipt(String value) {
if (value == null || value.length() > 48) {
throw invalid("part receipt token");
}
String[] parts = value.split("\\.", -1);
if (parts.length != 3
|| !"osp1".equals(parts[0])
|| parts[1].length() != 26
|| !parts[1].matches(CROCKFORD)
|| !parts[2].matches("[0-9a-f]{10}")) {
throw invalid("part receipt token");
}
requireCheck(parts[0] + "." + parts[1], parts[2]);
return value;
}
static String redactedLogToken(String canonicalText) {
int separator = canonicalText.indexOf('.');
String family = separator < 0 ? "object-id" : canonicalText.substring(0, separator);
return family + "#" + sha256Hex(canonicalText).substring(0, 12);
}
private static boolean accepted(String candidate, String[] acceptedPrefixes) {
for (String acceptedPrefix : acceptedPrefixes) {
if (acceptedPrefix.equals(candidate)) {
return true;
}
}
return false;
}
private static void requireCheck(String payload, String actual) {
byte[] expected = sha256Hex(payload).substring(0, 10).getBytes(StandardCharsets.US_ASCII);
byte[] supplied = actual.getBytes(StandardCharsets.US_ASCII);
if (!MessageDigest.isEqual(expected, supplied)) {
throw invalid("opaque object identity");
}
}
private static String sha256Hex(String value) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available", e);
}
}
private static IllegalArgumentException invalid(String label) {
return new IllegalArgumentException(label + " is not canonical");
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.identity;
/** Bounded namespace epoch used to rotate or seal operation identities. */
public record ObjectOperationEpoch(String value) {
public ObjectOperationEpoch {
value = ObjectIdentitySupport.requireSimple("object operation epoch", value, 64);
}
public static ObjectOperationEpoch of(String value) {
return new ObjectOperationEpoch(value);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.objectstorage.identity;
/** Stable identity reused only for retries of the same logical mutation. */
public record ObjectOperationId(String value) {
public ObjectOperationId {
value = ObjectIdentitySupport.requireSimple("object operation id", value, 64);
}
public static ObjectOperationId of(String value) {
return new ObjectOperationId(value);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Exact mutation key: logical destination, retained operation epoch, and stable operation ID. */
public record ObjectOperationKey(
ObjectDestinationId destination, ObjectOperationEpoch epoch, ObjectOperationId operationId) {
public ObjectOperationKey {
Objects.requireNonNull(destination, "destination must be non-null");
Objects.requireNonNull(epoch, "epoch must be non-null");
Objects.requireNonNull(operationId, "operationId must be non-null");
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Opaque published reference. It is an identity, not an authorization credential. */
public final class ObjectReference {
private final String canonicalText;
private ObjectReference(String canonicalText) {
this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osr1");
}
public static ObjectReference parse(String canonicalText) {
return new ObjectReference(canonicalText);
}
public String canonicalText() {
return canonicalText;
}
public String redactedLogToken() {
return ObjectIdentitySupport.redactedLogToken(canonicalText);
}
@Override
public String toString() {
return redactedLogToken();
}
@Override
public boolean equals(Object other) {
return other instanceof ObjectReference that && canonicalText.equals(that.canonicalText);
}
@Override
public int hashCode() {
return Objects.hash(canonicalText);
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Opaque handle for an unpublished object; published-reference parsers reject it. */
public final class ObjectStageHandle {
private final String canonicalText;
private ObjectStageHandle(String canonicalText) {
this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osh1");
}
public static ObjectStageHandle parse(String canonicalText) {
return new ObjectStageHandle(canonicalText);
}
public String canonicalText() {
return canonicalText;
}
public String redactedLogToken() {
return ObjectIdentitySupport.redactedLogToken(canonicalText);
}
@Override
public String toString() {
return redactedLogToken();
}
@Override
public boolean equals(Object other) {
return other instanceof ObjectStageHandle that && canonicalText.equals(that.canonicalText);
}
@Override
public int hashCode() {
return Objects.hash(canonicalText);
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Opaque exact-generation precondition; it never exposes provider version or ETag values. */
public final class ObjectVersionToken {
private final String canonicalText;
private ObjectVersionToken(String canonicalText) {
this.canonicalText = ObjectIdentitySupport.requireRouted(canonicalText, "osv1");
}
public static ObjectVersionToken parse(String canonicalText) {
return new ObjectVersionToken(canonicalText);
}
public String canonicalText() {
return canonicalText;
}
public String redactedLogToken() {
return ObjectIdentitySupport.redactedLogToken(canonicalText);
}
@Override
public String toString() {
return redactedLogToken();
}
@Override
public boolean equals(Object other) {
return other instanceof ObjectVersionToken that && canonicalText.equals(that.canonicalText);
}
@Override
public int hashCode() {
return Objects.hash(canonicalText);
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.objectstorage.identity;
import java.util.Objects;
/** Server-issued opaque acknowledgement of a verified multipart part. */
public final class PartReceiptToken {
private final String canonicalText;
private PartReceiptToken(String canonicalText) {
this.canonicalText = ObjectIdentitySupport.requirePartReceipt(canonicalText);
}
public static PartReceiptToken parse(String canonicalText) {
return new PartReceiptToken(canonicalText);
}
public String canonicalText() {
return canonicalText;
}
public String redactedLogToken() {
return ObjectIdentitySupport.redactedLogToken(canonicalText);
}
@Override
public String toString() {
return redactedLogToken();
}
@Override
public boolean equals(Object other) {
return other instanceof PartReceiptToken that && canonicalText.equals(that.canonicalText);
}
@Override
public int hashCode() {
return Objects.hash(canonicalText);
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/** Transient download bearer grant; persistence and string rendering must omit its secret URI. */
public final class DirectDownloadGrant {
private final DirectTransferSessionId sessionId;
private final URI requestUri;
private final Map<String, String> signedHeaders;
private final Instant expiresAt;
public DirectDownloadGrant(
DirectTransferSessionId sessionId,
URI requestUri,
Map<String, String> signedHeaders,
Instant expiresAt) {
this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null");
this.requestUri = requireGrantUri(requestUri);
this.signedHeaders = ObjectModelSupport.immutableHeaders(signedHeaders);
this.expiresAt = ObjectModelSupport.requireExpiry(expiresAt);
}
public DirectTransferSessionId sessionId() {
return sessionId;
}
public URI requestUri() {
return requestUri;
}
public Map<String, String> signedHeaders() {
return signedHeaders;
}
public Instant expiresAt() {
return expiresAt;
}
private static URI requireGrantUri(URI uri) {
if (uri == null
|| uri.toASCIIString().length() > 4096
|| uri.getHost() == null
|| uri.getUserInfo() != null
|| uri.getFragment() != null
|| !("https".equalsIgnoreCase(uri.getScheme())
|| "http".equalsIgnoreCase(uri.getScheme()))) {
throw new IllegalArgumentException("grant URI is invalid");
}
return uri;
}
@Override
public String toString() {
return "DirectDownloadGrant[session=" + sessionId.redactedLogToken() + ", redacted]";
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.util.Objects;
/** Server-verified completion; a client success claim alone never creates this receipt. */
public record DirectUploadCompletionReceipt(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectContentIdentity verifiedContent,
ObjectMutationOutcome outcome) {
public DirectUploadCompletionReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(verifiedContent, "verifiedContent must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/** Transient bearer grant; string rendering never includes its URI or signed header values. */
public final class DirectUploadGrant {
private final DirectTransferSessionId sessionId;
private final URI requestUri;
private final Map<String, String> signedHeaders;
private final Instant expiresAt;
public DirectUploadGrant(
DirectTransferSessionId sessionId,
URI requestUri,
Map<String, String> signedHeaders,
Instant expiresAt) {
this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null");
this.requestUri = requireGrantUri(requestUri);
this.signedHeaders = ObjectModelSupport.immutableHeaders(signedHeaders);
this.expiresAt = ObjectModelSupport.requireExpiry(expiresAt);
}
public DirectTransferSessionId sessionId() {
return sessionId;
}
public URI requestUri() {
return requestUri;
}
public Map<String, String> signedHeaders() {
return signedHeaders;
}
public Instant expiresAt() {
return expiresAt;
}
private static URI requireGrantUri(URI uri) {
if (uri == null
|| uri.toASCIIString().length() > 4096
|| uri.getHost() == null
|| uri.getUserInfo() != null
|| uri.getFragment() != null
|| !("https".equalsIgnoreCase(uri.getScheme())
|| "http".equalsIgnoreCase(uri.getScheme()))) {
throw new IllegalArgumentException("grant URI is invalid");
}
return uri;
}
@Override
public String toString() {
return "DirectUploadGrant[session=" + sessionId.redactedLogToken() + ", redacted]";
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.util.Objects;
/** Verified multipart completion receipt that still represents an unpublished stage. */
public record MultipartReceipt(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectContentIdentity verifiedContent,
ObjectMutationOutcome outcome) {
public MultipartReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(verifiedContent, "verifiedContent must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import java.time.Instant;
import java.util.Objects;
/** Opaque multipart session with a bounded part budget and expiry. */
public record MultipartSession(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
Instant expiresAt,
int maximumParts,
ObjectMutationOutcome outcome) {
public MultipartSession {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(sessionId, "sessionId must be non-null");
Objects.requireNonNull(expiresAt, "expiresAt must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
if (maximumParts < 1 || maximumParts > 10_000) {
throw new IllegalArgumentException("maximumParts is outside the supported range");
}
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.objectstorage.model;
/** Named guarantee that a compiled destination/provider profile must satisfy. */
public enum ObjectCapabilityRequirement {
IMMUTABLE_CREATE,
EXACT_VERSION_READ,
CONDITIONAL_RETIREMENT,
SHA_256_VERIFICATION,
SCAN_GATED_PUBLICATION,
DIRECT_UPLOAD,
DIRECT_MULTIPART,
RETENTION_HOLD,
SERVER_SIDE_ENCRYPTION,
RESPONSE_LOSS_RECONCILIATION
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.objectstorage.model;
import java.util.Base64;
import java.util.Objects;
/** Exact expected byte length plus a logical full-content digest. */
public record ObjectContentIdentity(long exactLength, ObjectDigest fullDigest) {
public ObjectContentIdentity {
if (exactLength < 0) {
throw new IllegalArgumentException("exactLength must be non-negative");
}
Objects.requireNonNull(fullDigest, "fullDigest must be non-null");
}
public static ObjectContentIdentity sha256(long exactLength, byte[] digestBytes) {
Objects.requireNonNull(digestBytes, "digestBytes must be non-null");
return new ObjectContentIdentity(
exactLength,
ObjectDigest.of(
ObjectDigestAlgorithm.SHA_256,
Base64.getEncoder().encodeToString(digestBytes.clone())));
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Published logical object metadata with no physical provider coordinates. */
public record ObjectDescriptor(
ObjectReference reference,
ObjectVersionToken exactVersion,
ObjectContentIdentity contentIdentity,
ObjectMediaType declaredMediaType,
Optional<ObjectMediaType> detectedMediaType,
ObjectPublicationState publicationState,
ObjectScanState scanState,
ObjectEncryptionRequirement encryption,
ObjectRetentionRequirement retention,
Instant createdAt,
Optional<Instant> publishedAt,
int schemaVersion) {
public ObjectDescriptor {
Objects.requireNonNull(reference, "reference must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
Objects.requireNonNull(declaredMediaType, "declaredMediaType must be non-null");
Objects.requireNonNull(detectedMediaType, "detectedMediaType must be non-null");
Objects.requireNonNull(publicationState, "publicationState must be non-null");
Objects.requireNonNull(scanState, "scanState must be non-null");
Objects.requireNonNull(encryption, "encryption must be non-null");
Objects.requireNonNull(retention, "retention must be non-null");
Objects.requireNonNull(createdAt, "createdAt must be non-null");
Objects.requireNonNull(publishedAt, "publishedAt must be non-null");
if (schemaVersion < 1) {
throw new IllegalArgumentException("schemaVersion must be positive");
}
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.application.objectstorage.model;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Objects;
/** Canonical Base64-encoded logical full-content digest. */
public record ObjectDigest(ObjectDigestAlgorithm algorithm, String base64Value) {
public ObjectDigest {
Objects.requireNonNull(algorithm, "algorithm must be non-null");
if (base64Value == null || base64Value.isBlank() || base64Value.length() > 128) {
throw new IllegalArgumentException("digest value is not canonical");
}
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(base64Value);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("digest value is not canonical", exception);
}
if (!Base64.getEncoder().encodeToString(decoded).equals(base64Value)
|| (algorithm == ObjectDigestAlgorithm.SHA_256 && decoded.length != 32)) {
throw new IllegalArgumentException("digest value is not canonical");
}
}
public static ObjectDigest of(ObjectDigestAlgorithm algorithm, String base64Value) {
return new ObjectDigest(algorithm, base64Value);
}
public static ObjectDigest sha256(byte[] content) {
Objects.requireNonNull(content, "content must be non-null");
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(content);
return new ObjectDigest(
ObjectDigestAlgorithm.SHA_256, Base64.getEncoder().encodeToString(digest));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 must be available", exception);
}
}
public byte[] decodedValue() {
return Base64.getDecoder().decode(base64Value);
}
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.objectstorage.model;
/** Logical full-content digest algorithms; provider ETags and composite checksums are excluded. */
public enum ObjectDigestAlgorithm {
SHA_256
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.objectstorage.model;
/** Requested logical integrity verification for a read. */
public enum ObjectDigestVerification {
NONE,
FULL_CONTENT
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.objectstorage.model;
import java.util.Objects;
/** Minimum provider-neutral encryption profile strength. */
public enum ObjectEncryptionRequirement {
PROVIDER_MANAGED,
CUSTOMER_MANAGED;
public ObjectEncryptionRequirement requireAtLeast(ObjectEncryptionRequirement minimum) {
Objects.requireNonNull(minimum, "minimum must be non-null");
return this == CUSTOMER_MANAGED || minimum == PROVIDER_MANAGED ? this : CUSTOMER_MANAGED;
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import java.time.Instant;
import java.util.Objects;
/** Monotonic application/publication handoff fence. */
public record ObjectHandoffReceipt(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
long fence,
Instant leaseExpiresAt,
ObjectMutationOutcome outcome) {
public ObjectHandoffReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(leaseExpiresAt, "leaseExpiresAt must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
if (fence < 1) {
throw new IllegalArgumentException("fence must be positive");
}
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.objectstorage.model;
import java.util.Locale;
/** Canonical bounded declared media type without parameters or control characters. */
public record ObjectMediaType(String canonicalText) {
public ObjectMediaType {
if (canonicalText == null || canonicalText.length() > 127) {
throw new IllegalArgumentException("media type is not canonical");
}
canonicalText = canonicalText.toLowerCase(Locale.ROOT);
String token = "[a-z0-9!#$&^_.+-]+";
if (!canonicalText.matches(token + "/" + token)) {
throw new IllegalArgumentException("media type is not canonical");
}
}
public static ObjectMediaType of(String value) {
return new ObjectMediaType(value);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.application.objectstorage.model;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
final class ObjectModelSupport {
private ObjectModelSupport() {}
static String requireBoundedToken(String label, String value, int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength
|| value.chars().anyMatch(character -> character < 0x20 || character == 0x7f)) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
static Map<String, String> immutableHeaders(Map<String, String> headers) {
if (headers == null || headers.size() > 32) {
throw new IllegalArgumentException("signed headers are invalid");
}
Map<String, String> copy = new LinkedHashMap<>();
headers.forEach(
(name, value) -> {
String checkedName = requireBoundedToken("signed header name", name, 128);
String checkedValue = requireBoundedToken("signed header value", value, 1024);
if (!checkedName.equals(checkedName.toLowerCase(java.util.Locale.ROOT))) {
throw new IllegalArgumentException("signed header name is invalid");
}
copy.put(checkedName, checkedValue);
});
return Map.copyOf(copy);
}
static Instant requireExpiry(Instant expiresAt) {
if (expiresAt == null) {
throw new IllegalArgumentException("grant expiry is invalid");
}
return expiresAt;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.objectstorage.model;
/** Provider-neutral certainty/result of a logical object mutation. */
public enum ObjectMutationOutcome {
APPLIED,
REPLAYED,
NO_CHANGE,
HELD,
INDETERMINATE,
REJECTED
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import java.time.Instant;
import java.util.Objects;
/** Provider-neutral result for retirement, purge, abort, verdict, or handoff mutation. */
public record ObjectMutationReceipt(
ObjectOperationKey operationKey,
ObjectMutationOutcome outcome,
ObjectOperationError error,
Instant appliedAt) {
public ObjectMutationReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(error, "error must be non-null");
Objects.requireNonNull(appliedAt, "appliedAt must be non-null");
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.objectstorage.model;
/** Bounded provider-neutral operation error classification. */
public enum ObjectOperationError {
NONE,
INVALID_REQUEST,
NOT_FOUND,
CONFLICT,
UNSUPPORTED_CAPABILITY,
PROVIDER_UNAVAILABLE,
CORRUPT_EVIDENCE,
OPERATION_EPOCH_NOT_ACTIVE,
OPERATION_EXPIRED,
CANCELLED
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import java.util.Objects;
import java.util.Optional;
/** Read-only recovery view for an exact operation key. */
public record ObjectOperationResolution(
ObjectOperationKey operationKey,
ObjectPublicationState state,
ObjectMutationOutcome outcome,
ObjectOperationError error,
Optional<ObjectReference> publishedReference) {
public ObjectOperationResolution {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(state, "state must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(error, "error must be non-null");
Objects.requireNonNull(publishedReference, "publishedReference must be non-null");
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.objectstorage.model;
import java.util.Objects;
/** Minimum evidence required before a reference may become published. */
public enum ObjectPublicationRequirement {
INTEGRITY_VERIFIED,
SCAN_CLEAN;
public ObjectPublicationRequirement requireAtLeast(ObjectPublicationRequirement minimum) {
Objects.requireNonNull(minimum, "minimum must be non-null");
return this == SCAN_CLEAN || minimum == INTEGRITY_VERIFIED ? this : SCAN_CLEAN;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.objectstorage.model;
/** Logical visibility state; only PUBLISHED is available through public read ports. */
public enum ObjectPublicationState {
STAGED,
VERIFIED,
PUBLISHED,
RETIRED,
ABORTED,
QUARANTINED
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.time.Instant;
import java.util.Objects;
/** Durable terminal publication receipt containing only semantic identities and evidence. */
public record ObjectPublishReceipt(
ObjectOperationKey operationKey,
RequestFingerprint requestFingerprint,
ObjectReference reference,
ObjectVersionToken exactVersion,
ObjectContentIdentity contentIdentity,
ObjectMediaType mediaType,
ObjectMutationOutcome outcome,
Instant appliedAt,
String capabilityRevision) {
public ObjectPublishReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(requestFingerprint, "requestFingerprint must be non-null");
Objects.requireNonNull(reference, "reference must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
Objects.requireNonNull(mediaType, "mediaType must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(appliedAt, "appliedAt must be non-null");
capabilityRevision =
ObjectModelSupport.requireBoundedToken("capabilityRevision", capabilityRevision, 128);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.objectstorage.model;
/** One contiguous zero-based read range. */
public record ObjectReadRange(long offset, long length) {
public ObjectReadRange {
if (offset < 0 || length < 1) {
throw new IllegalArgumentException("range offset/length are outside the supported range");
}
try {
Math.addExact(offset, length);
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("range end overflows", exception);
}
}
public static ObjectReadRange of(long offset, long length) {
return new ObjectReadRange(offset, length);
}
public long endExclusive() {
return Math.addExact(offset, length);
}
public ObjectReadRange requireMaximumDeliveredBytes(long maximumDeliveredBytes) {
if (maximumDeliveredBytes < 1 || length > maximumDeliveredBytes) {
throw new IllegalArgumentException("range exceeds maximum delivered bytes");
}
return this;
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.util.Objects;
/** Completed bounded read facts; it is not proof that a partial range verified the full digest. */
public record ObjectReadReceipt(
ObjectReference reference,
ObjectVersionToken exactVersion,
ObjectReadRange deliveredRange,
long deliveredBytes,
ObjectDigestVerification digestVerification) {
public ObjectReadReceipt {
Objects.requireNonNull(reference, "reference must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(deliveredRange, "deliveredRange must be non-null");
Objects.requireNonNull(digestVerification, "digestVerification must be non-null");
if (deliveredBytes < 0 || deliveredBytes > deliveredRange.length()) {
throw new IllegalArgumentException("deliveredBytes is outside the requested range");
}
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.objectstorage.model;
import java.util.Objects;
/** Minimum logical retention strength selected by a destination and optionally strengthened. */
public enum ObjectRetentionRequirement {
NONE,
RETAIN_UNTIL_POLICY,
LEGAL_HOLD;
public ObjectRetentionRequirement requireAtLeast(ObjectRetentionRequirement minimum) {
Objects.requireNonNull(minimum, "minimum must be non-null");
if (this == LEGAL_HOLD || minimum == NONE) {
return this;
}
if (minimum == LEGAL_HOLD) {
return LEGAL_HOLD;
}
return this == NONE ? RETAIN_UNTIL_POLICY : this;
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.util.Objects;
/** Bounded unpublished scanner read receipt; deliberately cannot carry a published reference. */
public record ObjectScanReadReceipt(
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectReadRange deliveredRange,
long deliveredBytes,
ObjectDigestVerification digestVerification) {
public ObjectScanReadReceipt {
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(deliveredRange, "deliveredRange must be non-null");
Objects.requireNonNull(digestVerification, "digestVerification must be non-null");
if (deliveredBytes < 0 || deliveredBytes > deliveredRange.length()) {
throw new IllegalArgumentException("deliveredBytes is outside the requested range");
}
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.objectstorage.model;
/** Scanner verdict bound to an exact unpublished version and policy revision. */
public enum ObjectScanState {
NOT_REQUIRED,
PENDING,
CLEAN,
MALICIOUS,
INDETERMINATE
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.util.Objects;
/** Unpublished stage receipt; deliberately contains no published reference. */
public record ObjectStageReceipt(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectContentIdentity contentIdentity,
ObjectMutationOutcome outcome) {
public ObjectStageReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.util.Objects;
/** Exact-version integrity verification result for an unpublished stage. */
public record ObjectVerificationReceipt(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectContentIdentity verifiedContent,
ObjectMutationOutcome outcome) {
public ObjectVerificationReceipt {
Objects.requireNonNull(operationKey, "operationKey must be non-null");
Objects.requireNonNull(stageHandle, "stageHandle must be non-null");
Objects.requireNonNull(exactVersion, "exactVersion must be non-null");
Objects.requireNonNull(verifiedContent, "verifiedContent must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.application.objectstorage.model;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/** Transient multipart-part bearer grant with redacted rendering. */
public final class PartUploadGrant {
private final DirectTransferSessionId sessionId;
private final MultipartPartNumber partNumber;
private final URI requestUri;
private final Map<String, String> signedHeaders;
private final Instant expiresAt;
public PartUploadGrant(
DirectTransferSessionId sessionId,
MultipartPartNumber partNumber,
URI requestUri,
Map<String, String> signedHeaders,
Instant expiresAt) {
this.sessionId = Objects.requireNonNull(sessionId, "sessionId must be non-null");
this.partNumber = Objects.requireNonNull(partNumber, "partNumber must be non-null");
this.requestUri = requireGrantUri(requestUri);
this.signedHeaders = ObjectModelSupport.immutableHeaders(signedHeaders);
this.expiresAt = ObjectModelSupport.requireExpiry(expiresAt);
}
public DirectTransferSessionId sessionId() {
return sessionId;
}
public MultipartPartNumber partNumber() {
return partNumber;
}
public URI requestUri() {
return requestUri;
}
public Map<String, String> signedHeaders() {
return signedHeaders;
}
public Instant expiresAt() {
return expiresAt;
}
private static URI requireGrantUri(URI uri) {
if (uri == null
|| uri.toASCIIString().length() > 4096
|| uri.getHost() == null
|| uri.getUserInfo() != null
|| uri.getFragment() != null
|| !("https".equalsIgnoreCase(uri.getScheme())
|| "http".equalsIgnoreCase(uri.getScheme()))) {
throw new IllegalArgumentException("grant URI is invalid");
}
return uri;
}
@Override
public String toString() {
return "PartUploadGrant[session="
+ sessionId.redactedLogToken()
+ ", part="
+ partNumber.value()
+ ", redacted]";
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.MultipartReceipt;
import dev.caskeleton.application.objectstorage.model.MultipartSession;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.model.PartUploadGrant;
import dev.caskeleton.application.objectstorage.request.MultipartAbortRequest;
import dev.caskeleton.application.objectstorage.request.MultipartCompleteRequest;
import dev.caskeleton.application.objectstorage.request.MultipartPartAcknowledgement;
import dev.caskeleton.application.objectstorage.request.MultipartStartRequest;
import dev.caskeleton.application.objectstorage.request.PartUploadGrantRequest;
/** Adapter-owned multipart session, part-ledger, completion, and abort boundary. */
public interface DirectMultipartUploadPort {
MultipartSession startMultipart(MultipartStartRequest request);
PartUploadGrant createPartGrant(PartUploadGrantRequest request);
PartReceiptToken acknowledgePart(MultipartPartAcknowledgement request);
MultipartReceipt completeMultipart(MultipartCompleteRequest request);
ObjectMutationReceipt abortMultipart(MultipartAbortRequest request);
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.model.DirectDownloadGrant;
import dev.caskeleton.application.objectstorage.request.DirectDownloadGrantRequest;
/** Issues a bounded exact-version download grant after application authorization. */
public interface DirectObjectDownloadGrantPort {
DirectDownloadGrant createDownloadGrant(DirectDownloadGrantRequest request);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.model.DirectUploadCompletionReceipt;
import dev.caskeleton.application.objectstorage.model.DirectUploadGrant;
import dev.caskeleton.application.objectstorage.request.DirectUploadCompletionRequest;
import dev.caskeleton.application.objectstorage.request.DirectUploadGrantRequest;
/** Direct single-object grant issuance and server-side completion verification. */
public interface DirectObjectUploadPort {
DirectUploadGrant createUploadGrant(DirectUploadGrantRequest request);
DirectUploadCompletionReceipt completeUpload(DirectUploadCompletionRequest request);
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest;
/** Managed single-call publication for destinations whose compiled policy does not require scan. */
public interface ManagedObjectPublicationPort {
ObjectPublishReceipt publish(ObjectPublishRequest request, ObjectContentProducer producer);
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.model.ObjectDescriptor;
import java.util.Optional;
/** Inspects only published opaque references. */
public interface ObjectInspectionPort {
Optional<ObjectDescriptor> inspect(ObjectReference reference);
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectOperationResolution;
/** Read-only recovery lookup for an exact retained operation key. */
public interface ObjectOperationResolutionPort {
ObjectOperationResolution resolve(ObjectOperationKey operationKey);
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.model.ObjectHandoffReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectAbortAuthorization;
import dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest;
import dev.caskeleton.application.objectstorage.request.ObjectHandoffReleaseRequest;
import dev.caskeleton.application.objectstorage.request.ObjectHandoffRenewRequest;
/** Fenced handoff seam between application intent and storage publication/cleanup. */
public interface ObjectPublicationHandoffPort {
ObjectHandoffReceipt claimForPublication(ObjectHandoffClaimRequest request);
ObjectHandoffReceipt renewClaim(ObjectHandoffRenewRequest request);
ObjectMutationReceipt releaseClaim(ObjectHandoffReleaseRequest request);
ObjectMutationReceipt authorizeAbort(ObjectAbortAuthorization authorization);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectPurgeRequest;
/**
* Privileged exact-version physical purge boundary. It must be assembled only in a separate
* maintenance composition with stronger credentials and must never be injected into normal business
* use cases.
*/
public interface ObjectPurgeMaintenancePort {
ObjectMutationReceipt purge(ObjectPurgeRequest request);
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest;
/** Logical business retirement; it does not grant physical purge authority. */
public interface ObjectRetirementPort {
ObjectMutationReceipt retire(ObjectRetireRequest request);
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectScanReadReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectScanReadRequest;
import dev.caskeleton.application.objectstorage.request.ObjectScanVerdictRequest;
/**
* Narrow scanner-workflow composition boundary for unpublished exact-version transfer and verdict
* recording. It must not share a normal publication router or privileged purge router.
*/
public interface ObjectScanMaintenancePort {
ObjectScanReadReceipt transferForScan(
ObjectScanReadRequest request, ObjectContentConsumer consumer);
ObjectMutationReceipt recordScanVerdict(ObjectScanVerdictRequest request);
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.content.ObjectContentConsumer;
import dev.caskeleton.application.objectstorage.model.ObjectReadReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectReadRequest;
/** Bounded server-mediated transfer of an authorized published reference. */
public interface ObjectTransferPort {
ObjectReadReceipt transfer(ObjectReadRequest request, ObjectContentConsumer consumer);
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.objectstorage.port;
import dev.caskeleton.application.objectstorage.content.ObjectContentProducer;
import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectPublishReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectStageReceipt;
import dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt;
import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest;
import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest;
import dev.caskeleton.application.objectstorage.request.ObjectStageRequest;
import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest;
/** Normal staged publication; scanner unpublished-read/verdict authority is deliberately absent. */
public interface StagedObjectPublicationPort {
ObjectStageReceipt stage(ObjectStageRequest request, ObjectContentProducer producer);
ObjectVerificationReceipt verifyIntegrity(ObjectVerifyRequest request);
ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request);
ObjectMutationReceipt abort(ObjectAbortRequest request);
}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/** Request for a short-lived exact-version download grant after application authorization. */
public record DirectDownloadGrantRequest(
ObjectOperationKey operationKey,
ObjectReference reference,
Optional<ObjectVersionToken> expectedVersion,
Duration requestedTtl,
CallBudget budget,
CancellationView cancellation) {
public DirectDownloadGrantRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(reference, "reference");
Objects.requireNonNull(expectedVersion, "expectedVersion must be non-null");
requestedTtl = ObjectRequestSupport.grantTtl(requestedTtl);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.outbound.CallBudget;
/** Server verification request for a direct upload; it contains no trusted client-success flag. */
public record DirectUploadCompletionRequest(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
ObjectContentIdentity expectedContent,
CallBudget budget,
CancellationView cancellation) {
public DirectUploadCompletionRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(sessionId, "sessionId");
ObjectRequestSupport.required(expectedContent, "expectedContent");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
/** Request for a bounded direct-upload bearer grant. */
public record DirectUploadGrantRequest(
ObjectOperationKey operationKey,
ObjectMediaType declaredMediaType,
ObjectContentIdentity contentIdentity,
ObjectPublicationRequirement publicationRequirement,
Duration requestedTtl,
CallBudget budget,
CancellationView cancellation) {
public DirectUploadGrantRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(declaredMediaType, "declaredMediaType");
ObjectRequestSupport.required(contentIdentity, "contentIdentity");
ObjectRequestSupport.required(publicationRequirement, "publicationRequirement");
requestedTtl = ObjectRequestSupport.grantTtl(requestedTtl);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.outbound.CallBudget;
/** Conditional abort of one exact multipart session. */
public record MultipartAbortRequest(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
CallBudget budget,
CancellationView cancellation) {
public MultipartAbortRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(sessionId, "sessionId");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.PartReceiptToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.outbound.CallBudget;
import java.util.List;
import java.util.Objects;
/** Completes multipart using only server-issued part tokens and an exact full-content identity. */
public record MultipartCompleteRequest(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
List<PartReceiptToken> partTokens,
ObjectContentIdentity expectedContent,
CallBudget budget,
CancellationView cancellation) {
public MultipartCompleteRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(sessionId, "sessionId");
ObjectRequestSupport.required(partTokens, "partTokens");
if (partTokens.isEmpty()
|| partTokens.size() > 10_000
|| partTokens.stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("partTokens are outside the supported range");
}
partTokens = List.copyOf(partTokens);
ObjectRequestSupport.required(expectedContent, "expectedContent");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
/** Bounded client completion claim that the server verifies before issuing an opaque part token. */
public record MultipartPartAcknowledgement(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
MultipartPartNumber partNumber,
long observedLength,
ObjectDigest observedDigest,
String clientCompletionClaim) {
public MultipartPartAcknowledgement {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(sessionId, "sessionId");
ObjectRequestSupport.required(partNumber, "partNumber");
ObjectRequestSupport.positive(observedLength, "observedLength");
ObjectRequestSupport.required(observedDigest, "observedDigest");
clientCompletionClaim =
ObjectRequestSupport.boundedToken(clientCompletionClaim, "clientCompletionClaim", 1024);
}
@Override
public String toString() {
return "MultipartPartAcknowledgement[operation="
+ operationKey.operationId().value()
+ ", session="
+ sessionId.redactedLogToken()
+ ", part="
+ partNumber.value()
+ ", completionClaim=redacted]";
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
/** Starts one adapter-owned multipart session for an exact content identity. */
public record MultipartStartRequest(
ObjectOperationKey operationKey,
ObjectMediaType declaredMediaType,
ObjectContentIdentity contentIdentity,
ObjectPublicationRequirement publicationRequirement,
int maximumParts,
Duration sessionTtl,
CallBudget budget,
CancellationView cancellation) {
public MultipartStartRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(declaredMediaType, "declaredMediaType");
ObjectRequestSupport.required(contentIdentity, "contentIdentity");
ObjectRequestSupport.required(publicationRequirement, "publicationRequirement");
if (maximumParts < 1 || maximumParts > 10_000) {
throw new IllegalArgumentException("maximumParts is outside the supported range");
}
sessionTtl = ObjectRequestSupport.grantTtl(sessionTtl);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import java.time.Instant;
/** Application-issued exact authorization to destructively abort one unpublished stage. */
public record ObjectAbortAuthorization(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
long handoffFence,
Instant authorizedAt) {
public ObjectAbortAuthorization {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.positive(handoffFence, "handoffFence");
ObjectRequestSupport.required(authorizedAt, "authorizedAt");
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.outbound.CallBudget;
/** Abort request that must carry the exact application-issued authorization. */
public record ObjectAbortRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectAbortAuthorization authorization,
CallBudget budget,
CancellationView cancellation) {
public ObjectAbortRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(authorization, "authorization");
if (!operationKey.equals(authorization.operationKey())
|| !stageHandle.equals(authorization.stageHandle())) {
throw new IllegalArgumentException("abort authorization binding does not match request");
}
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.outbound.CallBudget;
/** Finalize is the sole staged operation allowed to mint a published reference. */
public record ObjectFinalizeRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectPublicationRequirement publicationRequirement,
CallBudget budget,
CancellationView cancellation) {
public ObjectFinalizeRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.required(publicationRequirement, "publicationRequirement");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
/** Claim request that starts a monotonically fenced publication handoff lease. */
public record ObjectHandoffClaimRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
String claimant,
Duration leaseDuration,
CallBudget budget,
CancellationView cancellation) {
public ObjectHandoffClaimRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(exactVersion, "exactVersion");
claimant = ObjectRequestSupport.boundedToken(claimant, "claimant", 128);
leaseDuration = ObjectRequestSupport.grantTtl(leaseDuration);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.outbound.CallBudget;
/** Conditional release of the exact claimant/fence pair. */
public record ObjectHandoffReleaseRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
long fence,
String claimant,
CallBudget budget,
CancellationView cancellation) {
public ObjectHandoffReleaseRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.positive(fence, "fence");
claimant = ObjectRequestSupport.boundedToken(claimant, "claimant", 128);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
/** Renewal request for the same claimant and exact monotonic handoff fence. */
public record ObjectHandoffRenewRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
long fence,
String claimant,
Duration leaseDuration,
CallBudget budget,
CancellationView cancellation) {
public ObjectHandoffRenewRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.positive(fence, "fence");
claimant = ObjectRequestSupport.boundedToken(claimant, "claimant", 128);
leaseDuration = ObjectRequestSupport.grantTtl(leaseDuration);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
import dev.caskeleton.application.outbound.CallBudget;
import java.util.Set;
/** Immutable intent for a managed scan-free publication. */
public record ObjectPublishRequest(
ObjectOperationKey operationKey,
ObjectMediaType declaredMediaType,
ObjectContentIdentity contentIdentity,
ObjectPublicationRequirement publicationRequirement,
ObjectRetentionRequirement retentionRequirement,
ObjectEncryptionRequirement encryptionRequirement,
Set<ObjectCapabilityRequirement> capabilityRequirements,
CallBudget budget,
CancellationView cancellation) {
public ObjectPublishRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(declaredMediaType, "declaredMediaType");
ObjectRequestSupport.required(contentIdentity, "contentIdentity");
ObjectRequestSupport.required(publicationRequirement, "publicationRequirement");
ObjectRequestSupport.required(retentionRequirement, "retentionRequirement");
ObjectRequestSupport.required(encryptionRequirement, "encryptionRequirement");
capabilityRequirements = ObjectRequestSupport.immutableCapabilities(capabilityRequirements);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.outbound.CallBudget;
/** Privileged physical purge request with an exact immutable version precondition. */
public record ObjectPurgeRequest(
ObjectOperationKey operationKey,
ObjectReference reference,
ObjectVersionToken exactVersion,
CallBudget budget,
CancellationView cancellation) {
public ObjectPurgeRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(reference, "reference");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectDigestVerification;
import dev.caskeleton.application.objectstorage.model.ObjectReadRange;
import dev.caskeleton.application.outbound.CallBudget;
import java.util.Objects;
import java.util.Optional;
/** Exact published-reference read request with one bounded range. */
public record ObjectReadRequest(
ObjectReference reference,
Optional<ObjectVersionToken> expectedVersion,
ObjectReadRange range,
ObjectDigestVerification digestVerification,
long maximumDeliveredBytes,
CallBudget budget,
CancellationView cancellation) {
public ObjectReadRequest {
ObjectRequestSupport.required(reference, "reference");
Objects.requireNonNull(expectedVersion, "expectedVersion must be non-null");
ObjectRequestSupport.required(range, "range")
.requireMaximumDeliveredBytes(maximumDeliveredBytes);
ObjectRequestSupport.required(digestVerification, "digestVerification");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
final class ObjectRequestSupport {
private static final Duration MAXIMUM_GRANT_TTL = Duration.ofHours(24);
private ObjectRequestSupport() {}
static <T> T required(T value, String label) {
return Objects.requireNonNull(value, label + " must be non-null");
}
static String boundedToken(String value, String label, int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength
|| value.chars().anyMatch(character -> character < 0x20 || character == 0x7f)) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
static Set<ObjectCapabilityRequirement> immutableCapabilities(
Set<ObjectCapabilityRequirement> capabilities) {
required(capabilities, "capabilities");
if (capabilities.contains(null)) {
throw new IllegalArgumentException("capabilities must not contain null");
}
return Set.copyOf(capabilities);
}
static Duration grantTtl(Duration requestedTtl) {
required(requestedTtl, "requestedTtl");
if (requestedTtl.isZero()
|| requestedTtl.isNegative()
|| requestedTtl.compareTo(MAXIMUM_GRANT_TTL) > 0) {
throw new IllegalArgumentException("requestedTtl is outside the supported range");
}
return requestedTtl;
}
static long positive(long value, String label) {
if (value < 1) {
throw new IllegalArgumentException(label + " must be positive");
}
return value;
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.outbound.CallBudget;
/** Logical business retirement of one exact published reference/version. */
public record ObjectRetireRequest(
ObjectOperationKey operationKey,
ObjectReference reference,
ObjectVersionToken exactVersion,
CallBudget budget,
CancellationView cancellation) {
public ObjectRetireRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(reference, "reference");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectReadRange;
import dev.caskeleton.application.outbound.CallBudget;
/** Narrow maintenance read of one exact unpublished version for a scanner workflow. */
public record ObjectScanReadRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectReadRange range,
long maximumDeliveredBytes,
CallBudget budget,
CancellationView cancellation) {
public ObjectScanReadRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.required(range, "range")
.requireMaximumDeliveredBytes(maximumDeliveredBytes);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectScanState;
import java.time.Instant;
/** Scanner verdict fenced to an exact stage/version, scan operation, and policy revision. */
public record ObjectScanVerdictRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectOperationId scanOperationId,
String scannerPolicyRevision,
ObjectScanState verdict,
Instant observedAt) {
public ObjectScanVerdictRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.required(scanOperationId, "scanOperationId");
scannerPolicyRevision =
ObjectRequestSupport.boundedToken(scannerPolicyRevision, "scannerPolicyRevision", 128);
ObjectRequestSupport.required(verdict, "verdict");
if (verdict != ObjectScanState.CLEAN
&& verdict != ObjectScanState.MALICIOUS
&& verdict != ObjectScanState.INDETERMINATE) {
throw new IllegalArgumentException("verdict is not a terminal scanner verdict");
}
ObjectRequestSupport.required(observedAt, "observedAt");
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement;
import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement;
import dev.caskeleton.application.outbound.CallBudget;
import java.util.Set;
/** Immutable unpublished-stage intent. */
public record ObjectStageRequest(
ObjectOperationKey operationKey,
ObjectMediaType declaredMediaType,
ObjectContentIdentity contentIdentity,
ObjectPublicationRequirement publicationRequirement,
ObjectRetentionRequirement retentionRequirement,
ObjectEncryptionRequirement encryptionRequirement,
Set<ObjectCapabilityRequirement> capabilityRequirements,
CallBudget budget,
CancellationView cancellation) {
public ObjectStageRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(declaredMediaType, "declaredMediaType");
ObjectRequestSupport.required(contentIdentity, "contentIdentity");
ObjectRequestSupport.required(publicationRequirement, "publicationRequirement");
ObjectRequestSupport.required(retentionRequirement, "retentionRequirement");
ObjectRequestSupport.required(encryptionRequirement, "encryptionRequirement");
capabilityRequirements = ObjectRequestSupport.immutableCapabilities(capabilityRequirements);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.outbound.CallBudget;
/** Full-content integrity verification request for an exact unpublished stage/version. */
public record ObjectVerifyRequest(
ObjectOperationKey operationKey,
ObjectStageHandle stageHandle,
ObjectVersionToken exactVersion,
ObjectContentIdentity expectedContent,
CallBudget budget,
CancellationView cancellation) {
public ObjectVerifyRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(stageHandle, "stageHandle");
ObjectRequestSupport.required(exactVersion, "exactVersion");
ObjectRequestSupport.required(expectedContent, "expectedContent");
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.objectstorage.request;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.identity.DirectTransferSessionId;
import dev.caskeleton.application.objectstorage.identity.MultipartPartNumber;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.model.ObjectDigest;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
/** Creates one bounded part grant tied to exact expected length and digest. */
public record PartUploadGrantRequest(
ObjectOperationKey operationKey,
DirectTransferSessionId sessionId,
MultipartPartNumber partNumber,
long exactPartLength,
ObjectDigest expectedPartDigest,
Duration requestedTtl,
CallBudget budget,
CancellationView cancellation) {
public PartUploadGrantRequest {
ObjectRequestSupport.required(operationKey, "operationKey");
ObjectRequestSupport.required(sessionId, "sessionId");
ObjectRequestSupport.required(partNumber, "partNumber");
ObjectRequestSupport.positive(exactPartLength, "exactPartLength");
ObjectRequestSupport.required(expectedPartDigest, "expectedPartDigest");
requestedTtl = ObjectRequestSupport.grantTtl(requestedTtl);
ObjectRequestSupport.required(budget, "budget");
ObjectRequestSupport.required(cancellation, "cancellation");
}
}
@@ -9,12 +9,23 @@ import java.util.Optional;
* ca-skeleton.objectstorage.backend}); see the {@code adapter:outbound:objectstorage} README for
* the backend matrix and the key-mapping contract.
*
* <p><strong>Legacy compatibility only.</strong> This contract preserves caller-keyed overwrite and
* whole-{@code byte[]} materialization semantics in a separate legacy namespace while stored data
* and the sample endpoint migrate. New code must use the semantic ports under {@code
* dev.caskeleton.application.objectstorage}. Production activation must be removed after the
* additive API/data migration, dual-read observation, external-consumer approval, and zero-usage
* gates pass; no calendar removal date is implied here.
*
* <p>Keys are backend-relative, caller-supplied, opaque strings (e.g. {@code
* "posters/2026/cover.png"}). Implementations MUST reject a key that escapes the backend's
* namespace (path traversal) with {@link IllegalArgumentException}. Content is passed and returned
* as raw bytes; this port intentionally exposes no streaming/presigned-URL surface — a fork adds
* those when a concrete feature needs them.
*
* @deprecated use the semantic object publication/read/retirement ports; do not adapt them back to
* raw keys
*/
@Deprecated(forRemoval = true)
public interface ObjectStoragePort {
/**
@@ -4,15 +4,19 @@ import java.net.URI;
import java.util.Objects;
/**
* Immutable receipt for a blob stored through {@link ObjectStoragePort}. Framework-neutral value
* object (no Spring / AWS types) so the application layer stays decoupled from the storage backend.
* Immutable legacy receipt for a blob stored through {@link ObjectStoragePort}. It exposes a raw
* caller key and backend locator and therefore must remain confined to the separately activated
* compatibility namespace until API/data migration and zero-usage evidence permit removal. New code
* uses opaque semantic references and must not translate them into this lossy shape.
*
* @param key the object key the blob was stored under (backend-relative, never null/blank)
* @param size the stored content length in bytes (never negative)
* @param contentType the MIME type the blob was stored with (never null/blank)
* @param location a backend-specific locator — a {@code file://} URI for the filesystem backend, an
* {@code s3://bucket/key} URI for the S3/MinIO backend (never null)
* @deprecated use semantic receipts under {@code dev.caskeleton.application.objectstorage.model}
*/
@Deprecated(forRemoval = true)
public record StoredObject(String key, long size, String contentType, URI location) {
public StoredObject {
@@ -0,0 +1,39 @@
package dev.caskeleton.application.storage.migration;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import java.time.Instant;
/** Verified, expiry-bounded two-approver authorization for one exact adoption manifest. */
@Deprecated(forRemoval = true)
public record LegacyObjectAdoptionApproval(
ObjectOperationKey operationKey,
String manifestSha256,
String legacyNamespaceDigest,
ObjectDestinationId targetDestination,
String targetNamespaceDigest,
Instant notBefore,
Instant expiresAt,
String nonce,
String approvalDigest) {
public LegacyObjectAdoptionApproval {
if (operationKey == null
|| !hex64(manifestSha256)
|| !hex64(legacyNamespaceDigest)
|| targetDestination == null
|| !hex64(targetNamespaceDigest)
|| notBefore == null
|| expiresAt == null
|| !expiresAt.isAfter(notBefore)
|| nonce == null
|| !nonce.matches("[A-Za-z0-9_-]{16,128}")
|| !hex64(approvalDigest)) {
throw new IllegalArgumentException("legacy adoption approval is invalid");
}
}
private static boolean hex64(String value) {
return value != null && value.matches("[0-9a-f]{64}");
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.storage.migration;
/** Verifies a detached canonical approval before any adoption mutation. */
@Deprecated(forRemoval = true)
public interface LegacyObjectAdoptionApprovalVerifierPort {
LegacyObjectAdoptionApproval verify(
byte[] canonicalApprovalDocument, LegacyObjectAdoptionRequest expectedRequest);
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.storage.migration;
/** Deprecated administrative migration seam; unavailable to normal business composition. */
@Deprecated(forRemoval = true)
public interface LegacyObjectAdoptionPort {
LegacyObjectAdoptionReceipt adopt(LegacyObjectAdoptionRequest request);
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.storage.migration;
import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey;
import dev.caskeleton.application.objectstorage.identity.ObjectReference;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectMediaType;
import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome;
import java.time.Instant;
/** Locator-free evidence from a report-only inspection or reviewed adoption apply. */
@Deprecated(forRemoval = true)
public record LegacyObjectAdoptionReceipt(
ObjectOperationKey operationKey,
ObjectContentIdentity contentIdentity,
ObjectMediaType mediaType,
ObjectVersionToken inspectedLegacyVersion,
ObjectReference adoptedReference,
ObjectVersionToken adoptedVersion,
ObjectMutationOutcome outcome,
Instant recordedAt) {
public LegacyObjectAdoptionReceipt {
if (operationKey == null
|| contentIdentity == null
|| mediaType == null
|| inspectedLegacyVersion == null
|| outcome == null
|| recordedAt == null
|| (adoptedReference == null) != (adoptedVersion == null)) {
throw new IllegalArgumentException("legacy adoption receipt is invalid");
}
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.storage.migration;
import dev.caskeleton.application.objectstorage.request.ObjectPublishRequest;
/** Exact report/apply request for one reviewed legacy locator. */
@Deprecated(forRemoval = true)
public record LegacyObjectAdoptionRequest(
LegacyObjectLocator locator,
ObjectPublishRequest publicationRequest,
String manifestSha256,
String legacyNamespaceDigest,
String targetNamespaceDigest,
Mode mode,
LegacyObjectAdoptionApproval approval) {
public LegacyObjectAdoptionRequest {
if (locator == null
|| publicationRequest == null
|| !hex64(manifestSha256)
|| !hex64(legacyNamespaceDigest)
|| !hex64(targetNamespaceDigest)
|| mode == null
|| (mode == Mode.REPORT_ONLY && approval != null)) {
throw new IllegalArgumentException("legacy object adoption request is invalid");
}
}
public enum Mode {
REPORT_ONLY,
APPLY
}
private static boolean hex64(String value) {
return value != null && value.matches("[0-9a-f]{64}");
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.storage.migration;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Deprecated administrative raw-locator exception used only by the isolated adoption workflow.
*
* <p>The value is deliberately redacted from {@link #toString()} and exception messages.
*/
@Deprecated(forRemoval = true)
public final class LegacyObjectLocator {
private static final int MAXIMUM_UTF8_BYTES = 1024;
private final String value;
private LegacyObjectLocator(String value) {
this.value = value;
}
public static LegacyObjectLocator of(String value) {
if (value == null
|| value.isBlank()
|| value.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_UTF8_BYTES
|| value.codePoints().anyMatch(LegacyObjectLocator::isControl)) {
throw new IllegalArgumentException("legacy object locator is invalid");
}
return new LegacyObjectLocator(value);
}
/** Available only to the named migration adapter/use case; never log or persist this value. */
public String migrationValue() {
return value;
}
@Override
public String toString() {
return "LegacyObjectLocator[redacted]";
}
@Override
public boolean equals(Object other) {
return other instanceof LegacyObjectLocator locator && value.equals(locator.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
private static boolean isControl(int codePoint) {
return Character.isISOControl(codePoint);
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.application.objectstorage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.objectstorage.content.CancellationView;
import dev.caskeleton.application.objectstorage.content.ObjectChunkReadException;
import dev.caskeleton.application.objectstorage.content.ObjectChunkSink;
import dev.caskeleton.application.objectstorage.content.ObjectChunkSource;
import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext;
import dev.caskeleton.application.objectstorage.content.ObjectContentReadContext;
import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken;
import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity;
import dev.caskeleton.application.objectstorage.model.ObjectReadRange;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class ObjectContentContractTest {
private static final String ROUTE = "0123456789ab";
private static final String OBJECT = "0123456789abcdefghjkmnpqrs";
@Test
void boundedSinkValidatesRangesChunkLimitAndCallbackLifetime() throws Exception {
ObjectContentProductionContext context =
ObjectContentProductionContext.open(
CallBudget.after(100, Duration.ofSeconds(1)), CancellationView.never(), 4);
AtomicInteger delivered = new AtomicInteger();
ObjectChunkSink sink =
ObjectChunkSink.scoped(context, (bytes, offset, length) -> delivered.addAndGet(length));
sink.write(new byte[] {1, 2, 3, 4}, 1, 3);
assertThat(delivered).hasValue(3);
assertThatThrownBy(() -> sink.write(new byte[5], 0, 5))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> sink.write(new byte[2], 1, 2))
.isInstanceOf(IndexOutOfBoundsException.class);
context.invalidate();
assertThatThrownBy(() -> sink.write(new byte[] {1}, 0, 1))
.isInstanceOf(IllegalStateException.class);
}
@Test
void boundedSourceUsesMinusOneForEofAndRejectsRepeatedZeroProgress() throws Exception {
ObjectContentReadContext context = readContext();
ObjectChunkSource source =
ObjectChunkSource.scoped(context, (destination, offset, length) -> 0);
byte[] destination = new byte[4];
for (int i = 0; i < ObjectChunkSource.MAXIMUM_ZERO_PROGRESS_READS; i++) {
assertThat(source.read(destination, 0, 4)).isZero();
}
assertThatThrownBy(() -> source.read(destination, 0, 4))
.isInstanceOf(ObjectChunkReadException.class);
ObjectChunkSource eof = ObjectChunkSource.scoped(readContext(), (bytes, offset, length) -> -1);
assertThat(eof.read(destination, 0, 4)).isEqualTo(ObjectChunkSource.EOF);
assertThat(eof.read(destination, 0, 0)).isZero();
}
@Test
void boundedSourceRejectsInvalidCountsAndCannotOutliveItsContext() throws Exception {
ObjectContentReadContext context = readContext();
ObjectChunkSource tooLarge = ObjectChunkSource.scoped(context, (bytes, offset, length) -> 5);
assertThatThrownBy(() -> tooLarge.read(new byte[4], 0, 4))
.isInstanceOf(ObjectChunkReadException.class);
assertThatThrownBy(() -> tooLarge.read(new byte[4], -1, 1))
.isInstanceOf(IndexOutOfBoundsException.class);
context.invalidate();
assertThatThrownBy(() -> tooLarge.read(new byte[4], 0, 1))
.isInstanceOf(IllegalStateException.class);
}
@Test
void contextsCarryOnlyMonotonicBudgetCancellationAndBoundedReadFacts() {
AtomicInteger checks = new AtomicInteger();
CancellationView cancellation = () -> checks.incrementAndGet() > 1;
CallBudget budget = CallBudget.after(100, Duration.ofSeconds(1));
ObjectContentProductionContext production =
ObjectContentProductionContext.open(budget, cancellation, 8192);
ObjectContentReadContext read = readContext();
assertThat(production.budget()).isEqualTo(budget);
assertThat(production.maximumChunkBytes()).isEqualTo(8192);
assertThat(production.cancellation().isCancelled()).isFalse();
assertThat(production.cancellation().isCancelled()).isTrue();
assertThat(read.deliveredRange()).isEqualTo(ObjectReadRange.of(4, 8));
assertThat(read.maximumChunkBytes()).isEqualTo(4);
assertThat(read.exactVersion().canonicalText()).startsWith("osv1.");
}
private static ObjectContentReadContext readContext() {
return ObjectContentReadContext.open(
CallBudget.after(100, Duration.ofSeconds(1)),
CancellationView.never(),
4,
ObjectContentIdentity.sha256(16, new byte[32]),
ObjectVersionToken.parse("osv1." + ROUTE + "." + OBJECT + ".6678c6821f"),
ObjectReadRange.of(4, 8));
}
}
@@ -0,0 +1,174 @@
package dev.caskeleton.application.objectstorage;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId;
import dev.caskeleton.application.objectstorage.model.DirectDownloadGrant;
import dev.caskeleton.application.objectstorage.model.DirectUploadGrant;
import dev.caskeleton.application.objectstorage.model.PartUploadGrant;
import dev.caskeleton.application.storage.ObjectStoragePort;
import dev.caskeleton.application.storage.StoredObject;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class ObjectStorageArchitectureContractTest {
private static final String CONTRACT_PACKAGE = "dev.caskeleton.application.objectstorage";
private static final Set<Class<?>> URI_GRANTS =
Set.of(DirectUploadGrant.class, DirectDownloadGrant.class, PartUploadGrant.class);
private static final List<String> FORBIDDEN_TYPE_NAMES =
List.of(
"org.springframework.",
"software.amazon.awssdk.",
"jakarta.servlet.",
"jakarta.persistence.",
"javax.persistence.",
"org.hibernate.",
"org.slf4j.",
"dev.caskeleton.adapter.",
"dev.caskeleton.bootstrap.",
"java.nio.file.Path",
"java.io.File");
@Test
void semanticContractIsFrameworkProviderTransportAndPersistentLocatorFree() throws Exception {
for (Class<?> contract : topLevelClassesUnder(CONTRACT_PACKAGE)) {
assertElementTypesArePure(contract, contract);
for (Field field : contract.getDeclaredFields()) {
assertElementTypesArePure(contract, field);
assertTypeIsPure(contract, field.getGenericType());
}
for (Constructor<?> constructor : contract.getDeclaredConstructors()) {
assertElementTypesArePure(contract, constructor);
for (Type parameter : constructor.getGenericParameterTypes()) {
assertTypeIsPure(contract, parameter);
}
}
for (Method method : contract.getDeclaredMethods()) {
assertElementTypesArePure(contract, method);
assertTypeIsPure(contract, method.getGenericReturnType());
for (Type parameter : method.getGenericParameterTypes()) {
assertTypeIsPure(contract, parameter);
}
for (Type exception : method.getGenericExceptionTypes()) {
assertTypeIsPure(contract, exception);
}
}
for (RecordComponent component : recordComponents(contract)) {
assertElementTypesArePure(contract, component);
assertTypeIsPure(contract, component.getGenericType());
}
}
}
@Test
void uriAppearsOnlyOnExplicitTransientGrantValues() throws Exception {
for (Class<?> contract : topLevelClassesUnder(CONTRACT_PACKAGE)) {
boolean exposesUri =
ArraysSupport.allTypes(contract).stream()
.map(Type::getTypeName)
.anyMatch(name -> name.equals(URI.class.getName()));
if (exposesUri) {
assertThat(URI_GRANTS).as(contract.getName()).contains(contract);
}
}
}
@Test
void legacyBlobPortAndReceiptAreExplicitRemovalBoundaries() {
Deprecated port = ObjectStoragePort.class.getAnnotation(Deprecated.class);
Deprecated receipt = StoredObject.class.getAnnotation(Deprecated.class);
assertThat(port).isNotNull();
assertThat(port.forRemoval()).isTrue();
assertThat(receipt).isNotNull();
assertThat(receipt.forRemoval()).isTrue();
}
private static void assertElementTypesArePure(Class<?> owner, AnnotatedElement element) {
for (Annotation annotation : element.getAnnotations()) {
assertTypeNameIsPure(owner, annotation.annotationType().getName());
}
}
private static void assertTypeIsPure(Class<?> owner, Type type) {
String typeName = type.getTypeName();
assertTypeNameIsPure(owner, typeName);
if (typeName.contains(URI.class.getName())) {
assertThat(URI_GRANTS).as(owner.getName()).contains(owner);
}
}
private static void assertTypeNameIsPure(Class<?> owner, String typeName) {
assertThat(FORBIDDEN_TYPE_NAMES)
.as(owner.getName() + " -> " + typeName)
.noneMatch(typeName::contains);
}
private static RecordComponent[] recordComponents(Class<?> type) {
RecordComponent[] components = type.getRecordComponents();
return components == null ? new RecordComponent[0] : components;
}
private static List<Class<?>> topLevelClassesUnder(String packageName)
throws IOException, URISyntaxException, ClassNotFoundException {
String packagePath = packageName.replace('.', '/');
Path classesRoot =
Path.of(
ObjectDestinationId.class.getProtectionDomain().getCodeSource().getLocation().toURI());
Path root = classesRoot.resolve(packagePath);
List<Class<?>> classes = new ArrayList<>();
try (var files = Files.walk(root)) {
for (Path classFile :
files
.filter(path -> path.toString().endsWith(".class"))
.filter(path -> !path.getFileName().toString().contains("$"))
.toList()) {
String relative =
root.relativize(classFile).toString().replace(java.io.File.separator, ".");
String className =
packageName + "." + relative.substring(0, relative.length() - ".class".length());
classes.add(Class.forName(className));
}
}
assertThat(classes).isNotEmpty();
return classes;
}
private static final class ArraysSupport {
private ArraysSupport() {}
static List<Type> allTypes(Class<?> type) {
List<Type> types = new ArrayList<>();
for (Field field : type.getDeclaredFields()) {
types.add(field.getGenericType());
}
for (Constructor<?> constructor : type.getDeclaredConstructors()) {
types.addAll(List.of(constructor.getGenericParameterTypes()));
}
for (Method method : type.getDeclaredMethods()) {
types.add(method.getGenericReturnType());
types.addAll(List.of(method.getGenericParameterTypes()));
}
for (RecordComponent component : recordComponents(type)) {
types.add(component.getGenericType());
}
return types;
}
}
}

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