merge: integrate object storage production capability
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+56
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+91
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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");
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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");
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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]";
|
||||
}
|
||||
}
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+61
@@ -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]";
|
||||
}
|
||||
}
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+24
@@ -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())));
|
||||
}
|
||||
}
|
||||
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.objectstorage.model;
|
||||
|
||||
/** Requested logical integrity verification for a read. */
|
||||
public enum ObjectDigestVerification {
|
||||
NONE,
|
||||
FULL_CONTENT
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+73
@@ -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]";
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+30
@@ -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");
|
||||
}
|
||||
}
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
+37
@@ -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]";
|
||||
}
|
||||
}
|
||||
+34
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+31
@@ -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");
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+32
@@ -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");
|
||||
}
|
||||
}
|
||||
+11
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
+39
@@ -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}");
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
+34
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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}");
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+105
@@ -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));
|
||||
}
|
||||
}
|
||||
+174
@@ -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
Reference in New Issue
Block a user