chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both this worktree and the main checkout before this session began: the initial HTTP Client platform implementation (previously untracked), the redis-lab removal, and the JPA / object-storage / notification integration work. Kept separate from this session's HTTP Client review response, which lands in the following commit, so the two bodies of work stay reviewable apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
@@ -1,5 +1,7 @@
|
||||
// Framework-free application use-case contract. Runtime dependencies are project-only;
|
||||
// composition and diagnostic rendering belong to adapters/bootstrap.
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':shared-contract')
|
||||
|
||||
@@ -7,28 +9,19 @@ dependencies {
|
||||
testImplementation 'net.jqwik:jqwik:1.9.1'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
redisPolicyContractTest {
|
||||
java.srcDir 'src/redisPolicyContractTest/java'
|
||||
resources.srcDir 'src/redisPolicyContractTest/resources'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += sourceSets.main.output
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
redisPolicyContractTestImplementation.extendsFrom testImplementation
|
||||
redisPolicyContractTestCompileOnly.extendsFrom testCompileOnly
|
||||
redisPolicyContractTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
}
|
||||
|
||||
tasks.register('redisPolicyContractTest', Test) {
|
||||
group = 'redis verification'
|
||||
description = 'Runs provider-neutral Redis policy contracts without a Redis/framework dependency.'
|
||||
testClassesDirs = sourceSets.redisPolicyContractTest.output.classesDirs
|
||||
classpath = sourceSets.redisPolicyContractTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
def messagingApplicationContractQualification = registerStrictQualificationTest(
|
||||
name: 'messagingApplicationContractQualificationTest',
|
||||
sourceSet: sourceSets.test,
|
||||
requiredClasses: [
|
||||
'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest',
|
||||
'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest',
|
||||
'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest'
|
||||
],
|
||||
junitXmlOutput: rootProject.layout.buildDirectory.dir(
|
||||
'test-results/messaging-evidence/application'),
|
||||
binaryResultsOutput: rootProject.layout.buildDirectory.dir(
|
||||
'test-results/messaging-evidence-binary/application'),
|
||||
description: 'Runs exact Messaging application contract qualification tests.')
|
||||
messagingApplicationContractQualification.configure {
|
||||
dependsOn ':prepareMessagingContractEvidence'
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ org.junit.platform:junit-platform-engine:6.0.1=redisPolicyContractTestRuntimeCla
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import java.util.List;
|
||||
* Outbound port for exporting tabular data as a delimited file — the "file server" boundary (a
|
||||
* stand-in for an NFS mount, shared file server, or SFTP drop). The application layer hands over
|
||||
* plain strings, so use cases stay decoupled from the export format and the destination filesystem.
|
||||
* The adapter is selected by configuration ({@code ca-skeleton.fileserver}); see the {@code
|
||||
* The adapter is selected by configuration ({@code app.file-export}); see the {@code
|
||||
* adapter:outbound:fileserver} README for the on-disk layout and the CSV-escaping contract.
|
||||
*
|
||||
* <p>The contract is deliberately domain-neutral: no framework or domain type crosses it. A caller
|
||||
|
||||
+5
@@ -35,6 +35,11 @@ public record ExportSchema(String schemaId, int version, List<Column> columns) {
|
||||
|
||||
public Column {
|
||||
name = FilePublicationValues.requireOpaque("column name", name, 128);
|
||||
// The formula policy governs cell values; the header row had no policy at all, so a column
|
||||
// named "=cmd|'/c calc'!A1" was written verbatim and executed by the spreadsheet that opened
|
||||
// the export. Rejecting rather than mitigating is deliberate: prefixing a header with a quote
|
||||
// would silently rename the column and break whatever parses it downstream.
|
||||
FilePublicationValues.requireNotFormulaShaped("column name", name);
|
||||
Objects.requireNonNull(cellType, "cellType must be non-null");
|
||||
Objects.requireNonNull(formulaPolicy, "formulaPolicy must be non-null");
|
||||
if (maximumUtf8Bytes < 1) {
|
||||
|
||||
+25
@@ -17,4 +17,29 @@ final class FilePublicationValues {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a value a spreadsheet would evaluate as a formula.
|
||||
*
|
||||
* <p>A CSV is data to the producer and a program to Excel, LibreOffice and Sheets: a field
|
||||
* starting with {@code =}, {@code +}, {@code -}, {@code @}, a tab or a carriage return is
|
||||
* evaluated on open, and {@code =cmd|'/c calc'!A1} is a remote-code-execution vector against
|
||||
* whoever opens the report. Escaping belongs to cell values, where a leading quote is invisible;
|
||||
* for identifiers such as a column name there is nothing to escape into, so it is refused.
|
||||
*/
|
||||
static void requireNotFormulaShaped(String field, String value) {
|
||||
if (value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
char first = value.charAt(0);
|
||||
if (first == '='
|
||||
|| first == '+'
|
||||
|| first == '-'
|
||||
|| first == '@'
|
||||
|| first == '\t'
|
||||
|| first == '\r') {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must not start with a spreadsheet formula character (= + - @ tab CR)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
/**
|
||||
* Durable sink for administrative actions.
|
||||
*
|
||||
* <p>Every mutating admin operation writes here, including the ones that failed: a rejected
|
||||
* force-delete attempt is exactly the event a reviewer most wants to see.
|
||||
*/
|
||||
public interface AdminAuditPort {
|
||||
|
||||
void record(AdminAuditRecord record);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One administrative action, recorded for review.
|
||||
*
|
||||
* <p>{@code actorFingerprint} is a stable pseudonym rather than a principal id, and no path, mount,
|
||||
* filename, or raw scanner response ever appears. An audit trail that leaked those would become a
|
||||
* second copy of exactly the data the rest of the design refuses to disclose.
|
||||
*/
|
||||
public record AdminAuditRecord(
|
||||
String operation,
|
||||
String reasonCode,
|
||||
String actorFingerprint,
|
||||
String traceId,
|
||||
boolean succeeded,
|
||||
String subjectId,
|
||||
Instant occurredAt) {
|
||||
|
||||
public AdminAuditRecord {
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
Objects.requireNonNull(actorFingerprint, "actorFingerprint");
|
||||
Objects.requireNonNull(traceId, "traceId");
|
||||
Objects.requireNonNull(subjectId, "subjectId");
|
||||
Objects.requireNonNull(occurredAt, "occurredAt");
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
|
||||
/**
|
||||
* Answers whether any record still claims a physical object.
|
||||
*
|
||||
* <p>An orphan scan walks storage and must decide, per object, whether deleting it would destroy
|
||||
* live data. That decision belongs to the metadata side, and it is deliberately the only question
|
||||
* the scan is allowed to ask: a scan that could read records would be tempted to reconstruct them.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ContentReferenceLedger {
|
||||
|
||||
/** True when a file record names {@code key}, in any state. */
|
||||
boolean isReferenced(ContentKey key);
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileNotFoundException;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileNotReadyException;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
|
||||
import dev.caskeleton.application.fileserver.api.metadata.FileMetadataStore;
|
||||
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
|
||||
import dev.caskeleton.application.fileserver.api.metadata.FileRecordMutation;
|
||||
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
|
||||
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionStore;
|
||||
import dev.caskeleton.application.fileserver.api.security.FileAccessPolicy;
|
||||
import dev.caskeleton.application.fileserver.api.security.FileOperation;
|
||||
import dev.caskeleton.application.fileserver.api.security.RequestContext;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupQueue;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupRequest;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupService;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupType;
|
||||
import dev.caskeleton.application.fileserver.observability.SafeFileFingerprint;
|
||||
import dev.caskeleton.application.fileserver.upload.FileView;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The management plane, with its own authority and its own audit trail.
|
||||
*
|
||||
* <p>Two rules shape everything here. A reconcile is a dry run unless the caller explicitly opted
|
||||
* out <em>and</em> echoed the fingerprints it was shown, so a stale scan can never turn into a mass
|
||||
* delete. And every mutating action is audited whether it succeeded or not, because a refused
|
||||
* force-delete is exactly the event a reviewer needs to see.
|
||||
*/
|
||||
public final class DefaultFileserverAdminService implements FileserverAdminService {
|
||||
|
||||
private static final int MAXIMUM_ADMIN_PAGE = 1000;
|
||||
|
||||
private final StorageHealthPort healthPort;
|
||||
private final OrphanScanPort orphanScanPort;
|
||||
private final FileMetadataStore metadataStore;
|
||||
private final UploadSessionStore sessionStore;
|
||||
private final CleanupQueue cleanupQueue;
|
||||
private final CleanupService cleanupService;
|
||||
private final FileAccessPolicy accessPolicy;
|
||||
private final AdminAuditPort auditPort;
|
||||
private final SafeFileFingerprint fingerprint;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public DefaultFileserverAdminService(
|
||||
StorageHealthPort healthPort,
|
||||
OrphanScanPort orphanScanPort,
|
||||
FileMetadataStore metadataStore,
|
||||
UploadSessionStore sessionStore,
|
||||
CleanupQueue cleanupQueue,
|
||||
CleanupService cleanupService,
|
||||
FileAccessPolicy accessPolicy,
|
||||
AdminAuditPort auditPort,
|
||||
SafeFileFingerprint fingerprint,
|
||||
TransactionPort transactions,
|
||||
Clock clock) {
|
||||
this.healthPort = healthPort;
|
||||
this.orphanScanPort = orphanScanPort;
|
||||
this.metadataStore = metadataStore;
|
||||
this.sessionStore = sessionStore;
|
||||
this.cleanupQueue = cleanupQueue;
|
||||
this.cleanupService = cleanupService;
|
||||
this.accessPolicy = accessPolicy;
|
||||
this.auditPort = auditPort;
|
||||
this.fingerprint = fingerprint;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StorageHealthReport storageHealth(RequestContext context) {
|
||||
authorizeRead(context);
|
||||
return healthPort.health();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RuntimeCapabilityReport capabilities(RequestContext context) {
|
||||
authorizeRead(context);
|
||||
return healthPort.capabilities();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OrphanObject> orphans(int limit, RequestContext context) {
|
||||
authorizeRead(context);
|
||||
return orphanScanPort.scan(boundedLimit(limit));
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrphanReconcileReport reconcileOrphans(
|
||||
OrphanReconcileCommand command, RequestContext context) {
|
||||
accessPolicy.authorize(FileOperation.ADMIN_FORCE_DELETE, context.subject(), Optional.empty());
|
||||
List<OrphanObject> candidates = orphanScanPort.scan(boundedLimit(command.limit()));
|
||||
if (command.dryRun()) {
|
||||
audit("orphans:reconcile", command.reasonCode(), context, true, "dry-run");
|
||||
return new OrphanReconcileReport(true, candidates, 0, 0, 0);
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
int mismatched = 0;
|
||||
long reclaimed = 0;
|
||||
for (OrphanObject candidate : candidates) {
|
||||
if (!command.expectedFingerprints().contains(candidate.fingerprint())) {
|
||||
mismatched++;
|
||||
continue;
|
||||
}
|
||||
if (reclaimed + candidate.sizeBytes() > command.maxBytes()) {
|
||||
break;
|
||||
}
|
||||
if (orphanScanPort.deleteIfFingerprintMatches(
|
||||
candidate.contentKey(), candidate.fingerprint())) {
|
||||
// Retirement moved the object to quarantine; the durable record of that intent is queued
|
||||
// immediately after. Without it a node that dies here leaves an object nobody is looking
|
||||
// for, in an area nothing scans.
|
||||
transactions.inWrite(
|
||||
() -> cleanupQueue.enqueue(CleanupRequest.forOrphan(candidate.contentKey())));
|
||||
deleted++;
|
||||
reclaimed += candidate.sizeBytes();
|
||||
} else {
|
||||
mismatched++;
|
||||
}
|
||||
}
|
||||
audit("orphans:reconcile", command.reasonCode(), context, true, "apply");
|
||||
return new OrphanReconcileReport(false, candidates, deleted, mismatched, reclaimed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileView reverify(FileId fileId, RequestContext context) {
|
||||
accessPolicy.authorize(FileOperation.ADMIN_REVERIFY, context.subject(), Optional.empty());
|
||||
FileRecord record = requireRecord(fileId);
|
||||
if (record.state() != FileState.QUARANTINED) {
|
||||
audit("files:reverify", "REVERIFY_REJECTED", context, false, fileId.canonicalText());
|
||||
throw new FileNotReadyException(
|
||||
"only a quarantined file can be re-verified",
|
||||
FileserverFailureContext.forFileState(
|
||||
FileserverErrorCode.FILE_NOT_READY, fileId, record.state(), false));
|
||||
}
|
||||
FileRecord verifying =
|
||||
transactions.inWrite(
|
||||
() ->
|
||||
metadataStore.transition(
|
||||
record.fileId(),
|
||||
record.version(),
|
||||
FileState.QUARANTINED,
|
||||
FileState.VERIFYING,
|
||||
FileRecordMutation.none()));
|
||||
audit("files:reverify", "OPERATOR_REVERIFY", context, true, fileId.canonicalText());
|
||||
return FileView.of(verifying.toDescriptor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void forceDelete(ForceDeleteCommand command, RequestContext context) {
|
||||
accessPolicy.authorize(FileOperation.ADMIN_FORCE_DELETE, context.subject(), Optional.empty());
|
||||
FileRecord record = requireRecord(command.fileId());
|
||||
// An operator force-delete must not be able to leave a file unreachable with nothing queued to
|
||||
// reclaim its bytes, so retiring the record and queueing the content commit together.
|
||||
transactions.inWrite(
|
||||
() -> {
|
||||
FileRecord deleting = metadataStore.markDeleting(record.fileId(), record.version());
|
||||
deleting
|
||||
.contentKey()
|
||||
.ifPresent(
|
||||
key ->
|
||||
cleanupQueue.enqueue(
|
||||
CleanupRequest.forContent(
|
||||
CleanupType.DELETED_READY_CONTENT, deleting.fileId(), key)));
|
||||
});
|
||||
audit(
|
||||
"files:force-delete",
|
||||
command.reasonCode(),
|
||||
context,
|
||||
true,
|
||||
command.fileId().canonicalText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IncompleteUploadView> incompleteUploads(int limit, RequestContext context) {
|
||||
authorizeRead(context);
|
||||
List<IncompleteUploadView> views = new ArrayList<>();
|
||||
for (UploadSession session : sessionStore.findExpired(clock.instant(), boundedLimit(limit))) {
|
||||
views.add(
|
||||
new IncompleteUploadView(
|
||||
session.uploadId(),
|
||||
session.fileId(),
|
||||
session.committedOffset(),
|
||||
session.expiresAt(),
|
||||
session.leaseOwner(),
|
||||
session.leaseUntil()));
|
||||
}
|
||||
return List.copyOf(views);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CleanupBatchResult cleanupUploads(int maxItems, long maxBytes, RequestContext context) {
|
||||
accessPolicy.authorize(FileOperation.ADMIN_FORCE_DELETE, context.subject(), Optional.empty());
|
||||
CleanupBatchResult result = cleanupService.runBatch(boundedLimit(maxItems), maxBytes);
|
||||
audit("uploads:cleanup", "OPERATOR_CLEANUP", context, true, "batch");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void authorizeRead(RequestContext context) {
|
||||
accessPolicy.authorize(FileOperation.READ_METADATA, context.subject(), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps every admin query.
|
||||
*
|
||||
* <p>An unbounded admin listing is a self-inflicted outage: it walks production storage or the
|
||||
* whole metadata table on an operator's keystroke.
|
||||
*/
|
||||
private static int boundedLimit(int requested) {
|
||||
return Math.max(1, Math.min(requested, MAXIMUM_ADMIN_PAGE));
|
||||
}
|
||||
|
||||
private void audit(
|
||||
String operation,
|
||||
String reasonCode,
|
||||
RequestContext context,
|
||||
boolean succeeded,
|
||||
String subjectId) {
|
||||
auditPort.record(
|
||||
new AdminAuditRecord(
|
||||
operation,
|
||||
reasonCode,
|
||||
actorFingerprint(context),
|
||||
context.traceId(),
|
||||
succeeded,
|
||||
subjectFingerprint(subjectId),
|
||||
clock.instant()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable pseudonym for the acting operator.
|
||||
*
|
||||
* <p>An audit trail needs to correlate actions by the same actor without becoming a second
|
||||
* directory of who works here, so the principal is reduced to a fingerprint.
|
||||
*
|
||||
* <p>A keyed HMAC, not {@code hashCode()}. A 32-bit unkeyed hash over an enumerable identifier
|
||||
* space is reversible with a laptop and collides often enough that two operators can share a
|
||||
* pseudonym — which is worse than no pseudonym, because the trail then reads as if one person did
|
||||
* both things.
|
||||
*/
|
||||
private String actorFingerprint(RequestContext context) {
|
||||
return fingerprint.of(context.subject().principalId());
|
||||
}
|
||||
|
||||
/** The same reduction for the object of the action; a raw file id is a disclosure too. */
|
||||
private String subjectFingerprint(String subjectId) {
|
||||
return subjectId.isBlank() ? subjectId : fingerprint.of(subjectId);
|
||||
}
|
||||
|
||||
private FileRecord requireRecord(FileId fileId) {
|
||||
return metadataStore
|
||||
.find(fileId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new FileNotFoundException(
|
||||
"file record does not exist",
|
||||
FileserverFailureContext.forFile(
|
||||
FileserverErrorCode.FILE_NOT_FOUND, fileId, false)));
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.security.RequestContext;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
|
||||
import dev.caskeleton.application.fileserver.upload.FileView;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The management-plane surface.
|
||||
*
|
||||
* <p>Every method here is deliberately separate from the public application services: an operator
|
||||
* action is authorized against a different authority, is always audited, and may see counters a
|
||||
* tenant never should. Mixing them into the public services is how an admin capability ends up one
|
||||
* missing check away from being publicly reachable.
|
||||
*/
|
||||
public interface FileserverAdminService {
|
||||
|
||||
StorageHealthReport storageHealth(RequestContext context);
|
||||
|
||||
RuntimeCapabilityReport capabilities(RequestContext context);
|
||||
|
||||
List<OrphanObject> orphans(int limit, RequestContext context);
|
||||
|
||||
OrphanReconcileReport reconcileOrphans(OrphanReconcileCommand command, RequestContext context);
|
||||
|
||||
FileView reverify(FileId fileId, RequestContext context);
|
||||
|
||||
void forceDelete(ForceDeleteCommand command, RequestContext context);
|
||||
|
||||
List<IncompleteUploadView> incompleteUploads(int limit, RequestContext context);
|
||||
|
||||
CleanupBatchResult cleanupUploads(int maxItems, long maxBytes, RequestContext context);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Operator-forced removal of a file, bypassing the normal precondition.
|
||||
*
|
||||
* <p>The reason is mandatory and the caller must hold the second, force-delete-specific authority.
|
||||
* A force delete that needed only the ordinary delete permission would make every operator able to
|
||||
* destroy content the lifecycle rules exist to protect.
|
||||
*/
|
||||
public record ForceDeleteCommand(FileId fileId, String reasonCode) {
|
||||
|
||||
private static final int MINIMUM_REASON_LENGTH = 8;
|
||||
|
||||
public ForceDeleteCommand {
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
if (reasonCode.strip().length() < MINIMUM_REASON_LENGTH) {
|
||||
throw new IllegalArgumentException("force delete requires an explicit reason");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Operator view of an upload that never completed.
|
||||
*
|
||||
* <p>The original filename is deliberately absent: an operator triaging stuck uploads needs the
|
||||
* identity, the offset, and the lease, and a filename is user-supplied content that would put
|
||||
* arbitrary text into an admin console.
|
||||
*/
|
||||
public record IncompleteUploadView(
|
||||
UploadId uploadId,
|
||||
FileId fileId,
|
||||
long committedOffset,
|
||||
Instant expiresAt,
|
||||
Optional<String> leaseOwner,
|
||||
Optional<Instant> leaseUntil) {
|
||||
|
||||
public IncompleteUploadView {
|
||||
Objects.requireNonNull(uploadId, "uploadId");
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(leaseOwner, "leaseOwner");
|
||||
Objects.requireNonNull(leaseUntil, "leaseUntil");
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A physical object with no metadata record pointing at it.
|
||||
*
|
||||
* <p>{@code fingerprint} is what makes an apply safe: the caller must echo the exact fingerprint it
|
||||
* was shown, so an object that changed between the scan and the apply is never deleted.
|
||||
*/
|
||||
public record OrphanObject(
|
||||
ContentKey contentKey, long sizeBytes, Instant observedAt, String fingerprint) {
|
||||
|
||||
public OrphanObject {
|
||||
Objects.requireNonNull(contentKey, "contentKey");
|
||||
Objects.requireNonNull(observedAt, "observedAt");
|
||||
Objects.requireNonNull(fingerprint, "fingerprint");
|
||||
if (sizeBytes < 0) {
|
||||
throw new IllegalArgumentException("sizeBytes must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Request to reconcile orphaned physical objects.
|
||||
*
|
||||
* <p>{@code dryRun} defaults to true at every layer above this record. An apply additionally has to
|
||||
* name the exact fingerprints it intends to remove and a byte budget, so a reconcile can never turn
|
||||
* into an unbounded mass delete driven by a stale scan.
|
||||
*/
|
||||
public record OrphanReconcileCommand(
|
||||
boolean dryRun,
|
||||
int limit,
|
||||
long maxBytes,
|
||||
List<String> expectedFingerprints,
|
||||
String reasonCode) {
|
||||
|
||||
public OrphanReconcileCommand {
|
||||
Objects.requireNonNull(expectedFingerprints, "expectedFingerprints");
|
||||
Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
if (limit < 1 || maxBytes < 1) {
|
||||
throw new IllegalArgumentException("limit and maxBytes must be positive");
|
||||
}
|
||||
if (!dryRun && expectedFingerprints.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an apply must name the fingerprints it intends to remove");
|
||||
}
|
||||
if (reasonCode.isBlank()) {
|
||||
throw new IllegalArgumentException("reasonCode must be non-blank");
|
||||
}
|
||||
expectedFingerprints = List.copyOf(expectedFingerprints);
|
||||
}
|
||||
|
||||
/** Bounded dry run, the default and the only shape a caller can reach without opting in. */
|
||||
public static OrphanReconcileCommand dryRun(int limit) {
|
||||
return new OrphanReconcileCommand(true, limit, Long.MAX_VALUE, List.of(), "ORPHAN_SCAN");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Outcome of one reconcile.
|
||||
*
|
||||
* <p>{@code dryRun} is echoed back deliberately: an operator reading a report must never have to
|
||||
* infer whether it described a plan or an action already taken.
|
||||
*/
|
||||
public record OrphanReconcileReport(
|
||||
boolean dryRun,
|
||||
List<OrphanObject> candidates,
|
||||
int deleted,
|
||||
int skippedFingerprintMismatch,
|
||||
long reclaimedBytes) {
|
||||
|
||||
public OrphanReconcileReport {
|
||||
Objects.requireNonNull(candidates, "candidates");
|
||||
if (deleted < 0 || skippedFingerprintMismatch < 0 || reclaimedBytes < 0) {
|
||||
throw new IllegalArgumentException("counters must not be negative");
|
||||
}
|
||||
candidates = List.copyOf(candidates);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bounded scan for physical objects with no metadata record.
|
||||
*
|
||||
* <p>The scan is always bounded; an unbounded walk of a production content root is itself an
|
||||
* availability incident.
|
||||
*/
|
||||
public interface OrphanScanPort {
|
||||
|
||||
List<OrphanObject> scan(int limit);
|
||||
|
||||
/**
|
||||
* Retires one orphan, guarded by the fingerprint the caller was shown.
|
||||
*
|
||||
* <p>"Retires" rather than "deletes" on purpose. Checking that nothing references an object and
|
||||
* then unlinking it is not atomic against a record committed in between, and that ordering has no
|
||||
* safe variant: whichever step runs first, a live object can be destroyed with nothing left to
|
||||
* restore. The implementation therefore moves the object aside reversibly and re-checks, so a
|
||||
* record that appeared during the move puts the object straight back.
|
||||
*
|
||||
* @return true when the object was retired, false when it was left in place
|
||||
*/
|
||||
boolean deleteIfFingerprintMatches(ContentKey key, String expectedFingerprint);
|
||||
|
||||
/**
|
||||
* Reclaims a retired object for good, once nothing references it.
|
||||
*
|
||||
* <p>Separated from retirement so the destructive step is a second, later decision rather than
|
||||
* part of the same racing sequence.
|
||||
*/
|
||||
boolean purgeQuarantined(ContentKey key);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.content.ContentStoreCapabilities;
|
||||
import dev.caskeleton.application.fileserver.api.content.PublishMode;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What this deployment can actually do, as proven by the startup probe.
|
||||
*
|
||||
* <p>Every flag here came from a real filesystem probe rather than configuration, which is what
|
||||
* makes the endpoint useful for diagnosing a misconfigured mount. No physical path appears.
|
||||
*/
|
||||
public record RuntimeCapabilityReport(
|
||||
String storageType,
|
||||
PublishMode publishMode,
|
||||
ContentStoreCapabilities capabilities,
|
||||
String filesystemProfile) {
|
||||
|
||||
public RuntimeCapabilityReport {
|
||||
Objects.requireNonNull(storageType, "storageType");
|
||||
Objects.requireNonNull(publishMode, "publishMode");
|
||||
Objects.requireNonNull(capabilities, "capabilities");
|
||||
Objects.requireNonNull(filesystemProfile, "filesystemProfile");
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
/**
|
||||
* Live capacity and probe observation.
|
||||
*
|
||||
* <p>Behind this port sits the same startup probe that gated the application's boot, so the admin
|
||||
* answer and the startup decision can never disagree.
|
||||
*/
|
||||
public interface StorageHealthPort {
|
||||
|
||||
StorageHealthReport health();
|
||||
|
||||
RuntimeCapabilityReport capabilities();
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.application.fileserver.admin;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Operator view of storage capacity and probe results.
|
||||
*
|
||||
* <p>It reports proportions and boolean probe outcomes, never the physical root, the mount, or the
|
||||
* device. An operator needs to know whether storage is healthy; disclosing where it lives only adds
|
||||
* a target.
|
||||
*/
|
||||
public record StorageHealthReport(
|
||||
long totalBytes,
|
||||
long usableBytes,
|
||||
double usedFraction,
|
||||
boolean writable,
|
||||
boolean atomicPublishProven,
|
||||
String filesystemProfile,
|
||||
List<String> probeWarnings) {
|
||||
|
||||
public StorageHealthReport {
|
||||
Objects.requireNonNull(filesystemProfile, "filesystemProfile");
|
||||
Objects.requireNonNull(probeWarnings, "probeWarnings");
|
||||
if (totalBytes < 0 || usableBytes < 0) {
|
||||
throw new IllegalArgumentException("byte counters must not be negative");
|
||||
}
|
||||
probeWarnings = List.copyOf(probeWarnings);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
/**
|
||||
* Inclusive byte range over a concrete representation.
|
||||
*
|
||||
* <p>HTTP suffix and open-ended ranges are normalized into this value object by the transport range
|
||||
* resolver, using the current representation length. The core never sees an unresolved range.
|
||||
*/
|
||||
public record ByteRange(long startInclusive, long endInclusive) {
|
||||
|
||||
public ByteRange {
|
||||
if (startInclusive < 0 || endInclusive < startInclusive) {
|
||||
throw new IllegalArgumentException("invalid byte range");
|
||||
}
|
||||
}
|
||||
|
||||
public static ByteRange of(long startInclusive, long endInclusive) {
|
||||
return new ByteRange(startInclusive, endInclusive);
|
||||
}
|
||||
|
||||
/** Full-representation range for a non-empty representation. */
|
||||
public static ByteRange entire(long representationLength) {
|
||||
if (representationLength <= 0) {
|
||||
throw new IllegalArgumentException("representation length must be positive");
|
||||
}
|
||||
return new ByteRange(0, representationLength - 1);
|
||||
}
|
||||
|
||||
public long length() {
|
||||
return Math.addExact(Math.subtractExact(endInclusive, startInclusive), 1);
|
||||
}
|
||||
|
||||
public boolean overlaps(ByteRange other) {
|
||||
return startInclusive <= other.endInclusive && other.startInclusive <= endInclusive;
|
||||
}
|
||||
|
||||
/** True when this range and {@code other} touch or overlap and can be merged into one range. */
|
||||
public boolean isAdjacentOrOverlapping(ByteRange other) {
|
||||
return overlaps(other)
|
||||
|| endInclusive + 1 == other.startInclusive
|
||||
|| other.endInclusive + 1 == startInclusive;
|
||||
}
|
||||
|
||||
public ByteRange merge(ByteRange other) {
|
||||
return new ByteRange(
|
||||
Math.min(startInclusive, other.startInclusive), Math.max(endInclusive, other.endInclusive));
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Server-generated physical content key.
|
||||
*
|
||||
* <p>The key is never part of the public HTTP contract and never derives from a client filename.
|
||||
* The character class deliberately excludes {@code .}, so no traversal or extension-shaped segment
|
||||
* can survive validation.
|
||||
*/
|
||||
public record ContentKey(String value) {
|
||||
|
||||
private static final Pattern CANONICAL = Pattern.compile("[a-z0-9/_-]{16,200}");
|
||||
|
||||
public ContentKey {
|
||||
if (value == null || !CANONICAL.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid content key");
|
||||
}
|
||||
}
|
||||
|
||||
public static ContentKey of(String value) {
|
||||
return new ContentKey(value);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Exact transition table from the Fileserver platform design.
|
||||
*
|
||||
* <p>Recovery transitions out of {@link FileState#FAILED} are structurally allowed here; the
|
||||
* recovery policy separately decides whether the stored {@code lastErrorCode} permits them.
|
||||
*/
|
||||
public final class DefaultFileStateMachine implements FileStateMachine {
|
||||
|
||||
private static final Map<FileState, Set<FileState>> ALLOWED =
|
||||
Map.ofEntries(
|
||||
Map.entry(FileState.CREATED, Set.of(FileState.UPLOADING)),
|
||||
Map.entry(
|
||||
FileState.UPLOADING,
|
||||
Set.of(FileState.UPLOADED, FileState.FAILED, FileState.EXPIRED, FileState.DELETING)),
|
||||
Map.entry(
|
||||
FileState.UPLOADED,
|
||||
Set.of(FileState.VERIFYING, FileState.FAILED, FileState.DELETING)),
|
||||
Map.entry(
|
||||
FileState.VERIFYING,
|
||||
Set.of(FileState.READY, FileState.QUARANTINED, FileState.REJECTED, FileState.FAILED)),
|
||||
Map.entry(
|
||||
FileState.QUARANTINED,
|
||||
Set.of(FileState.VERIFYING, FileState.READY, FileState.REJECTED, FileState.DELETING)),
|
||||
Map.entry(FileState.READY, Set.of(FileState.DELETING)),
|
||||
Map.entry(FileState.REJECTED, Set.of(FileState.DELETING)),
|
||||
Map.entry(
|
||||
FileState.FAILED,
|
||||
Set.of(
|
||||
FileState.UPLOADING, FileState.VERIFYING, FileState.DELETING, FileState.EXPIRED)),
|
||||
Map.entry(FileState.DELETING, Set.of(FileState.DELETED, FileState.FAILED)),
|
||||
Map.entry(FileState.EXPIRED, Set.of(FileState.DELETING)),
|
||||
Map.entry(FileState.DELETED, Set.of()));
|
||||
|
||||
@Override
|
||||
public boolean canTransition(FileState current, FileState target) {
|
||||
if (current == null || target == null) {
|
||||
return false;
|
||||
}
|
||||
return ALLOWED.getOrDefault(current, Set.of()).contains(target);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requireTransition(FileState current, FileState target) {
|
||||
if (!canTransition(current, target)) {
|
||||
throw new IllegalStateException("illegal file transition: " + current + " -> " + target);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Opaque public identity of a stored file.
|
||||
*
|
||||
* <p>The value is hard to guess but is never treated as a bearer secret: every public operation
|
||||
* still runs the authorization hook. It never encodes a path, a physical key, or an original
|
||||
* filename.
|
||||
*/
|
||||
public record FileId(UUID value) {
|
||||
|
||||
public FileId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
public static FileId of(UUID value) {
|
||||
return new FileId(value);
|
||||
}
|
||||
|
||||
public static FileId parse(String canonicalText) {
|
||||
Objects.requireNonNull(canonicalText, "canonicalText");
|
||||
return new FileId(UUID.fromString(canonicalText));
|
||||
}
|
||||
|
||||
public String canonicalText() {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
/**
|
||||
* Authoritative lifecycle state of a file record.
|
||||
*
|
||||
* <p>Only {@link #READY} exposes readable immutable content. Every other state is excluded from
|
||||
* direct download and from delegated (Nginx) transfer.
|
||||
*/
|
||||
public enum FileState {
|
||||
CREATED,
|
||||
UPLOADING,
|
||||
UPLOADED,
|
||||
VERIFYING,
|
||||
QUARANTINED,
|
||||
READY,
|
||||
REJECTED,
|
||||
FAILED,
|
||||
DELETING,
|
||||
DELETED,
|
||||
EXPIRED;
|
||||
|
||||
/** True when the state permits public download authorization. */
|
||||
public boolean isPubliclyReadable() {
|
||||
return this == READY;
|
||||
}
|
||||
|
||||
/** True when no further lifecycle progress is possible through the public API. */
|
||||
public boolean isTerminal() {
|
||||
return this == DELETED;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
/**
|
||||
* Single authority for allowed file lifecycle transitions.
|
||||
*
|
||||
* <p>Persistence adapters and transport adapters never assign {@link FileState} directly; they ask
|
||||
* this contract first so an illegal transition cannot enter the metadata store.
|
||||
*/
|
||||
public interface FileStateMachine {
|
||||
|
||||
void requireTransition(FileState current, FileState target);
|
||||
|
||||
boolean canTransition(FileState current, FileState target);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Logical storage and ownership namespace.
|
||||
*
|
||||
* <p>A namespace is a metadata grouping only. It is never a directory, a mount, or a bucket name.
|
||||
*/
|
||||
public record StorageNamespace(String value) {
|
||||
|
||||
private static final Pattern CANONICAL = Pattern.compile("[a-z][a-z0-9-]{1,62}");
|
||||
|
||||
public StorageNamespace {
|
||||
if (value == null || !CANONICAL.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid storage namespace");
|
||||
}
|
||||
}
|
||||
|
||||
public static StorageNamespace of(String value) {
|
||||
return new StorageNamespace(value);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.application.fileserver.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Opaque public identity of a resumable upload resource.
|
||||
*
|
||||
* <p>An upload resource has a lifecycle independent of the READY file it eventually produces, so
|
||||
* the two identities are never interchangeable.
|
||||
*/
|
||||
public record UploadId(UUID value) {
|
||||
|
||||
public UploadId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
public static UploadId of(UUID value) {
|
||||
return new UploadId(value);
|
||||
}
|
||||
|
||||
public static UploadId parse(String canonicalText) {
|
||||
Objects.requireNonNull(canonicalText, "canonicalText");
|
||||
return new UploadId(UUID.fromString(canonicalText));
|
||||
}
|
||||
|
||||
public String canonicalText() {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Outcome of one durable append.
|
||||
*
|
||||
* <p>{@code committedOffset} only advances by bytes the store observed reaching the channel, so a
|
||||
* partial write can never inflate the resumable offset.
|
||||
*/
|
||||
public record AppendResult(long committedOffset, long appendedBytes, String sha256) {
|
||||
|
||||
public AppendResult {
|
||||
if (committedOffset < 0 || appendedBytes < 0) {
|
||||
throw new IllegalArgumentException("append offsets must not be negative");
|
||||
}
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ByteRange;
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Flow;
|
||||
|
||||
/**
|
||||
* Non-blocking storage SPI with the same semantics as {@link BlockingContentStore}.
|
||||
*
|
||||
* <p>Streaming uses {@link Flow.Publisher} of {@link ByteBuffer} so the core stays free of Reactor
|
||||
* and Spring buffer types; the WebFlux adapter owns the conversion and the pooled-buffer lifecycle.
|
||||
*/
|
||||
public interface AsyncContentStore {
|
||||
|
||||
CompletionStage<UploadHandle> createUpload(CreateContentCommand command);
|
||||
|
||||
CompletionStage<AppendResult> append(
|
||||
UploadHandle handle, long expectedOffset, Flow.Publisher<ByteBuffer> content);
|
||||
|
||||
CompletionStage<StoredContent> finalizeUpload(
|
||||
UploadHandle handle, FinalizeContentCommand command);
|
||||
|
||||
CompletionStage<ContentMetadata> stat(ContentKey key);
|
||||
|
||||
Flow.Publisher<ByteBuffer> openRead(ContentKey key, ByteRange range);
|
||||
|
||||
CompletionStage<DeleteResult> delete(ContentKey key, DeletePrecondition precondition);
|
||||
|
||||
ContentStoreCapabilities capabilities();
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ByteRange;
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
|
||||
/**
|
||||
* Blocking storage SPI shared by every content store implementation.
|
||||
*
|
||||
* <p>The contract is deliberately expressed as create / append / finalize / stat / openRead /
|
||||
* delete semantics rather than as a mirror of filesystem commands. No signature may name a {@code
|
||||
* Path}, a Spring {@code Resource}, a {@code DataBuffer}, a Reactor type, or a provider SDK type.
|
||||
*/
|
||||
public interface BlockingContentStore {
|
||||
|
||||
UploadHandle createUpload(CreateContentCommand command);
|
||||
|
||||
/**
|
||||
* Appends under a fence that is re-checked as the transfer proceeds.
|
||||
*
|
||||
* <p>The fence is a parameter rather than store state because ownership belongs to the caller's
|
||||
* lease, not to the object: the store knows how to stop writing, but only the caller knows when
|
||||
* it has lost the right to.
|
||||
*/
|
||||
AppendResult append(
|
||||
UploadHandle handle,
|
||||
long expectedOffset,
|
||||
ReadableByteChannel source,
|
||||
long contentLength,
|
||||
WriteFence fence);
|
||||
|
||||
/** Appends with no ownership to lose; see {@link WriteFence#unfenced()}. */
|
||||
default AppendResult append(
|
||||
UploadHandle handle, long expectedOffset, ReadableByteChannel source, long contentLength) {
|
||||
return append(handle, expectedOffset, source, contentLength, WriteFence.unfenced());
|
||||
}
|
||||
|
||||
StoredContent finalizeUpload(UploadHandle handle, FinalizeContentCommand command);
|
||||
|
||||
ContentMetadata stat(ContentKey key);
|
||||
|
||||
ReadableByteChannel openRead(ContentKey key, ByteRange range);
|
||||
|
||||
DeleteResult delete(ContentKey key, DeletePrecondition precondition);
|
||||
|
||||
ContentStoreCapabilities capabilities();
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* Optional capacity reporting used by admission control and the admin plane.
|
||||
*
|
||||
* <p>Stores that cannot answer capacity simply do not implement this interface; the high-water
|
||||
* guards then degrade to reservation-only accounting.
|
||||
*/
|
||||
public interface CapacityAwareContentStore {
|
||||
|
||||
StorageCapacity capacity();
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Physical observation of a stored object.
|
||||
*
|
||||
* <p>This is used for publish verification and reconciliation only. It is never the source of
|
||||
* public metadata, and its timestamp is never served as {@code Last-Modified}.
|
||||
*/
|
||||
public record ContentMetadata(ContentKey contentKey, long size, Instant lastModified) {
|
||||
|
||||
public ContentMetadata {
|
||||
Objects.requireNonNull(contentKey, "contentKey");
|
||||
Objects.requireNonNull(lastModified, "lastModified");
|
||||
if (size < 0) {
|
||||
throw new IllegalArgumentException("size must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* Runtime capabilities of a content store, produced by a real startup probe.
|
||||
*
|
||||
* <p>These flags are never read from configuration alone: the local adapter proves each one against
|
||||
* the configured storage root before the application accepts traffic.
|
||||
*/
|
||||
public record ContentStoreCapabilities(
|
||||
boolean rangedRead,
|
||||
boolean atomicCreate,
|
||||
boolean atomicPublish,
|
||||
boolean conditionalWrite,
|
||||
boolean serverSideCopy,
|
||||
boolean delegatedDownload,
|
||||
boolean resumableAppend) {
|
||||
|
||||
/** Capability set with every optional feature disabled. */
|
||||
public static ContentStoreCapabilities none() {
|
||||
return new ContentStoreCapabilities(false, false, false, false, false, false, false);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* Optional server-side copy capability.
|
||||
*
|
||||
* <p>Stores without it fall back to an application-level stream copy. Automatic rollback of a
|
||||
* failed copy is never promised; an incomplete target goes to the cleanup queue.
|
||||
*/
|
||||
public interface CopyCapableContentStore {
|
||||
|
||||
CompletionStage<StoredContent> copy(
|
||||
ContentKey source, ContentKey target, CopyPrecondition precondition);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* Conditions a server-side copy must satisfy.
|
||||
*
|
||||
* <p>The default is create-only: an existing target is a failure, never a silent overwrite.
|
||||
*/
|
||||
public record CopyPrecondition(boolean createOnly) {
|
||||
|
||||
/** Create-only copy, the Fileserver default. */
|
||||
public static CopyPrecondition requireCreateOnly() {
|
||||
return new CopyPrecondition(true);
|
||||
}
|
||||
|
||||
/** Conditional replace, reachable only from a path that already validated a precondition. */
|
||||
public static CopyPrecondition allowConditionalReplace() {
|
||||
return new CopyPrecondition(false);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Request to create a new staging object.
|
||||
*
|
||||
* <p>The command carries no client filename: the physical object is named from server-generated
|
||||
* identity only. {@code expectedLength} is advisory and is re-verified against the bytes that are
|
||||
* actually written.
|
||||
*/
|
||||
public record CreateContentCommand(
|
||||
UploadId uploadId,
|
||||
StorageNamespace namespace,
|
||||
OptionalLong expectedLength,
|
||||
long maximumLength) {
|
||||
|
||||
public CreateContentCommand {
|
||||
Objects.requireNonNull(uploadId, "uploadId");
|
||||
Objects.requireNonNull(namespace, "namespace");
|
||||
Objects.requireNonNull(expectedLength, "expectedLength");
|
||||
if (maximumLength <= 0) {
|
||||
throw new IllegalArgumentException("maximumLength must be positive");
|
||||
}
|
||||
if (expectedLength.isPresent() && expectedLength.getAsLong() < 0) {
|
||||
throw new IllegalArgumentException("expectedLength must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Internal descriptor a front proxy consumes to perform the actual transfer.
|
||||
*
|
||||
* <p>{@code internalUri} is always relative and always below the configured internal prefix.
|
||||
*/
|
||||
public record DelegatedDownloadDescriptor(String internalUri, Duration ttl) {
|
||||
|
||||
public DelegatedDownloadDescriptor {
|
||||
Objects.requireNonNull(internalUri, "internalUri");
|
||||
Objects.requireNonNull(ttl, "ttl");
|
||||
if (!internalUri.startsWith("/") || internalUri.contains("..")) {
|
||||
throw new IllegalArgumentException("internal uri must be a relative-rooted safe path");
|
||||
}
|
||||
if (ttl.isNegative() || ttl.isZero()) {
|
||||
throw new IllegalArgumentException("ttl must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ByteRange;
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Optional capability to hand a transfer to a front proxy instead of streaming it in-process.
|
||||
*
|
||||
* <p>The descriptor is a validated relative internal URI. It never contains an absolute physical
|
||||
* path, and issuing a publicly signed URL is out of scope for this store family.
|
||||
*/
|
||||
public interface DelegatedDownloadStore {
|
||||
|
||||
DelegatedDownloadDescriptor createDelegation(
|
||||
ContentKey key, Optional<ByteRange> range, Duration ttl);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Conditions a physical delete must satisfy before it runs.
|
||||
*
|
||||
* <p>Cleanup never deletes an object whose observed size or digest disagrees with the record that
|
||||
* scheduled the deletion.
|
||||
*/
|
||||
public record DeletePrecondition(OptionalLong expectedSize, Optional<String> expectedSha256) {
|
||||
|
||||
public DeletePrecondition {
|
||||
Objects.requireNonNull(expectedSize, "expectedSize");
|
||||
Objects.requireNonNull(expectedSha256, "expectedSha256");
|
||||
}
|
||||
|
||||
/** Unconditional delete, used only where the caller already proved ownership. */
|
||||
public static DeletePrecondition none() {
|
||||
return new DeletePrecondition(OptionalLong.empty(), Optional.empty());
|
||||
}
|
||||
|
||||
public static DeletePrecondition ofSize(long expectedSize) {
|
||||
return new DeletePrecondition(OptionalLong.of(expectedSize), Optional.empty());
|
||||
}
|
||||
|
||||
public static DeletePrecondition ofSizeAndDigest(long expectedSize, String expectedSha256) {
|
||||
return new DeletePrecondition(OptionalLong.of(expectedSize), Optional.of(expectedSha256));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* Outcome of a physical delete.
|
||||
*
|
||||
* <p>A delete of an object that is already gone is an idempotent success, but it is reported with
|
||||
* {@code alreadyAbsent} so reconciliation can record the divergence.
|
||||
*/
|
||||
public record DeleteResult(boolean deleted, boolean alreadyAbsent, long reclaimedBytes) {
|
||||
|
||||
public DeleteResult {
|
||||
if (reclaimedBytes < 0) {
|
||||
throw new IllegalArgumentException("reclaimedBytes must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public static DeleteResult removed(long reclaimedBytes) {
|
||||
return new DeleteResult(true, false, reclaimedBytes);
|
||||
}
|
||||
|
||||
public static DeleteResult alreadyGone() {
|
||||
return new DeleteResult(true, true, 0);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Request to turn a completed staging object into immutable published content.
|
||||
*
|
||||
* <p>{@code expectedSha256} is the digest the server computed while streaming, not a client
|
||||
* assertion. The store re-verifies length and digest before it exposes anything.
|
||||
*/
|
||||
public record FinalizeContentCommand(
|
||||
OptionalLong expectedLength,
|
||||
Optional<String> expectedSha256,
|
||||
PublishMode publishMode,
|
||||
boolean forceDurable) {
|
||||
|
||||
public FinalizeContentCommand {
|
||||
Objects.requireNonNull(expectedLength, "expectedLength");
|
||||
Objects.requireNonNull(expectedSha256, "expectedSha256");
|
||||
Objects.requireNonNull(publishMode, "publishMode");
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* How a completed upload becomes publicly visible.
|
||||
*
|
||||
* <p>{@link #ATOMIC_MOVE_PREFERRED} is the default: use a same-FileStore atomic move when the probe
|
||||
* proves it works, otherwise fall back to publishing a metadata pointer to an already-complete
|
||||
* immutable object.
|
||||
*/
|
||||
public enum PublishMode {
|
||||
ATOMIC_MOVE_REQUIRED,
|
||||
ATOMIC_MOVE_PREFERRED,
|
||||
METADATA_POINTER
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* Usable and total bytes of a storage pool.
|
||||
*
|
||||
* <p>Neither value identifies a mount point or a physical root.
|
||||
*/
|
||||
public record StorageCapacity(long usableBytes, long totalBytes) {
|
||||
|
||||
public StorageCapacity {
|
||||
if (usableBytes < 0 || totalBytes < 0 || usableBytes > totalBytes) {
|
||||
throw new IllegalArgumentException("invalid storage capacity");
|
||||
}
|
||||
}
|
||||
|
||||
/** Fraction of the pool already consumed, in the closed interval zero to one. */
|
||||
public double usedFraction() {
|
||||
if (totalBytes == 0) {
|
||||
return 0;
|
||||
}
|
||||
return (double) (totalBytes - usableBytes) / (double) totalBytes;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable content that a store has finished publishing.
|
||||
*
|
||||
* <p>Publication here means the physical object is complete and verified; the file becomes publicly
|
||||
* readable only once the metadata store commits the READY transition.
|
||||
*/
|
||||
public record StoredContent(
|
||||
ContentKey contentKey, long size, String sha256, boolean atomicMoveUsed) {
|
||||
|
||||
public StoredContent {
|
||||
Objects.requireNonNull(contentKey, "contentKey");
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
if (size < 0) {
|
||||
throw new IllegalArgumentException("size must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Opaque handle to an in-progress staging object held by a content store.
|
||||
*
|
||||
* <p>The handle never exposes a path. Adapters that need physical detail keep it in their own
|
||||
* package-private subtype and downcast internally.
|
||||
*/
|
||||
public interface UploadHandle {
|
||||
|
||||
UploadId uploadId();
|
||||
|
||||
StorageNamespace namespace();
|
||||
|
||||
/**
|
||||
* Store-specific opaque token that lets the same store re-attach to the staging object after a
|
||||
* restart. It is never returned to a client.
|
||||
*/
|
||||
String stagingToken();
|
||||
|
||||
/** Throws when {@code handle} was produced by a different content store implementation. */
|
||||
static <T extends UploadHandle> T requireOwn(UploadHandle handle, Class<T> ownType) {
|
||||
Objects.requireNonNull(handle, "handle");
|
||||
if (!ownType.isInstance(handle)) {
|
||||
throw new IllegalArgumentException(
|
||||
"upload handle was not produced by " + ownType.getSimpleName());
|
||||
}
|
||||
return ownType.cast(handle);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.application.fileserver.api.content;
|
||||
|
||||
/**
|
||||
* Permission to keep writing, re-checked while a transfer is still in flight.
|
||||
*
|
||||
* <p>An upload holds a writer lease that is granted once and expires on a timer, but the transfer
|
||||
* it authorizes can run for minutes. Checking ownership only at the start leaves the interval where
|
||||
* a slow writer's lease lapses, another node takes it over, and both are appending to the same
|
||||
* staging object — with the loser's bytes landing at offsets the winner never accounted for.
|
||||
*
|
||||
* <p>The store therefore re-asks between buffers rather than trusting the initial grant. A refusal
|
||||
* aborts the transfer before the next write, and the store's own rollback returns the object to the
|
||||
* offset the append started from, so a fenced-out writer leaves no trace on the volume.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface WriteFence {
|
||||
|
||||
/**
|
||||
* Confirms this writer may still mutate the physical object.
|
||||
*
|
||||
* @throws dev.caskeleton.application.fileserver.api.error.FileserverException when ownership was
|
||||
* lost; the caller must not write again
|
||||
*/
|
||||
void requireStillOwned();
|
||||
|
||||
/**
|
||||
* A fence that never refuses.
|
||||
*
|
||||
* <p>For call sites with no ownership to lose — a contract test driving the store directly, or a
|
||||
* copy between two objects only this thread can reach.
|
||||
*/
|
||||
static WriteFence unfenced() {
|
||||
return () -> {};
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
|
||||
/**
|
||||
* The operation may or may not have taken effect and the server cannot decide which.
|
||||
*
|
||||
* <p>Typical causes are a lost rename response on a network filesystem, a vanished mount after a
|
||||
* successful force, and a missing database commit acknowledgement. This failure is never downgraded
|
||||
* to a retryable error and never blind-retried: it always carries {@code reconciliationRequired}.
|
||||
*/
|
||||
public final class AmbiguousCompletionException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public AmbiguousCompletionException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public AmbiguousCompletionException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Ambiguous outcome for an upload resource. */
|
||||
public static AmbiguousCompletionException forUpload(String message, UploadId uploadId) {
|
||||
return new AmbiguousCompletionException(
|
||||
message,
|
||||
FileserverFailureContext.forUpload(
|
||||
FileserverErrorCode.AMBIGUOUS_COMPLETION, uploadId, false, true, true));
|
||||
}
|
||||
|
||||
/** Ambiguous outcome for a file record, typically a publish or metadata commit. */
|
||||
public static AmbiguousCompletionException forFile(String message, FileId fileId) {
|
||||
return new AmbiguousCompletionException(
|
||||
message,
|
||||
FileserverFailureContext.forFile(FileserverErrorCode.AMBIGUOUS_COMPLETION, fileId, false)
|
||||
.ambiguousRequiringReconciliation());
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** Atomic publish was required by configuration but the storage probe proved it unavailable. */
|
||||
public final class AtomicPublishUnsupportedException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public AtomicPublishUnsupportedException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public AtomicPublishUnsupportedException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static AtomicPublishUnsupportedException of(String message) {
|
||||
return new AtomicPublishUnsupportedException(
|
||||
message,
|
||||
FileserverFailureContext.of(FileserverErrorCode.ATOMIC_PUBLISH_UNSUPPORTED, false));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** An optimistic version or writer-lease precondition lost to a concurrent writer. */
|
||||
public final class ConcurrentFileModificationException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ConcurrentFileModificationException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public ConcurrentFileModificationException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static ConcurrentFileModificationException of(String message) {
|
||||
return new ConcurrentFileModificationException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.CONCURRENT_MODIFICATION, true));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The injected access policy denied the operation before any quota or storage mutation. */
|
||||
public final class FileAccessDeniedException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public FileAccessDeniedException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public FileAccessDeniedException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static FileAccessDeniedException of(String message) {
|
||||
return new FileAccessDeniedException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.ACCESS_DENIED, false));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** Create-only target already exists; the server never silently overwrites. */
|
||||
public final class FileAlreadyExistsException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public FileAlreadyExistsException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public FileAlreadyExistsException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static FileAlreadyExistsException of(String message) {
|
||||
return new FileAlreadyExistsException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.FILE_ALREADY_EXISTS, false));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/**
|
||||
* Requested file identity does not exist, or existence hiding decided the caller may not learn that
|
||||
* it does.
|
||||
*/
|
||||
public final class FileNotFoundException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public FileNotFoundException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public FileNotFoundException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static FileNotFoundException of(String message) {
|
||||
return new FileNotFoundException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.FILE_NOT_FOUND, false));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
|
||||
/**
|
||||
* The file exists but is not in {@link FileState#READY}, so no byte may be served.
|
||||
*
|
||||
* <p>This gate is evaluated before any content handle is opened and applies identically to direct
|
||||
* transfer and to delegated (Nginx) transfer.
|
||||
*/
|
||||
public final class FileNotReadyException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public FileNotReadyException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
/** Gate failure carrying the observed non-READY state. */
|
||||
public static FileNotReadyException of(FileId fileId, FileState currentState) {
|
||||
return new FileNotReadyException(
|
||||
"file is not readable in state " + currentState,
|
||||
FileserverFailureContext.forFileState(
|
||||
FileserverErrorCode.FILE_NOT_READY, fileId, currentState, false));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The declared or streamed byte count exceeded the configured maximum. */
|
||||
public final class FileTooLargeException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public FileTooLargeException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public FileTooLargeException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static FileTooLargeException of(String message) {
|
||||
return new FileTooLargeException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.FILE_TOO_LARGE, false));
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Stable Fileserver failure vocabulary shared by every transport adapter.
|
||||
*
|
||||
* <p>Implements the repository-wide {@link ApiErrorCode} contract rather than parallelling it. The
|
||||
* enum already carried a status and a retryability notion of its own, in its own shape, so a caller
|
||||
* that handles {@code ApiErrorCode} uniformly — the envelope writer, the error registry contract
|
||||
* tests, a fork's own handler — silently did not cover Fileserver failures. Same information,
|
||||
* inside the contract instead of beside it.
|
||||
*
|
||||
* <p>The status stays here, on the code, for the reason {@link ApiErrorCode} gives: it is a plain
|
||||
* integer, never a framework status type, so the module holding it stays framework-neutral and the
|
||||
* servlet transport, the reactive transport and the Nginx delegation path cannot answer the same
|
||||
* failure three different ways.
|
||||
*
|
||||
* <p>The URN is the {@code type} member of the emitted problem detail and has no counterpart in the
|
||||
* shared contract, so it remains Fileserver-specific.
|
||||
*/
|
||||
public enum FileserverErrorCode implements ApiErrorCode {
|
||||
BAD_REQUEST(400, Category.VALIDATION, false),
|
||||
UNAUTHENTICATED(401, Category.AUTH, false),
|
||||
ACCESS_DENIED(403, Category.AUTHZ, false),
|
||||
FILE_NOT_FOUND(404, Category.NOT_FOUND, false),
|
||||
FILE_ALREADY_EXISTS(409, Category.CONFLICT, false),
|
||||
FILE_NOT_READY(409, Category.CONFLICT, true),
|
||||
UPLOAD_OFFSET_MISMATCH(409, Category.CONFLICT, true),
|
||||
CONCURRENT_MODIFICATION(409, Category.CONFLICT, true),
|
||||
UPLOAD_EXPIRED(410, Category.CONFLICT, false),
|
||||
CONTENT_LENGTH_REQUIRED(411, Category.VALIDATION, false),
|
||||
PRECONDITION_FAILED(412, Category.CONFLICT, false),
|
||||
FILE_TOO_LARGE(413, Category.VALIDATION, false),
|
||||
QUOTA_EXCEEDED(413, Category.CONFLICT, false),
|
||||
UNSUPPORTED_MEDIA_TYPE(415, Category.VALIDATION, false),
|
||||
RANGE_NOT_SATISFIABLE(416, Category.VALIDATION, false),
|
||||
INTEGRITY_MISMATCH(422, Category.DATA_INTEGRITY, false),
|
||||
MALWARE_DETECTED(422, Category.DATA_INTEGRITY, false),
|
||||
INVALID_PATH(422, Category.VALIDATION, false),
|
||||
PATH_OUTSIDE_NAMESPACE(422, Category.VALIDATION, false),
|
||||
TRANSFER_ADMISSION_REJECTED(429, Category.RATE_LIMIT, true),
|
||||
PARTIAL_WRITE(500, Category.TRANSIENT_DEPENDENCY, false),
|
||||
// Never retryable by definition: the operation may already have taken effect, and a blind retry
|
||||
// is what turns an ambiguous outcome into a duplicated one.
|
||||
AMBIGUOUS_COMPLETION(500, Category.DATA_INTEGRITY, false),
|
||||
ATOMIC_PUBLISH_UNSUPPORTED(503, Category.PERMANENT_DEPENDENCY, false),
|
||||
STORAGE_UNAVAILABLE(503, Category.TRANSIENT_DEPENDENCY, true),
|
||||
TRANSFER_TIMEOUT(504, Category.TRANSIENT_DEPENDENCY, true),
|
||||
STORAGE_FULL(507, Category.TRANSIENT_DEPENDENCY, false);
|
||||
|
||||
private static final String PROBLEM_TYPE_PREFIX = "urn:fileserver:problem:";
|
||||
|
||||
private final int httpStatus;
|
||||
private final Category category;
|
||||
private final boolean retryable;
|
||||
|
||||
FileserverErrorCode(int httpStatus, Category category, boolean retryable) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.category = category;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String code() {
|
||||
return name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Category category() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/** Design §19.3 status for this failure; identical across MVC, WebFlux, and Nginx delegation. */
|
||||
@Override
|
||||
public int httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the same call may succeed on retry, as a property of the code.
|
||||
*
|
||||
* <p>Distinct from {@link FileserverFailureContext#retryable()}, which is what the server decided
|
||||
* about one particular occurrence. This is the ceiling: a code that is never retryable cannot be
|
||||
* made retryable by a context, and a client that only sees the code still gets a safe answer.
|
||||
*/
|
||||
@Override
|
||||
public boolean retryable() {
|
||||
return retryable;
|
||||
}
|
||||
|
||||
/** Problem-detail {@code type} URN, for example {@code urn:fileserver:problem:file-not-found}. */
|
||||
public String problemType() {
|
||||
return PROBLEM_TYPE_PREFIX + name().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Root of the stable Fileserver failure hierarchy.
|
||||
*
|
||||
* <p>Transport adapters map these failures through {@link #context()} alone; they never inspect a
|
||||
* storage-driver or JDBC exception. {@link #getMessage()} is server-log-only and must never be
|
||||
* copied into a client response body.
|
||||
*/
|
||||
public abstract class FileserverException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient FileserverFailureContext context;
|
||||
|
||||
protected FileserverException(String message, FileserverFailureContext context) {
|
||||
super(message);
|
||||
this.context = Objects.requireNonNull(context, "context");
|
||||
}
|
||||
|
||||
protected FileserverException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause);
|
||||
this.context = Objects.requireNonNull(context, "context");
|
||||
}
|
||||
|
||||
public final FileserverFailureContext context() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public final FileserverErrorCode code() {
|
||||
return context.code();
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Machine-readable failure metadata attached to every {@link FileserverException}.
|
||||
*
|
||||
* <p>{@code ambiguous} means the operation may have taken effect on the storage or metadata side
|
||||
* even though no success was observed; such a failure is never downgraded to a plain retryable
|
||||
* error. The context never carries a physical path, a mount, a scanner credential, or a filename.
|
||||
*/
|
||||
public record FileserverFailureContext(
|
||||
FileserverErrorCode code,
|
||||
boolean retryable,
|
||||
boolean ambiguous,
|
||||
boolean reconciliationRequired,
|
||||
Optional<FileId> fileId,
|
||||
Optional<UploadId> uploadId,
|
||||
OptionalLong expectedOffset,
|
||||
OptionalLong currentOffset,
|
||||
Optional<FileState> currentState) {
|
||||
|
||||
public FileserverFailureContext {
|
||||
Objects.requireNonNull(code, "code");
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(uploadId, "uploadId");
|
||||
Objects.requireNonNull(expectedOffset, "expectedOffset");
|
||||
Objects.requireNonNull(currentOffset, "currentOffset");
|
||||
Objects.requireNonNull(currentState, "currentState");
|
||||
}
|
||||
|
||||
/** Failure with no file, upload, or offset correlation. */
|
||||
public static FileserverFailureContext of(FileserverErrorCode code, boolean retryable) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
retryable,
|
||||
false,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
OptionalLong.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Failure correlated to a file, optionally reporting the observed state. */
|
||||
public static FileserverFailureContext forFile(
|
||||
FileserverErrorCode code, FileId fileId, boolean retryable) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
retryable,
|
||||
false,
|
||||
false,
|
||||
Optional.of(fileId),
|
||||
Optional.empty(),
|
||||
OptionalLong.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Failure correlated to a file whose observed lifecycle state matters to the caller. */
|
||||
public static FileserverFailureContext forFileState(
|
||||
FileserverErrorCode code, FileId fileId, FileState currentState, boolean retryable) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
retryable,
|
||||
false,
|
||||
false,
|
||||
Optional.of(fileId),
|
||||
Optional.empty(),
|
||||
OptionalLong.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.of(currentState));
|
||||
}
|
||||
|
||||
/** Failure correlated to an upload resource, including the ambiguity and reconciliation flags. */
|
||||
public static FileserverFailureContext forUpload(
|
||||
FileserverErrorCode code,
|
||||
UploadId uploadId,
|
||||
boolean retryable,
|
||||
boolean ambiguous,
|
||||
boolean reconciliationRequired) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
retryable,
|
||||
ambiguous,
|
||||
reconciliationRequired,
|
||||
Optional.empty(),
|
||||
Optional.of(uploadId),
|
||||
OptionalLong.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Offset conflict reporting both the expected and the durably committed offset. */
|
||||
public static FileserverFailureContext forOffset(
|
||||
FileserverErrorCode code, long expectedOffset, long currentOffset) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
OptionalLong.of(expectedOffset),
|
||||
OptionalLong.of(currentOffset),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Same context with the upload correlation filled in. */
|
||||
public FileserverFailureContext withUpload(UploadId uploadId) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
retryable,
|
||||
ambiguous,
|
||||
reconciliationRequired,
|
||||
fileId,
|
||||
Optional.of(uploadId),
|
||||
expectedOffset,
|
||||
currentOffset,
|
||||
currentState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same context re-marked as an ambiguous outcome that must go through reconciliation.
|
||||
*
|
||||
* <p>An ambiguous failure is never retryable: the operation may already have taken effect.
|
||||
*/
|
||||
public FileserverFailureContext ambiguousRequiringReconciliation() {
|
||||
return new FileserverFailureContext(
|
||||
code, false, true, true, fileId, uploadId, expectedOffset, currentOffset, currentState);
|
||||
}
|
||||
|
||||
/** Same context with the file correlation filled in. */
|
||||
public FileserverFailureContext withFile(FileId fileId) {
|
||||
return new FileserverFailureContext(
|
||||
code,
|
||||
retryable,
|
||||
ambiguous,
|
||||
reconciliationRequired,
|
||||
Optional.of(fileId),
|
||||
uploadId,
|
||||
expectedOffset,
|
||||
currentOffset,
|
||||
currentState);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The server-computed size or SHA-256 disagreed with the value the client asserted. */
|
||||
public final class IntegrityMismatchException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public IntegrityMismatchException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public IntegrityMismatchException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static IntegrityMismatchException of(String message) {
|
||||
return new IntegrityMismatchException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.INTEGRITY_MISMATCH, false));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** A server-generated key or internal descriptor failed its structural validation. */
|
||||
public final class InvalidPathException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public InvalidPathException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public InvalidPathException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static InvalidPathException of(String message) {
|
||||
return new InvalidPathException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.INVALID_PATH, false));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/**
|
||||
* A request's headers or their combination are not valid for the operation.
|
||||
*
|
||||
* <p>This is separate from {@link UnsupportedMediaTypeException} on purpose: a missing protocol
|
||||
* version and an unsupported content type are different failures, and collapsing them would make
|
||||
* the emitted code disagree with the exception a reader sees in a stack trace.
|
||||
*/
|
||||
public final class MalformedRequestException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public MalformedRequestException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public MalformedRequestException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static MalformedRequestException of(String message) {
|
||||
return new MalformedRequestException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.BAD_REQUEST, false));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** A malware or content-disarm verifier returned a reject verdict; content is never published. */
|
||||
public final class MalwareDetectedException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public MalwareDetectedException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public MalwareDetectedException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static MalwareDetectedException of(String message) {
|
||||
return new MalwareDetectedException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.MALWARE_DETECTED, false));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** Fewer bytes than promised reached durable storage; the session stays recoverable. */
|
||||
public final class PartialWriteException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PartialWriteException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public PartialWriteException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static PartialWriteException of(String message) {
|
||||
return new PartialWriteException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.PARTIAL_WRITE, false));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** A resolved physical location escaped its configured storage root. */
|
||||
public final class PathOutsideNamespaceException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PathOutsideNamespaceException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public PathOutsideNamespaceException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static PathOutsideNamespaceException of(String message) {
|
||||
return new PathOutsideNamespaceException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.PATH_OUTSIDE_NAMESPACE, false));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** A quota scope reservation, commit, or concurrency limit rejected the request. */
|
||||
public final class QuotaExceededException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public QuotaExceededException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public QuotaExceededException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static QuotaExceededException of(String message) {
|
||||
return new QuotaExceededException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.QUOTA_EXCEEDED, false));
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/**
|
||||
* No requested byte range intersects the current representation.
|
||||
*
|
||||
* <p>The representation length travels with the failure so the transport can emit the unsatisfiable
|
||||
* {@code Content-Range} form (an asterisk in place of the range, then a slash and the
|
||||
* representation length) without re-reading metadata.
|
||||
*/
|
||||
public final class RangeNotSatisfiableException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final long representationLength;
|
||||
|
||||
public RangeNotSatisfiableException(
|
||||
String message, long representationLength, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
this.representationLength = representationLength;
|
||||
}
|
||||
|
||||
/** Unsatisfiable range over a representation of {@code representationLength} bytes. */
|
||||
public static RangeNotSatisfiableException of(long representationLength) {
|
||||
return new RangeNotSatisfiableException(
|
||||
"requested range is not satisfiable",
|
||||
representationLength,
|
||||
FileserverFailureContext.of(FileserverErrorCode.RANGE_NOT_SATISFIABLE, false));
|
||||
}
|
||||
|
||||
/** Length of the representation the range was evaluated against. */
|
||||
public long representationLength() {
|
||||
return representationLength;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The storage pool crossed its hard high-water mark or the filesystem reported no space. */
|
||||
public final class StorageFullException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public StorageFullException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public StorageFullException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static StorageFullException of(String message) {
|
||||
return new StorageFullException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.STORAGE_FULL, false));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The storage backend or a required verifier is temporarily unreachable. */
|
||||
public final class StorageUnavailableException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public StorageUnavailableException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public StorageUnavailableException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static StorageUnavailableException of(String message) {
|
||||
return new StorageUnavailableException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.STORAGE_UNAVAILABLE, true));
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** Instance or scope transfer permits were exhausted; the caller may retry after backoff. */
|
||||
public final class TransferAdmissionRejectedException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public TransferAdmissionRejectedException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public TransferAdmissionRejectedException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static TransferAdmissionRejectedException of(String message) {
|
||||
return new TransferAdmissionRejectedException(
|
||||
message,
|
||||
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_ADMISSION_REJECTED, true));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** A downstream transfer exceeded its idle or total timeout budget. */
|
||||
public final class TransferTimeoutException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public TransferTimeoutException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public TransferTimeoutException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static TransferTimeoutException of(String message) {
|
||||
return new TransferTimeoutException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The upload media type is outside the configured allowlist. */
|
||||
public final class UnsupportedMediaTypeException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public UnsupportedMediaTypeException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public UnsupportedMediaTypeException(
|
||||
String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static UnsupportedMediaTypeException of(String message) {
|
||||
return new UnsupportedMediaTypeException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.UNSUPPORTED_MEDIA_TYPE, false));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
/** The upload resource passed its expiry and is no longer appendable. */
|
||||
public final class UploadExpiredException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public UploadExpiredException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
public UploadExpiredException(String message, Throwable cause, FileserverFailureContext context) {
|
||||
super(message, cause, context);
|
||||
}
|
||||
|
||||
/** Failure with no file or upload correlation. */
|
||||
public static UploadExpiredException of(String message) {
|
||||
return new UploadExpiredException(
|
||||
message, FileserverFailureContext.of(FileserverErrorCode.UPLOAD_EXPIRED, false));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.application.fileserver.api.error;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
|
||||
/**
|
||||
* The requested append offset disagrees with the durably committed offset.
|
||||
*
|
||||
* <p>The upload resource is never mutated when this is thrown: the client is expected to re-read
|
||||
* the current offset and resume from it.
|
||||
*/
|
||||
public final class UploadOffsetMismatchException extends FileserverException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public UploadOffsetMismatchException(String message, FileserverFailureContext context) {
|
||||
super(message, context);
|
||||
}
|
||||
|
||||
/** Mismatch reporting the offset the caller assumed and the offset that is actually durable. */
|
||||
public static UploadOffsetMismatchException of(long expectedOffset, long currentOffset) {
|
||||
return new UploadOffsetMismatchException(
|
||||
"upload offset mismatch: expected " + expectedOffset + " but committed " + currentOffset,
|
||||
FileserverFailureContext.forOffset(
|
||||
FileserverErrorCode.UPLOAD_OFFSET_MISMATCH, expectedOffset, currentOffset));
|
||||
}
|
||||
|
||||
/** Mismatch correlated to the upload resource the client addressed. */
|
||||
public static UploadOffsetMismatchException of(
|
||||
UploadId uploadId, long expectedOffset, long currentOffset) {
|
||||
return new UploadOffsetMismatchException(
|
||||
"upload offset mismatch: expected " + expectedOffset + " but committed " + currentOffset,
|
||||
FileserverFailureContext.forOffset(
|
||||
FileserverErrorCode.UPLOAD_OFFSET_MISMATCH, expectedOffset, currentOffset)
|
||||
.withUpload(uploadId));
|
||||
}
|
||||
|
||||
/** Offset the caller asserted, or {@code -1} when the context carries none. */
|
||||
public long expectedOffset() {
|
||||
return context().expectedOffset().orElse(-1L);
|
||||
}
|
||||
|
||||
/** Offset that is actually durable, or {@code -1} when the context carries none. */
|
||||
public long currentOffset() {
|
||||
return context().currentOffset().orElse(-1L);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Public metadata view of a file.
|
||||
*
|
||||
* <p>The descriptor never carries a physical path, a content key, a raw scanner response, or raw
|
||||
* user metadata. {@code originalFilename} is untrusted display data only.
|
||||
*/
|
||||
public record FileDescriptor(
|
||||
FileId fileId,
|
||||
StorageNamespace namespace,
|
||||
FileState state,
|
||||
String originalFilename,
|
||||
String mediaType,
|
||||
long size,
|
||||
String sha256,
|
||||
String strongEtag,
|
||||
Instant publishedAt,
|
||||
long version) {
|
||||
|
||||
public FileDescriptor {
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(namespace, "namespace");
|
||||
Objects.requireNonNull(state, "state");
|
||||
Objects.requireNonNull(originalFilename, "originalFilename");
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Authoritative file metadata port.
|
||||
*
|
||||
* <p>Every transition is conditional on both the expected version and the expected state, so two
|
||||
* writers racing on the same record produce exactly one winner and one optimistic conflict.
|
||||
*/
|
||||
public interface FileMetadataStore {
|
||||
|
||||
FileRecord insert(FileRecordDraft draft);
|
||||
|
||||
Optional<FileRecord> find(FileId fileId);
|
||||
|
||||
FileRecord transition(
|
||||
FileId fileId,
|
||||
long expectedVersion,
|
||||
FileState expectedState,
|
||||
FileState targetState,
|
||||
FileRecordMutation mutation);
|
||||
|
||||
FileRecord markDeleting(FileId fileId, long expectedVersion);
|
||||
|
||||
/**
|
||||
* Moves a file to another logical namespace.
|
||||
*
|
||||
* <p>A namespace is a metadata grouping, so this changes one column and never touches the
|
||||
* immutable physical object. Copying gigabytes to express an ownership change would also break
|
||||
* every strong validator already handed to clients.
|
||||
*/
|
||||
FileRecord relocate(FileId fileId, long expectedVersion, StorageNamespace targetNamespace);
|
||||
|
||||
List<FileRecord> findRecoverable(FileRecoveryQuery query);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Reservation-based quota accounting.
|
||||
*
|
||||
* <p>When the client declares no length, the caller reserves a profile-specific initial chunk and
|
||||
* extends it while appending. Every failure path releases the reservation.
|
||||
*/
|
||||
public interface FileQuotaService {
|
||||
|
||||
QuotaReservation reserve(QuotaScope scope, long expectedBytes, Duration ttl);
|
||||
|
||||
void extend(QuotaReservation reservation, long additionalBytes);
|
||||
|
||||
void commit(QuotaReservation reservation, long actualBytes);
|
||||
|
||||
void release(QuotaReservation reservation);
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Authoritative metadata row for one file.
|
||||
*
|
||||
* <p>The relational record — not the filesystem — decides whether a file is publicly readable.
|
||||
* {@code version} backs every optimistic transition.
|
||||
*/
|
||||
public record FileRecord(
|
||||
FileId fileId,
|
||||
StorageNamespace namespace,
|
||||
FileState state,
|
||||
Optional<ContentKey> contentKey,
|
||||
String originalName,
|
||||
Optional<String> claimedMediaType,
|
||||
Optional<String> verifiedMediaType,
|
||||
OptionalLong expectedSize,
|
||||
OptionalLong actualSize,
|
||||
Optional<String> sha256,
|
||||
Optional<String> strongEtag,
|
||||
Optional<Instant> publishedAt,
|
||||
Optional<String> lastErrorCode,
|
||||
long version,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
|
||||
public FileRecord {
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(namespace, "namespace");
|
||||
Objects.requireNonNull(state, "state");
|
||||
Objects.requireNonNull(contentKey, "contentKey");
|
||||
Objects.requireNonNull(originalName, "originalName");
|
||||
Objects.requireNonNull(claimedMediaType, "claimedMediaType");
|
||||
Objects.requireNonNull(verifiedMediaType, "verifiedMediaType");
|
||||
Objects.requireNonNull(expectedSize, "expectedSize");
|
||||
Objects.requireNonNull(actualSize, "actualSize");
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
Objects.requireNonNull(strongEtag, "strongEtag");
|
||||
Objects.requireNonNull(publishedAt, "publishedAt");
|
||||
Objects.requireNonNull(lastErrorCode, "lastErrorCode");
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (version < 0) {
|
||||
throw new IllegalArgumentException("version must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the public descriptor.
|
||||
*
|
||||
* <p>The verified media type wins over the claimed one, and an unpublished record reports the
|
||||
* neutral {@code application/octet-stream} rather than echoing the client assertion as fact.
|
||||
*/
|
||||
public FileDescriptor toDescriptor() {
|
||||
return new FileDescriptor(
|
||||
fileId,
|
||||
namespace,
|
||||
state,
|
||||
originalName,
|
||||
verifiedMediaType.orElse("application/octet-stream"),
|
||||
actualSize.orElse(0L),
|
||||
sha256.orElse(""),
|
||||
strongEtag.orElse(""),
|
||||
publishedAt.orElse(null),
|
||||
version);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Insert payload for a new file record in {@code CREATED}.
|
||||
*
|
||||
* <p>{@code originalName} is already sanitized display text and {@code claimedMediaType} is the
|
||||
* untrusted client assertion; neither is used to build a physical key.
|
||||
*/
|
||||
public record FileRecordDraft(
|
||||
FileId fileId,
|
||||
StorageNamespace namespace,
|
||||
String originalName,
|
||||
Optional<String> claimedMediaType,
|
||||
OptionalLong expectedSize) {
|
||||
|
||||
public FileRecordDraft {
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(namespace, "namespace");
|
||||
Objects.requireNonNull(originalName, "originalName");
|
||||
Objects.requireNonNull(claimedMediaType, "claimedMediaType");
|
||||
Objects.requireNonNull(expectedSize, "expectedSize");
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Field changes applied atomically with a state transition.
|
||||
*
|
||||
* <p>Only fields explicitly present are written, so a transition can never clear a published digest
|
||||
* or content key by omission.
|
||||
*/
|
||||
public record FileRecordMutation(
|
||||
Optional<ContentKey> contentKey,
|
||||
OptionalLong actualSize,
|
||||
Optional<String> sha256,
|
||||
Optional<String> strongEtag,
|
||||
Optional<String> verifiedMediaType,
|
||||
Optional<Instant> publishedAt,
|
||||
Optional<String> lastErrorCode) {
|
||||
|
||||
public FileRecordMutation {
|
||||
Objects.requireNonNull(contentKey, "contentKey");
|
||||
Objects.requireNonNull(actualSize, "actualSize");
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
Objects.requireNonNull(strongEtag, "strongEtag");
|
||||
Objects.requireNonNull(verifiedMediaType, "verifiedMediaType");
|
||||
Objects.requireNonNull(publishedAt, "publishedAt");
|
||||
Objects.requireNonNull(lastErrorCode, "lastErrorCode");
|
||||
}
|
||||
|
||||
/** Transition that changes state only. */
|
||||
public static FileRecordMutation none() {
|
||||
return new FileRecordMutation(
|
||||
Optional.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Publish mutation written together with the READY transition. */
|
||||
public static FileRecordMutation publish(
|
||||
ContentKey contentKey, long actualSize, String sha256, String strongEtag) {
|
||||
return new FileRecordMutation(
|
||||
Optional.of(contentKey),
|
||||
OptionalLong.of(actualSize),
|
||||
Optional.of(sha256),
|
||||
Optional.of(strongEtag),
|
||||
Optional.empty(),
|
||||
Optional.of(Instant.now()),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Publish mutation with an explicit publication instant, for deterministic tests and replay. */
|
||||
public static FileRecordMutation publishAt(
|
||||
ContentKey contentKey,
|
||||
long actualSize,
|
||||
String sha256,
|
||||
String strongEtag,
|
||||
Instant publishedAt) {
|
||||
return new FileRecordMutation(
|
||||
Optional.of(contentKey),
|
||||
OptionalLong.of(actualSize),
|
||||
Optional.of(sha256),
|
||||
Optional.of(strongEtag),
|
||||
Optional.empty(),
|
||||
Optional.of(publishedAt),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Records the streamed byte count and digest without publishing anything. */
|
||||
public static FileRecordMutation uploaded(long actualSize, String sha256) {
|
||||
return new FileRecordMutation(
|
||||
Optional.empty(),
|
||||
OptionalLong.of(actualSize),
|
||||
Optional.of(sha256),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same mutation with the verifier's media type attached.
|
||||
*
|
||||
* <p>Lets a publish carry the verified type in the one statement that makes the file readable,
|
||||
* instead of a follow-up write. An absent verdict leaves the column alone rather than clearing
|
||||
* it, which is the same omission rule the rest of this type follows.
|
||||
*/
|
||||
public FileRecordMutation withVerifiedMediaType(Optional<String> mediaType) {
|
||||
Objects.requireNonNull(mediaType, "mediaType");
|
||||
return mediaType.isEmpty()
|
||||
? this
|
||||
: new FileRecordMutation(
|
||||
contentKey, actualSize, sha256, strongEtag, mediaType, publishedAt, lastErrorCode);
|
||||
}
|
||||
|
||||
/** Records the verifier-established media type. */
|
||||
public static FileRecordMutation verified(String verifiedMediaType) {
|
||||
return new FileRecordMutation(
|
||||
Optional.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(verifiedMediaType),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** Records a stable failure code that the recovery policy later reads. */
|
||||
public static FileRecordMutation failure(String lastErrorCode) {
|
||||
return new FileRecordMutation(
|
||||
Optional.empty(),
|
||||
OptionalLong.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(lastErrorCode));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Bounded query for records that may need reconciliation.
|
||||
*
|
||||
* <p>The limit is mandatory: recovery scans are always bounded so a large backlog cannot turn into
|
||||
* an unbounded scan.
|
||||
*/
|
||||
public record FileRecoveryQuery(Set<FileState> states, Instant notUpdatedSince, int limit) {
|
||||
|
||||
public FileRecoveryQuery {
|
||||
Objects.requireNonNull(states, "states");
|
||||
Objects.requireNonNull(notUpdatedSince, "notUpdatedSince");
|
||||
if (states.isEmpty()) {
|
||||
throw new IllegalArgumentException("at least one state is required");
|
||||
}
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
throw new IllegalArgumentException("limit must be between 1 and 1000");
|
||||
}
|
||||
states = Set.copyOf(states);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Durable claim on storage capacity held while an upload is in flight.
|
||||
*
|
||||
* <p>Reserved bytes are converted into committed usage only after the actual byte count is known,
|
||||
* so an over-reservation never becomes permanent consumption.
|
||||
*/
|
||||
public record QuotaReservation(
|
||||
UUID reservationId,
|
||||
QuotaScope scope,
|
||||
long reservedBytes,
|
||||
long committedBytes,
|
||||
Instant expiresAt,
|
||||
QuotaReservationStatus status,
|
||||
long version) {
|
||||
|
||||
public QuotaReservation {
|
||||
Objects.requireNonNull(reservationId, "reservationId");
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(status, "status");
|
||||
if (reservedBytes < 0 || committedBytes < 0) {
|
||||
throw new IllegalArgumentException("quota byte counts must not be negative");
|
||||
}
|
||||
if (version < 0) {
|
||||
throw new IllegalArgumentException("version must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
/**
|
||||
* Lifecycle of a quota reservation.
|
||||
*
|
||||
* <p>A reservation that is neither committed nor released before its expiry is reclaimed by the
|
||||
* cleanup worker so an abandoned upload cannot hold capacity forever.
|
||||
*/
|
||||
public enum QuotaReservationStatus {
|
||||
RESERVED,
|
||||
COMMITTED,
|
||||
RELEASED,
|
||||
EXPIRED
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Accounting boundary for reservations, commits, and concurrency permits.
|
||||
*
|
||||
* <p>A scope is a bounded label such as a tenant or a namespace. It is never a user identifier that
|
||||
* would make a metric or a log line high-cardinality.
|
||||
*/
|
||||
public record QuotaScope(String type, String value) {
|
||||
|
||||
public QuotaScope {
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (type.isBlank() || value.isBlank()) {
|
||||
throw new IllegalArgumentException("quota scope must be non-blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static QuotaScope ofNamespace(String namespace) {
|
||||
return new QuotaScope("namespace", namespace);
|
||||
}
|
||||
|
||||
public static QuotaScope ofTenant(String tenant) {
|
||||
return new QuotaScope("tenant", tenant);
|
||||
}
|
||||
|
||||
/** Stable key used for lock ordering and permit maps. */
|
||||
public String canonicalKey() {
|
||||
return type + ':' + value;
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Durable state of one resumable upload.
|
||||
*
|
||||
* <p>{@code committedOffset} advances only by bytes proven durable, and the lease columns make the
|
||||
* single-writer rule enforceable across instances.
|
||||
*/
|
||||
public record UploadSession(
|
||||
UploadId uploadId,
|
||||
FileId fileId,
|
||||
UploadProtocol protocol,
|
||||
OptionalLong expectedLength,
|
||||
long committedOffset,
|
||||
Instant expiresAt,
|
||||
Optional<String> leaseOwner,
|
||||
Optional<UUID> leaseToken,
|
||||
Optional<Instant> leaseUntil,
|
||||
long version,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
|
||||
public UploadSession {
|
||||
Objects.requireNonNull(uploadId, "uploadId");
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(protocol, "protocol");
|
||||
Objects.requireNonNull(expectedLength, "expectedLength");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(leaseOwner, "leaseOwner");
|
||||
Objects.requireNonNull(leaseToken, "leaseToken");
|
||||
Objects.requireNonNull(leaseUntil, "leaseUntil");
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (committedOffset < 0) {
|
||||
throw new IllegalArgumentException("committedOffset must not be negative");
|
||||
}
|
||||
if (version < 0) {
|
||||
throw new IllegalArgumentException("version must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isExpiredAt(Instant now) {
|
||||
return !now.isBefore(expiresAt);
|
||||
}
|
||||
|
||||
/** True when the declared length is known and already fully committed. */
|
||||
public boolean isComplete() {
|
||||
return expectedLength.isPresent() && expectedLength.getAsLong() == committedOffset;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/** Insert payload for a new upload resource. */
|
||||
public record UploadSessionDraft(
|
||||
UploadId uploadId,
|
||||
FileId fileId,
|
||||
UploadProtocol protocol,
|
||||
OptionalLong expectedLength,
|
||||
Instant expiresAt) {
|
||||
|
||||
public UploadSessionDraft {
|
||||
Objects.requireNonNull(uploadId, "uploadId");
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(protocol, "protocol");
|
||||
Objects.requireNonNull(expectedLength, "expectedLength");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Upload resource and writer-lease port.
|
||||
*
|
||||
* <p>An offset commit always requires the lease token and the expected offset, so a writer whose
|
||||
* lease was taken over cannot advance the session.
|
||||
*/
|
||||
public interface UploadSessionStore {
|
||||
|
||||
UploadSession create(UploadSessionDraft draft);
|
||||
|
||||
Optional<UploadSession> find(UploadId uploadId);
|
||||
|
||||
WriterLease acquireLease(
|
||||
UploadId uploadId, String owner, Instant now, Duration leaseDuration, long expectedVersion);
|
||||
|
||||
/**
|
||||
* Extends a lease this node still holds.
|
||||
*
|
||||
* <p>Distinct from {@link #acquireLease} on purpose. Acquisition is only legal when no lease is
|
||||
* held or the held one has expired, which is exactly false for the writer that is mid-transfer;
|
||||
* routing renewal through acquisition would make every heartbeat fail and leave long uploads with
|
||||
* no way to keep the lease they are actively using.
|
||||
*
|
||||
* @throws dev.caskeleton.application.fileserver.api.error.ConcurrentFileModificationException
|
||||
* when the lease has expired or was taken over
|
||||
*/
|
||||
WriterLease renewLease(WriterLease lease, Instant now, Duration leaseDuration);
|
||||
|
||||
UploadSession commitOffset(
|
||||
UploadId uploadId, WriterLease lease, long expectedOffset, long committedOffset);
|
||||
|
||||
void releaseLease(UploadId uploadId, WriterLease lease);
|
||||
|
||||
List<UploadSession> findExpired(Instant cutoff, int limit);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.fileserver.api.metadata;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The single right to append to one upload resource.
|
||||
*
|
||||
* <p>The lease is acquired by a conditional database update. A local file lock or an NFS lock is
|
||||
* never used as the correctness mechanism, so a paused writer whose lease expired can no longer
|
||||
* commit.
|
||||
*/
|
||||
public record WriterLease(
|
||||
UploadId uploadId, String owner, UUID token, Instant expiresAt, long version) {
|
||||
|
||||
public WriterLease {
|
||||
Objects.requireNonNull(uploadId, "uploadId");
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(token, "token");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (owner.isBlank()) {
|
||||
throw new IllegalArgumentException("lease owner must be non-blank");
|
||||
}
|
||||
if (version < 0) {
|
||||
throw new IllegalArgumentException("version must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isExpiredAt(Instant now) {
|
||||
return !now.isBefore(expiresAt);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.metadata.FileDescriptor;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Authorization hook the Fileserver calls on every public operation.
|
||||
*
|
||||
* <p>The Fileserver contains no business authorization rule of its own. The starter must not create
|
||||
* an allow-all implementation in a production profile: a missing policy is a startup failure, not a
|
||||
* silent permit.
|
||||
*/
|
||||
public interface FileAccessPolicy {
|
||||
|
||||
/**
|
||||
* Authorizes {@code operation}, throwing when it is denied.
|
||||
*
|
||||
* <p>{@code descriptor} is empty for operations that run before a file record exists.
|
||||
*/
|
||||
void authorize(
|
||||
FileOperation operation, FileAccessSubject subject, Optional<FileDescriptor> descriptor);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Framework-free caller identity handed to the access policy.
|
||||
*
|
||||
* <p>The Fileserver never interprets these values: it only passes them to the injected policy. No
|
||||
* Spring Security type appears here.
|
||||
*/
|
||||
public record FileAccessSubject(
|
||||
String principalId, Set<String> roles, Map<String, String> attributes) {
|
||||
|
||||
public FileAccessSubject {
|
||||
Objects.requireNonNull(principalId, "principalId");
|
||||
Objects.requireNonNull(roles, "roles");
|
||||
Objects.requireNonNull(attributes, "attributes");
|
||||
roles = Set.copyOf(roles);
|
||||
attributes = Map.copyOf(attributes);
|
||||
}
|
||||
|
||||
/** Unauthenticated caller; a production policy is expected to deny it. */
|
||||
public static FileAccessSubject anonymous() {
|
||||
return new FileAccessSubject("anonymous", Set.of(), Map.of());
|
||||
}
|
||||
|
||||
public static FileAccessSubject of(String principalId, Set<String> roles) {
|
||||
return new FileAccessSubject(principalId, roles, Map.of());
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
/**
|
||||
* Operations the access policy is consulted for.
|
||||
*
|
||||
* <p>Every public entry point maps to exactly one value; there is no unchecked operation.
|
||||
*/
|
||||
public enum FileOperation {
|
||||
CREATE,
|
||||
APPEND,
|
||||
FINALIZE,
|
||||
READ_METADATA,
|
||||
DOWNLOAD,
|
||||
DELETE,
|
||||
COPY,
|
||||
MOVE,
|
||||
ADMIN_REVERIFY,
|
||||
ADMIN_FORCE_DELETE
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* One step of the verification pipeline.
|
||||
*
|
||||
* <p>A verifier returns only a stable code and bounded safe metadata. It never logs a content
|
||||
* sample, and a failure to reach an external scanner is {@link VerificationVerdict#RETRY}, never
|
||||
* {@link VerificationVerdict#ACCEPT}.
|
||||
*/
|
||||
public interface FileVerifier {
|
||||
|
||||
String verifierId();
|
||||
|
||||
CompletionStage<VerificationResult> verify(VerificationRequest request);
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Turns an untrusted client filename into display-only text.
|
||||
*
|
||||
* <p>The result is used for {@code Content-Disposition} and for the untrusted {@code originalName}
|
||||
* metadata column. It is never a physical filename, a path component, or a security verdict input.
|
||||
*/
|
||||
public final class OriginalFilenamePolicy {
|
||||
|
||||
private static final String FALLBACK = "file";
|
||||
|
||||
private static final Set<String> WINDOWS_RESERVED =
|
||||
Set.of(
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
|
||||
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9");
|
||||
|
||||
/**
|
||||
* Structural characters that must never survive into a name.
|
||||
*
|
||||
* <p>Path separators and NUL are the obvious ones. The colon is here because on Windows it opens
|
||||
* both a drive reference ({@code C:\...}) and an NTFS alternate data stream ({@code
|
||||
* name.txt:hidden}), so a name that keeps it is still path-shaped even after the slashes are
|
||||
* gone. Quote characters would break a header value.
|
||||
*/
|
||||
private static final Pattern STRUCTURAL = Pattern.compile("[/\\\\\\u0000\"';:]");
|
||||
|
||||
/** C0 and C1 control characters, including CR and LF. */
|
||||
private static final Pattern CONTROL = Pattern.compile("[\\p{Cntrl}\\u007f-\\u009f]");
|
||||
|
||||
/** Bidirectional override and isolate characters used for filename spoofing. */
|
||||
private static final Pattern BIDI =
|
||||
Pattern.compile("[\\u202a-\\u202e\\u2066-\\u2069\\u200e\\u200f]");
|
||||
|
||||
/** Runs of dots, which would otherwise leave a traversal-shaped display name. */
|
||||
private static final Pattern DOT_RUN = Pattern.compile("\\.{2,}");
|
||||
|
||||
private final int maximumByteLength;
|
||||
|
||||
public OriginalFilenamePolicy(int maximumByteLength) {
|
||||
if (maximumByteLength < 8) {
|
||||
throw new IllegalArgumentException("maximumByteLength must be at least 8");
|
||||
}
|
||||
this.maximumByteLength = maximumByteLength;
|
||||
}
|
||||
|
||||
/** Policy with the design's 255-byte UTF-8 bound. */
|
||||
public static OriginalFilenamePolicy standard() {
|
||||
return new OriginalFilenamePolicy(255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes {@code candidate}.
|
||||
*
|
||||
* <p>The steps run in a fixed order so the result is deterministic: remove structural and control
|
||||
* characters, collapse dot runs, trim leading dots and trailing dots or spaces, guard Windows
|
||||
* reserved names, then bound the UTF-8 length while preserving the final extension when it fits.
|
||||
*/
|
||||
public SanitizedFilename sanitize(String candidate) {
|
||||
if (candidate == null) {
|
||||
return new SanitizedFilename(FALLBACK);
|
||||
}
|
||||
String working = STRUCTURAL.matcher(candidate).replaceAll("");
|
||||
working = CONTROL.matcher(working).replaceAll("");
|
||||
working = BIDI.matcher(working).replaceAll("");
|
||||
working = DOT_RUN.matcher(working).replaceAll(".");
|
||||
working = stripLeading(working, '.');
|
||||
working = stripTrailing(working);
|
||||
working = guardReservedName(working);
|
||||
working = truncateToByteLength(working);
|
||||
working = stripTrailing(working);
|
||||
if (working.isEmpty()) {
|
||||
working = FALLBACK;
|
||||
}
|
||||
return new SanitizedFilename(working);
|
||||
}
|
||||
|
||||
private static String stripLeading(String value, char unwanted) {
|
||||
int start = 0;
|
||||
while (start < value.length() && value.charAt(start) == unwanted) {
|
||||
start++;
|
||||
}
|
||||
return value.substring(start);
|
||||
}
|
||||
|
||||
private static String stripTrailing(String value) {
|
||||
int end = value.length();
|
||||
while (end > 0) {
|
||||
char last = value.charAt(end - 1);
|
||||
if (last == '.' || last == ' ') {
|
||||
end--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value.substring(0, end).trim();
|
||||
}
|
||||
|
||||
private static String guardReservedName(String value) {
|
||||
if (value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
int dot = value.indexOf('.');
|
||||
String stem = dot < 0 ? value : value.substring(0, dot);
|
||||
if (WINDOWS_RESERVED.contains(stem.toUpperCase(Locale.ROOT))) {
|
||||
return "_" + value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the UTF-8 byte length.
|
||||
*
|
||||
* <p>The final extension is preserved when it still fits, because losing it would change how the
|
||||
* client offers the download even though the stored media type is unaffected.
|
||||
*/
|
||||
private String truncateToByteLength(String value) {
|
||||
if (utf8Length(value) <= maximumByteLength) {
|
||||
return value;
|
||||
}
|
||||
int lastDot = value.lastIndexOf('.');
|
||||
String extension = lastDot > 0 ? value.substring(lastDot) : "";
|
||||
if (utf8Length(extension) > maximumByteLength / 2) {
|
||||
extension = "";
|
||||
}
|
||||
String stem = extension.isEmpty() ? value : value.substring(0, lastDot);
|
||||
int budget = maximumByteLength - utf8Length(extension);
|
||||
return truncateCodePoints(stem, budget) + extension;
|
||||
}
|
||||
|
||||
private static String truncateCodePoints(String value, int budget) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
int used = 0;
|
||||
for (int index = 0; index < value.length(); ) {
|
||||
int codePoint = value.codePointAt(index);
|
||||
int width = utf8Length(new String(Character.toChars(codePoint)));
|
||||
if (used + width > budget) {
|
||||
break;
|
||||
}
|
||||
builder.appendCodePoint(codePoint);
|
||||
used += width;
|
||||
index += Character.charCount(codePoint);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static int utf8Length(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ambient information one Fileserver call carries.
|
||||
*
|
||||
* <p>{@code instanceId} is the writer-lease owner for this node. {@code traceId} correlates
|
||||
* observability without becoming a metric label.
|
||||
*/
|
||||
public record RequestContext(FileAccessSubject subject, String traceId, String instanceId) {
|
||||
|
||||
public RequestContext {
|
||||
Objects.requireNonNull(subject, "subject");
|
||||
Objects.requireNonNull(traceId, "traceId");
|
||||
Objects.requireNonNull(instanceId, "instanceId");
|
||||
if (instanceId.isBlank()) {
|
||||
throw new IllegalArgumentException("instanceId must be non-blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Display filename that is safe to place in a {@code Content-Disposition} header.
|
||||
*
|
||||
* <p>A sanitized name is never used to build a physical path or a content key: the physical object
|
||||
* is named from server-generated identity only.
|
||||
*/
|
||||
public record SanitizedFilename(String value) {
|
||||
|
||||
public SanitizedFilename {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("sanitized filename must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
/** UTF-8 length of the sanitized name, which the policy bounds. */
|
||||
public int byteLength() {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Everything a verifier is allowed to know about the object under inspection.
|
||||
*
|
||||
* <p>The claimed media type and the sanitized filename are present as untrusted hints. A verifier
|
||||
* that needs bytes reads them through the content store using {@code stagingUploadId} or {@code
|
||||
* contentKey}; the request itself never carries a path or a stream.
|
||||
*/
|
||||
public record VerificationRequest(
|
||||
FileId fileId,
|
||||
Optional<UploadId> stagingUploadId,
|
||||
Optional<ContentKey> contentKey,
|
||||
long size,
|
||||
String sha256,
|
||||
Optional<String> claimedMediaType,
|
||||
SanitizedFilename sanitizedFilename) {
|
||||
|
||||
public VerificationRequest {
|
||||
Objects.requireNonNull(fileId, "fileId");
|
||||
Objects.requireNonNull(stagingUploadId, "stagingUploadId");
|
||||
Objects.requireNonNull(contentKey, "contentKey");
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
Objects.requireNonNull(claimedMediaType, "claimedMediaType");
|
||||
Objects.requireNonNull(sanitizedFilename, "sanitizedFilename");
|
||||
if (size < 0) {
|
||||
throw new IllegalArgumentException("size must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A verifier's answer.
|
||||
*
|
||||
* <p>{@code safeMetadata} is a bounded, already-sanitized map. A scanner's raw response, a content
|
||||
* sample, and any credential are deliberately absent.
|
||||
*/
|
||||
public record VerificationResult(
|
||||
VerificationVerdict verdict,
|
||||
String code,
|
||||
Optional<String> verifiedMediaType,
|
||||
Map<String, String> safeMetadata) {
|
||||
|
||||
public VerificationResult {
|
||||
Objects.requireNonNull(verdict, "verdict");
|
||||
Objects.requireNonNull(code, "code");
|
||||
Objects.requireNonNull(verifiedMediaType, "verifiedMediaType");
|
||||
Objects.requireNonNull(safeMetadata, "safeMetadata");
|
||||
if (code.isBlank()) {
|
||||
throw new IllegalArgumentException("verification code must be non-blank");
|
||||
}
|
||||
safeMetadata = Map.copyOf(safeMetadata);
|
||||
}
|
||||
|
||||
public static VerificationResult accept(String code) {
|
||||
return new VerificationResult(VerificationVerdict.ACCEPT, code, Optional.empty(), Map.of());
|
||||
}
|
||||
|
||||
public static VerificationResult accept(String code, String verifiedMediaType) {
|
||||
return new VerificationResult(
|
||||
VerificationVerdict.ACCEPT, code, Optional.of(verifiedMediaType), Map.of());
|
||||
}
|
||||
|
||||
public static VerificationResult reject(String code) {
|
||||
return new VerificationResult(VerificationVerdict.REJECT, code, Optional.empty(), Map.of());
|
||||
}
|
||||
|
||||
public static VerificationResult quarantine(String code) {
|
||||
return new VerificationResult(VerificationVerdict.QUARANTINE, code, Optional.empty(), Map.of());
|
||||
}
|
||||
|
||||
public static VerificationResult retry(String code) {
|
||||
return new VerificationResult(VerificationVerdict.RETRY, code, Optional.empty(), Map.of());
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.application.fileserver.api.security;
|
||||
|
||||
/**
|
||||
* Outcome of one verifier or of the whole pipeline.
|
||||
*
|
||||
* <p>Combination precedence is {@code REJECT > QUARANTINE > RETRY > ACCEPT}. A scanner timeout is
|
||||
* {@link #RETRY} and is never silently promoted to {@link #ACCEPT}.
|
||||
*/
|
||||
public enum VerificationVerdict {
|
||||
ACCEPT,
|
||||
QUARANTINE,
|
||||
REJECT,
|
||||
RETRY;
|
||||
|
||||
/** Rank used by the policy combiner; a higher rank dominates. */
|
||||
public int precedence() {
|
||||
return switch (this) {
|
||||
case REJECT -> 3;
|
||||
case QUARANTINE -> 2;
|
||||
case RETRY -> 1;
|
||||
case ACCEPT -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
/** True when this verdict permits publishing content. */
|
||||
public boolean allowsPublish() {
|
||||
return this == ACCEPT;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.application.fileserver.api.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Transport-neutral view of the conditional and range headers of one request.
|
||||
*
|
||||
* <p>Adapters translate their own header types into this record so the decision logic is shared and
|
||||
* cannot diverge between MVC and WebFlux.
|
||||
*/
|
||||
public record ConditionalRequest(
|
||||
Optional<String> ifMatch,
|
||||
Optional<String> ifNoneMatch,
|
||||
Optional<Instant> ifModifiedSince,
|
||||
Optional<Instant> ifUnmodifiedSince,
|
||||
Optional<String> ifRange,
|
||||
Optional<String> range,
|
||||
boolean headOnly) {
|
||||
|
||||
public ConditionalRequest {
|
||||
Objects.requireNonNull(ifMatch, "ifMatch");
|
||||
Objects.requireNonNull(ifNoneMatch, "ifNoneMatch");
|
||||
Objects.requireNonNull(ifModifiedSince, "ifModifiedSince");
|
||||
Objects.requireNonNull(ifUnmodifiedSince, "ifUnmodifiedSince");
|
||||
Objects.requireNonNull(ifRange, "ifRange");
|
||||
Objects.requireNonNull(range, "range");
|
||||
}
|
||||
|
||||
/** Unconditional full-representation GET. */
|
||||
public static ConditionalRequest plainGet() {
|
||||
return new ConditionalRequest(
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
false);
|
||||
}
|
||||
|
||||
/** Unconditional GET restricted to one range. */
|
||||
public static ConditionalRequest rangeGet(String range) {
|
||||
return new ConditionalRequest(
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(range),
|
||||
false);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.fileserver.api.transfer;
|
||||
|
||||
/**
|
||||
* Applies the conditional and range rules in the exact order the design fixes.
|
||||
*
|
||||
* <p>Order is the contract: {@code If-Match} and {@code If-Unmodified-Since} first, then {@code
|
||||
* If-None-Match} and {@code If-Modified-Since}, then range parsing, then {@code If-Range}.
|
||||
*/
|
||||
public interface ConditionalRequestEvaluator {
|
||||
|
||||
DownloadDecision evaluate(
|
||||
ConditionalRequest request, FileRepresentation representation, RangeBudget budget);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.application.fileserver.api.transfer;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.security.SanitizedFilename;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Builds a {@code Content-Disposition} value that cannot carry an injection.
|
||||
*
|
||||
* <p>The ASCII {@code filename} form is restricted to a safe subset and the UTF-8 {@code filename*}
|
||||
* form is percent-encoded, so no quote, separator, or newline can escape the header. Scriptable
|
||||
* media types are always offered as an attachment unless the caller explicitly opted into an inline
|
||||
* safe profile.
|
||||
*/
|
||||
public final class ContentDispositionFactory {
|
||||
|
||||
private static final Set<String> SCRIPTABLE_MEDIA_TYPES =
|
||||
Set.of(
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"image/svg+xml",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"application/xhtml",
|
||||
"text/javascript",
|
||||
"application/javascript");
|
||||
|
||||
private static final String FALLBACK_ASCII = "file";
|
||||
|
||||
/** Attachment disposition, the default for every download. */
|
||||
public String attachment(SanitizedFilename filename) {
|
||||
return build("attachment", filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline disposition when, and only when, the media type is not scriptable.
|
||||
*
|
||||
* <p>Serving HTML or SVG inline from a user-upload origin is a stored cross-site scripting
|
||||
* primitive, so it degrades to an attachment instead of trusting the caller.
|
||||
*/
|
||||
public String inlineOrAttachment(SanitizedFilename filename, String mediaType) {
|
||||
boolean scriptable =
|
||||
mediaType != null && SCRIPTABLE_MEDIA_TYPES.contains(baseMediaType(mediaType));
|
||||
return build(scriptable ? "attachment" : "inline", filename);
|
||||
}
|
||||
|
||||
/** True when {@code mediaType} must never be rendered inline from an upload origin. */
|
||||
public boolean isScriptable(String mediaType) {
|
||||
return mediaType != null && SCRIPTABLE_MEDIA_TYPES.contains(baseMediaType(mediaType));
|
||||
}
|
||||
|
||||
private static String baseMediaType(String mediaType) {
|
||||
int semicolon = mediaType.indexOf(';');
|
||||
String base = semicolon < 0 ? mediaType : mediaType.substring(0, semicolon);
|
||||
return base.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String build(String disposition, SanitizedFilename filename) {
|
||||
String ascii = toSafeAscii(filename.value());
|
||||
String encoded = percentEncodeUtf8(filename.value());
|
||||
return disposition + "; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded;
|
||||
}
|
||||
|
||||
/** Reduces the name to a conservative ASCII subset for the legacy {@code filename} parameter. */
|
||||
private static String toSafeAscii(String value) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
boolean safe =
|
||||
(character >= 'a' && character <= 'z')
|
||||
|| (character >= 'A' && character <= 'Z')
|
||||
|| (character >= '0' && character <= '9')
|
||||
|| character == '.'
|
||||
|| character == '-'
|
||||
|| character == '_'
|
||||
|| character == ' ';
|
||||
if (safe) {
|
||||
builder.append(character);
|
||||
}
|
||||
}
|
||||
String ascii = builder.toString().trim();
|
||||
return ascii.isEmpty() ? FALLBACK_ASCII : ascii;
|
||||
}
|
||||
|
||||
private static String percentEncodeUtf8(String value) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte raw : value.getBytes(StandardCharsets.UTF_8)) {
|
||||
int unsigned = raw & 0xFF;
|
||||
boolean unreserved =
|
||||
(unsigned >= 'a' && unsigned <= 'z')
|
||||
|| (unsigned >= 'A' && unsigned <= 'Z')
|
||||
|| (unsigned >= '0' && unsigned <= '9')
|
||||
|| unsigned == '.'
|
||||
|| unsigned == '-'
|
||||
|| unsigned == '_'
|
||||
|| unsigned == '~';
|
||||
if (unreserved) {
|
||||
builder.append((char) unsigned);
|
||||
} else {
|
||||
builder.append('%').append(String.format(Locale.ROOT, "%02X", unsigned));
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package dev.caskeleton.application.fileserver.api.transfer;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.error.RangeNotSatisfiableException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The design's response-decision order, implemented once.
|
||||
*
|
||||
* <p>A mismatched {@code If-Range} silently degrades to the full representation rather than
|
||||
* failing, because the client's cached validator is simply stale. An unsatisfiable {@code Range}
|
||||
* still surfaces as {@code 416} so the client learns the real representation length.
|
||||
*/
|
||||
public final class DefaultConditionalRequestEvaluator implements ConditionalRequestEvaluator {
|
||||
|
||||
private final HttpRangeResolver rangeResolver;
|
||||
|
||||
public DefaultConditionalRequestEvaluator(HttpRangeResolver rangeResolver) {
|
||||
this.rangeResolver = rangeResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadDecision evaluate(
|
||||
ConditionalRequest request, FileRepresentation representation, RangeBudget budget) {
|
||||
if (failsIfMatch(request, representation) || failsIfUnmodifiedSince(request, representation)) {
|
||||
return DownloadDecision.preconditionFailed(representation);
|
||||
}
|
||||
if (matchesIfNoneMatch(request, representation)
|
||||
|| isNotModifiedSince(request, representation)) {
|
||||
return DownloadDecision.notModified(representation);
|
||||
}
|
||||
|
||||
boolean bodyExpected = !request.headOnly();
|
||||
if (request.range().isEmpty()) {
|
||||
return DownloadDecision.full(representation, bodyExpected);
|
||||
}
|
||||
if (!ifRangeMatches(request, representation)) {
|
||||
return DownloadDecision.full(representation, bodyExpected);
|
||||
}
|
||||
|
||||
ResolvedRanges resolved =
|
||||
rangeResolver.resolve(request.range().get(), representation.length(), budget);
|
||||
if (!resolved.isPartial()) {
|
||||
return DownloadDecision.full(representation, bodyExpected);
|
||||
}
|
||||
return DownloadDecision.partial(representation, resolved.ranges(), bodyExpected);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code If-Match} guards a lost update; a wildcard always matches an existing representation.
|
||||
*/
|
||||
private static boolean failsIfMatch(
|
||||
ConditionalRequest request, FileRepresentation representation) {
|
||||
Optional<String> ifMatch = request.ifMatch();
|
||||
if (ifMatch.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String value = ifMatch.get().trim();
|
||||
if ("*".equals(value)) {
|
||||
return false;
|
||||
}
|
||||
return !containsStrongEtag(value, representation.strongEtag());
|
||||
}
|
||||
|
||||
private static boolean failsIfUnmodifiedSince(
|
||||
ConditionalRequest request, FileRepresentation representation) {
|
||||
return request.ifUnmodifiedSince().isPresent()
|
||||
&& representation.lastModified().isAfter(request.ifUnmodifiedSince().get());
|
||||
}
|
||||
|
||||
private static boolean matchesIfNoneMatch(
|
||||
ConditionalRequest request, FileRepresentation representation) {
|
||||
Optional<String> ifNoneMatch = request.ifNoneMatch();
|
||||
if (ifNoneMatch.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String value = ifNoneMatch.get().trim();
|
||||
return "*".equals(value) || containsStrongEtag(value, representation.strongEtag());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code If-Modified-Since} is only consulted when no entity tag was supplied.
|
||||
*
|
||||
* <p>An entity tag is the stronger validator, so honouring both would let a coarse timestamp
|
||||
* override it.
|
||||
*/
|
||||
private static boolean isNotModifiedSince(
|
||||
ConditionalRequest request, FileRepresentation representation) {
|
||||
if (request.ifNoneMatch().isPresent() || request.ifModifiedSince().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Instant since = request.ifModifiedSince().get();
|
||||
return !representation.lastModified().isAfter(since);
|
||||
}
|
||||
|
||||
private static boolean ifRangeMatches(
|
||||
ConditionalRequest request, FileRepresentation representation) {
|
||||
Optional<String> ifRange = request.ifRange();
|
||||
return ifRange.isEmpty()
|
||||
|| containsStrongEtag(ifRange.get().trim(), representation.strongEtag());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares against a strong validator list.
|
||||
*
|
||||
* <p>A weak entity tag never satisfies a range or update precondition, so the {@code W/} form is
|
||||
* deliberately not accepted here.
|
||||
*/
|
||||
private static boolean containsStrongEtag(String headerValue, String strongEtag) {
|
||||
for (String candidate : List.of(headerValue.split(","))) {
|
||||
String trimmed = candidate.trim();
|
||||
if (trimmed.startsWith("W/")) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.equals(strongEtag)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Re-exposes the unsatisfiable-range failure type so adapters do not import the resolver. */
|
||||
public static long representationLengthOf(RangeNotSatisfiableException exception) {
|
||||
return exception.representationLength();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user