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:
DongHyeonka
2026-08-11 16:48:43 +09:00
co-authored by Claude Opus 5
parent 1a3b560678
commit 5f10b791d3
1857 changed files with 130925 additions and 72491 deletions
+17 -24
View File
@@ -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'
}
+1
View File
@@ -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
@@ -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
@@ -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) {
@@ -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)");
}
}
}
@@ -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);
}
@@ -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");
}
}
@@ -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);
}
@@ -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)));
}
}
@@ -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);
}
@@ -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");
}
}
}
@@ -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");
}
}
@@ -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");
}
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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");
}
}
@@ -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();
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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");
}
}
@@ -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
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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 () -> {};
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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('_', '-');
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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");
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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");
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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());
}
}
@@ -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
}
@@ -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);
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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