Add ObjectStore source

This commit is contained in:
LunarSkyOSS committed 2026-10-09 15:23:16 +02:00
1 parent 8a3bee5dcc
commit 51e206f8df
14 files changed
+673

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.git
.env
data
out
*.log
+9
View File
@@ -0,0 +1,9 @@
# Copy to .env and replace both empty values with random credentials.
# The Compose file refuses to start without them.
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=objects
S3_REGION=us-east-1
HOST_PORT=9000
MAX_OBJECT_BYTES=10485760
MAX_TOTAL_BYTES=2147483648
+20
View File
@@ -0,0 +1,20 @@
name: Tests
on:
push:
pull_request:
permissions:
contents: read
jobs:
java:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- run: sh scripts/test.sh
- run: docker build .
+4
View File
@@ -0,0 +1,4 @@
out/
.env
data/
*.log
+14
View File
@@ -0,0 +1,14 @@
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /src
COPY src ./src
COPY test ./test
RUN mkdir /out && javac --release 21 --add-modules jdk.httpserver,java.net.http -d /out src/cloud/lunarsky/store/*.java test/cloud/lunarsky/store/*.java
RUN java --add-modules jdk.httpserver -cp /out cloud.lunarsky.store.StoreTest
RUN java --add-modules jdk.httpserver,java.net.http -cp /out cloud.lunarsky.store.HttpTest
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -g 10001 store && adduser -D -u 10001 -G store store && mkdir /data && chown store:store /data
COPY --from=build /out /app
USER store
EXPOSE 9000
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", "-Dsun.net.httpserver.maxReqTime=30", "-Dsun.net.httpserver.maxRspTime=60", "-Dsun.net.httpserver.maxReqHeaders=64", "--add-modules", "jdk.httpserver", "-cp", "/app", "cloud.lunarsky.store.Main"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Justin Haag
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
services:
objectstore:
build: .
image: lunarsky-objectstore:local
restart: unless-stopped
environment:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY in .env}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY in .env}
S3_BUCKET: ${S3_BUCKET:-objects}
S3_REGION: ${S3_REGION:-us-east-1}
MAX_OBJECT_BYTES: ${MAX_OBJECT_BYTES:-10485760}
MAX_TOTAL_BYTES: ${MAX_TOTAL_BYTES:-2147483648}
ports:
- "127.0.0.1:${HOST_PORT:-9000}:9000"
volumes:
- object-data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9000/health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 10s
mem_limit: 384m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
object-data:
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
mkdir -p out/classes
javac --release 21 --add-modules jdk.httpserver,java.net.http -d out/classes \
src/cloud/lunarsky/store/*.java test/cloud/lunarsky/store/*.java
java --add-modules jdk.httpserver -cp out/classes cloud.lunarsky.store.StoreTest
java --add-modules jdk.httpserver,java.net.http -cp out/classes cloud.lunarsky.store.HttpTest
+137
View File
@@ -0,0 +1,137 @@
package cloud.lunarsky.store;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Arrays;
import java.util.HexFormat;
final class DiskStore implements AutoCloseable {
private static final long MAGIC = 0x4c534f424a303031L;
private static final int HEADER = 72;
private final Path objects, temporary;
private final FileChannel lockChannel;
private final FileLock processLock;
private final long maxObject, maxTotal;
private final Object[] locks = new Object[128];
private long used;
record Metadata(long length, long modified, String etag, byte[] sha256) {}
record OpenObject(Metadata metadata, InputStream stream) implements AutoCloseable {
public void close() throws IOException { stream.close(); }
}
DiskStore(Path root, long maxObject, long maxTotal) throws IOException {
objects = root.resolve("objects"); temporary = root.resolve("pending");
this.maxObject = maxObject; this.maxTotal = maxTotal;
Arrays.setAll(locks, i -> new Object());
Files.createDirectories(root);
FileChannel channel = FileChannel.open(root.resolve(".process.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock acquired = null;
boolean ready = false;
try {
try { acquired = channel.tryLock(); }
catch (OverlappingFileLockException e) { throw new IOException("Data directory is already in use", e); }
if (acquired == null) throw new IOException("Data directory is already in use");
Files.createDirectories(objects); Files.createDirectories(temporary);
try (var paths=Files.list(temporary)) {
for(Path p:paths.toList()) if(p.getFileName().toString().endsWith(".part"))Files.delete(p);
}
try (var paths=Files.walk(objects)) {
for(Path p:paths.filter(Files::isRegularFile).toList()) {
try(var in=new DataInputStream(Files.newInputStream(p))) {
long length = metadata(in).length();
if(Files.size(p)-HEADER != length) throw new IOException("Truncated or oversized object record: "+p);
used = Math.addExact(used, length);
}
}
}
ready = true;
} finally {
if (!ready) {
if (acquired != null) acquired.release();
channel.close();
}
}
lockChannel = channel;
processLock = acquired;
}
@Override public void close() throws IOException {
processLock.release();
lockChannel.close();
}
private Path object(String bucket, String key) throws IOException {
String id=SigV4.hex(SigV4.hash((bucket+"/"+key).getBytes(StandardCharsets.UTF_8)));
Path shard=objects.resolve(id.substring(0,2));Files.createDirectories(shard);
return shard.resolve(id);
}
private Object lock(Path p){return locks[(p.hashCode()&0x7fffffff)%locks.length];}
Metadata put(String bucket,String key,InputStream input,long length,String expectedHash,String checksum,boolean createOnly) throws IOException {
if(length<0)throw new StoreException(411,"MissingContentLength","Content-Length is required");
if(length>maxObject)throw new StoreException(413,"EntityTooLarge","Object exceeds the configured size limit");
Path destination=object(bucket,key),pending=Files.createTempFile(temporary,"upload-",".part");
try {
MessageDigest sha=digest("SHA-256"),md5=digest("MD5");
long count=0;
try(OutputStream out=Files.newOutputStream(pending)){
out.write(new byte[HEADER]);byte[] buffer=new byte[65536];int n;
while((n=input.read(buffer))!=-1){count+=n;if(count>length||count>maxObject)throw new StoreException(413,"EntityTooLarge","Payload exceeds declared size");sha.update(buffer,0,n);md5.update(buffer,0,n);out.write(buffer,0,n);}
}
if(count!=length)throw new StoreException(400,"IncompleteBody","Payload length does not match Content-Length");
byte[] hash=sha.digest(),etag=md5.digest();
if(!MessageDigest.isEqual(hash,HexFormat.of().parseHex(expectedHash)))throw new StoreException(400,"XAmzContentSHA256Mismatch","Payload hash mismatch");
if(checksum!=null&&!java.util.Base64.getEncoder().encodeToString(hash).equals(checksum))throw new StoreException(400,"BadDigest","SHA-256 checksum mismatch");
long modified=Instant.now().toEpochMilli();
try(FileChannel file=FileChannel.open(pending,StandardOpenOption.WRITE)){
ByteBuffer header=ByteBuffer.allocate(HEADER).putLong(MAGIC).putLong(count).putLong(modified).put(etag).put(hash);header.flip();
while(header.hasRemaining())file.write(header);file.force(true);
}
synchronized(lock(destination)){
long previous=0;
if(Files.exists(destination)){
if(createOnly)throw new StoreException(412,"PreconditionFailed","Object already exists");
try(var in=new DataInputStream(Files.newInputStream(destination))){previous=metadata(in).length();}
}
synchronized(this){
if(used-previous+count>maxTotal)throw new StoreException(507,"InsufficientStorage","Store capacity limit reached");
Files.move(pending,destination,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);
used=used-previous+count;
}
}
return new Metadata(count,modified,SigV4.hex(etag),hash);
} finally {Files.deleteIfExists(pending);}
}
OpenObject open(String bucket,String key) throws IOException {
Path destination=object(bucket,key);
synchronized(lock(destination)){
final DataInputStream input;
try{input=new DataInputStream(Files.newInputStream(destination));}
catch(NoSuchFileException e){throw new StoreException(404,"NoSuchKey","Object not found");}
try{return new OpenObject(metadata(input),input);}catch(IOException e){input.close();throw e;}
}
}
void delete(String bucket,String key) throws IOException {
Path destination=object(bucket,key);
synchronized(lock(destination)){
if(!Files.exists(destination))return;
long length;try(var input=new DataInputStream(Files.newInputStream(destination))){length=metadata(input).length();}
synchronized(this){Files.delete(destination);used-=length;}
}
}
private static Metadata metadata(DataInputStream in) throws IOException {
if(in.readLong()!=MAGIC)throw new IOException("Invalid object record");
long length=in.readLong(),modified=in.readLong();byte[] md5=new byte[16],sha=new byte[32];in.readFully(md5);in.readFully(sha);
if(length<0)throw new IOException("Invalid object length");
return new Metadata(length,modified,SigV4.hex(md5),sha);
}
private static MessageDigest digest(String algorithm){try{return MessageDigest.getInstance(algorithm);}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
}
+114
View File
@@ -0,0 +1,114 @@
package cloud.lunarsky.store;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public final class Main {
private final DiskStore store;
private final SigV4 authentication;
private final String bucket;
private final Semaphore slots=new Semaphore(16);
Main(DiskStore store,SigV4 authentication,String bucket){this.store=store;this.authentication=authentication;this.bucket=bucket;}
void handle(HttpExchange exchange) throws IOException {
boolean admitted=slots.tryAcquire();
String requestId=UUID.randomUUID().toString();
exchange.getResponseHeaders().set("x-amz-request-id",requestId);
exchange.getResponseHeaders().set("X-Content-Type-Options","nosniff");
try {
if(!admitted)throw new StoreException(503,"SlowDown","Too many concurrent requests");
if(exchange.getRequestURI().getRawPath().equals("/health")&&exchange.getRequestMethod().equals("GET")){
byte[] body="{\"status\":\"ok\",\"service\":\"lunarsky-objectstore\"}".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type","application/json");exchange.sendResponseHeaders(200,body.length);exchange.getResponseBody().write(body);return;
}
String hash=authentication.verify(exchange.getRequestMethod(),exchange.getRequestURI(),exchange.getRequestHeaders());
String path=SigV4.decode(exchange.getRequestURI().getRawPath());
String prefix="/"+bucket+"/";
if(!path.startsWith(prefix))throw new StoreException(404,"NoSuchBucket","Bucket not found");
String key=path.substring(prefix.length());
if(key.isEmpty()||key.getBytes(StandardCharsets.UTF_8).length>1024||key.indexOf('\0')>=0)throw new StoreException(400,"InvalidArgument","Invalid object key");
String query=exchange.getRequestURI().getRawQuery();
if(query!=null&&!query.isEmpty()&&!query.matches("x-id=(PutObject|GetObject|HeadObject|DeleteObject)"))unsupported("Query operation");
var headers=exchange.getRequestHeaders();
if(headers.containsKey("range")||headers.containsKey("if-match")||headers.containsKey("if-modified-since")||headers.containsKey("if-unmodified-since"))unsupported("Range or conditional read");
for(String name:headers.keySet()){
String lower=name.toLowerCase(java.util.Locale.ROOT);
if(lower.startsWith("x-amz-")&&!java.util.Set.of("x-amz-date","x-amz-content-sha256","x-amz-checksum-sha256","x-amz-sdk-checksum-algorithm","x-amz-user-agent").contains(lower))unsupported("Amazon header");
if(lower.startsWith("x-amz-meta-")||lower.startsWith("x-amz-server-side-")||lower.startsWith("x-amz-copy-")||lower.startsWith("x-amz-acl")||lower.startsWith("x-amz-grant")||lower.startsWith("x-amz-tagging")||lower.equals("content-md5"))unsupported("Object metadata, encryption, ACL, copy, tagging or MD5 header");
if(lower.startsWith("x-amz-checksum-")&&!lower.equals("x-amz-checksum-sha256"))unsupported("Checksum algorithm");
}
String method=exchange.getRequestMethod();
String algorithm=SigV4.single(headers,"x-amz-sdk-checksum-algorithm");
if(algorithm!=null&&!algorithm.equals("SHA256"))unsupported("Checksum algorithm");
if(!method.equals("PUT")&&(headers.containsKey("transfer-encoding")||(headers.containsKey("content-length")&&!"0".equals(SigV4.single(headers,"content-length")))))throw new StoreException(400,"InvalidRequest","Read/delete requests must have empty bodies");
if(!method.equals("PUT")&&!hash.equals(SigV4.hex(SigV4.hash(new byte[0]))))throw new StoreException(400,"InvalidRequest","Read/delete requests must have empty bodies");
switch(method){
case "PUT" -> {
String length=SigV4.single(headers,"content-length"),condition=SigV4.single(headers,"if-none-match");
if(condition!=null&&!condition.equals("*"))unsupported("Write condition");
long bytes;try{bytes=length==null?-1:Long.parseLong(length);}catch(NumberFormatException e){throw new StoreException(400,"InvalidArgument","Invalid Content-Length");}
if(headers.containsKey("content-encoding"))unsupported("Encoded payload");
DiskStore.Metadata data=store.put(bucket,key,exchange.getRequestBody(),bytes,hash,SigV4.single(headers,"x-amz-checksum-sha256"),condition!=null);
exchange.getResponseHeaders().set("ETag","\""+data.etag()+"\"");
exchange.getResponseHeaders().set("x-amz-checksum-sha256",java.util.Base64.getEncoder().encodeToString(data.sha256()));
exchange.sendResponseHeaders(200,-1);
}
case "GET", "HEAD" -> {
if(headers.containsKey("if-none-match"))unsupported("Conditional read");
try(var object=store.open(bucket,key)){
var meta=object.metadata();
exchange.getResponseHeaders().set("Content-Type","application/octet-stream");
exchange.getResponseHeaders().set("Content-Length",Long.toString(meta.length()));
exchange.getResponseHeaders().set("ETag","\""+meta.etag()+"\"");
exchange.getResponseHeaders().set("Last-Modified",DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(meta.modified())));
if(method.equals("HEAD")||meta.length()==0)exchange.sendResponseHeaders(200,-1);
else{exchange.sendResponseHeaders(200,meta.length());object.stream().transferTo(exchange.getResponseBody());}
}
}
case "DELETE" -> {if(headers.containsKey("if-none-match"))unsupported("Conditional delete");store.delete(bucket,key);exchange.sendResponseHeaders(204,-1);}
default -> unsupported("HTTP method");
}
} catch(StoreException error){sendError(exchange,error.status,error.code,error.getMessage(),requestId);}
catch(Exception error){System.err.println("ObjectStore request failed: "+requestId+" "+error.getClass().getSimpleName());sendError(exchange,500,"InternalError","Storage operation failed",requestId);}
finally {if(admitted)slots.release();exchange.close();}
}
private static void unsupported(String feature){throw new StoreException(501,"NotImplemented",feature+" is not supported in this prototype");}
private static String xml(String text){return text.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace("\"","&quot;");}
private static void sendError(HttpExchange exchange,int status,String code,String message,String id)throws IOException{
if(exchange.getResponseCode()!=-1)return;
byte[] body=("<?xml version=\"1.0\" encoding=\"UTF-8\"?><Error><Code>"+xml(code)+"</Code><Message>"+xml(message)+"</Message><RequestId>"+id+"</RequestId></Error>").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type","application/xml");exchange.sendResponseHeaders(status,exchange.getRequestMethod().equals("HEAD")?-1:body.length);
if(!exchange.getRequestMethod().equals("HEAD"))exchange.getResponseBody().write(body);
}
public static void main(String[] args)throws Exception{
Map<String,String> env=System.getenv();
String access=required(env,"S3_ACCESS_KEY"),secret=required(env,"S3_SECRET_KEY"),bucket=env.getOrDefault("S3_BUCKET","lunaris-files"),region=env.getOrDefault("S3_REGION","us-east-1");
if(!access.matches("[A-Za-z0-9]{16,128}")||secret.length()<32||!bucket.matches("[a-z0-9][a-z0-9-]{1,61}[a-z0-9]"))throw new IllegalArgumentException("Invalid storage credentials/bucket configuration");
long maxObject=Long.parseLong(env.getOrDefault("MAX_OBJECT_BYTES","10485760")),maxTotal=Long.parseLong(env.getOrDefault("MAX_TOTAL_BYTES","2147483648"));
if(maxObject<1||maxObject>1073741824L||maxTotal<maxObject)throw new IllegalArgumentException("Invalid size limits");
var store=new DiskStore(Path.of(env.getOrDefault("DATA_DIR","/data")),maxObject,maxTotal);
var app=new Main(store,new SigV4(access,secret,region,Clock.systemUTC()),bucket);
var server=HttpServer.create(new InetSocketAddress(Integer.parseInt(env.getOrDefault("PORT","9000"))),64);
var executor=Executors.newVirtualThreadPerTaskExecutor();server.setExecutor(executor);server.createContext("/",app::handle);
Runtime.getRuntime().addShutdownHook(new Thread(()->{
server.stop(5);
executor.close();
try { store.close(); }
catch (IOException error) { System.err.println("Could not release ObjectStore data lock: "+error.getMessage()); }
}));
server.start();System.out.println("LunarSky ObjectStore listening; S3 object-operation prototype, bucket="+bucket);
}
private static String required(Map<String,String> env,String key){String value=env.get(key);if(value==null||value.isBlank())throw new IllegalArgumentException("Missing "+key);return value;}
}
+129
View File
@@ -0,0 +1,129 @@
package cloud.lunarsky.store;
import com.sun.net.httpserver.Headers;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
final class SigV4 {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
private static final Pattern HEX = Pattern.compile("[0-9a-f]{64}");
private final String accessKey, secretKey, region;
private final Clock clock;
SigV4(String accessKey, String secretKey, String region, Clock clock) {
this.accessKey = accessKey; this.secretKey = secretKey; this.region = region; this.clock = clock;
}
String verify(String method, URI uri, Headers headers) {
String authorization = single(headers, "authorization");
if (authorization == null || !authorization.startsWith("AWS4-HMAC-SHA256 ")) denied("Signed requests are required");
Map<String,String> fields = new TreeMap<>();
for (String part : authorization.substring(17).split(",")) {
String[] pair = part.trim().split("=", 2);
if (pair.length != 2 || fields.put(pair[0], pair[1]) != null) denied("Invalid authorization header");
}
if (!fields.keySet().equals(java.util.Set.of("Credential", "SignedHeaders", "Signature"))) denied("Invalid authorization fields");
String[] credential = fields.get("Credential").split("/", -1);
if (credential.length != 5 || !credential[0].equals(accessKey) || !credential[2].equals(region)
|| !credential[3].equals("s3") || !credential[4].equals("aws4_request")) denied("Invalid credential scope");
String date = single(headers, "x-amz-date"), payload = single(headers, "x-amz-content-sha256");
if (date == null || !credential[1].matches("[0-9]{8}") || !date.matches("[0-9]{8}T[0-9]{6}Z") || !date.startsWith(credential[1])) denied("Invalid signing date");
try {
Instant signed = Instant.from(DATE.parse(date));
if (Duration.between(signed, clock.instant()).abs().compareTo(Duration.ofMinutes(5)) > 0)
throw new StoreException(403, "RequestTimeTooSkewed", "Request timestamp is outside the permitted window");
} catch (java.time.DateTimeException e) { denied("Invalid signing date"); }
if (payload == null || !HEX.matcher(payload).matches())
throw new StoreException(400, "NotImplemented", "A hexadecimal SHA-256 payload hash is required; unsigned and chunk-signed payloads are unsupported");
if (headers.containsKey("x-amz-security-token")) denied("Temporary credentials are unsupported");
String signedHeaders = fields.get("SignedHeaders");
String[] names = signedHeaders.split(";", -1);
if (names.length > 32 || !signedHeaders.equals(String.join(";", Arrays.stream(names).distinct().sorted().toList()))) denied("Signed headers must be unique and sorted");
var namesSet = java.util.Set.copyOf(Arrays.asList(names));
if (!namesSet.containsAll(java.util.Set.of("host", "x-amz-date", "x-amz-content-sha256"))) denied("Missing signed headers");
for (String key : headers.keySet()) {
String lower = key.toLowerCase(java.util.Locale.ROOT);
if (lower.startsWith("x-amz-") && !namesSet.contains(lower)) denied("Unsigned Amazon header");
}
if (headers.containsKey("if-none-match") && !namesSet.contains("if-none-match")) denied("Unsigned write condition");
StringBuilder canonicalHeaders = new StringBuilder();
for (String name : names) {
if (!name.matches("[a-z0-9-]+")) denied("Invalid signed header name");
String value = single(headers, name);
if (value == null) denied("Missing signed header");
canonicalHeaders.append(name).append(':').append(value.trim().replaceAll("[\\t ]+", " ")).append('\n');
}
String canonical = method + "\n" + encode(decode(uri.getRawPath()), true) + "\n"
+ canonicalQuery(uri.getRawQuery()) + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payload;
String scope = String.join("/", Arrays.copyOfRange(credential, 1, 5));
String toSign = "AWS4-HMAC-SHA256\n" + date + "\n" + scope + "\n" + hex(hash(canonical.getBytes(StandardCharsets.UTF_8)));
byte[] signingKey = signingKey(secretKey, credential[1], region);
String signature = fields.get("Signature");
if (!HEX.matcher(signature).matches() || !MessageDigest.isEqual(hmac(signingKey, toSign), HexFormat.of().parseHex(signature))) denied("Signature mismatch");
return payload;
}
static String single(Headers headers, String name) {
var values = headers.get(name);
if (values == null) return null;
if (values.size() != 1) denied("Duplicate security-relevant header");
return values.getFirst();
}
static String decode(String value) {
try {
var bytes=new java.io.ByteArrayOutputStream();
for(int i=0;i<value.length();){
if(value.charAt(i)=='%'){
if(i+2>=value.length())throw new IllegalArgumentException();
int hi=Character.digit(value.charAt(i+1),16),lo=Character.digit(value.charAt(i+2),16);
if(hi<0||lo<0)throw new IllegalArgumentException();
bytes.write((hi<<4)|lo);i+=3;
}else{int point=value.codePointAt(i);bytes.writeBytes(new String(Character.toChars(point)).getBytes(StandardCharsets.UTF_8));i+=Character.charCount(point);}
}
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(java.nio.charset.CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes.toByteArray())).toString();
}catch(IllegalArgumentException|java.nio.charset.CharacterCodingException e){throw new StoreException(400,"InvalidURI","Malformed URI encoding");}
}
static String encode(String value, boolean keepSlash) {
StringBuilder result = new StringBuilder();
for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
int c = b & 255;
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' || (c == '/' && keepSlash)) result.append((char)c);
else result.append('%').append("0123456789ABCDEF".charAt(c >> 4)).append("0123456789ABCDEF".charAt(c & 15));
}
return result.toString();
}
static String canonicalQuery(String raw) {
if (raw == null || raw.isEmpty()) return "";
return Arrays.stream(raw.split("&", -1)).map(part -> {
String[] pair = part.split("=", 2);
return encode(decode(pair[0]), false) + "=" + encode(decode(pair.length == 2 ? pair[1] : ""), false);
}).sorted().collect(java.util.stream.Collectors.joining("&"));
}
static byte[] signingKey(String secret, String date, String region) {
return hmac(hmac(hmac(hmac(("AWS4"+secret).getBytes(StandardCharsets.UTF_8),date),region),"s3"),"aws4_request");
}
static byte[] hmac(byte[] key, String text) {
try { Mac mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(key,"HmacSHA256"));return mac.doFinal(text.getBytes(StandardCharsets.UTF_8)); }
catch (java.security.GeneralSecurityException e) { throw new IllegalStateException(e); }
}
static byte[] hash(byte[] data) { try {return MessageDigest.getInstance("SHA-256").digest(data);}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);} }
static String hex(byte[] data) { return HexFormat.of().formatHex(data); }
private static void denied(String message) { throw new StoreException(403,"AccessDenied",message); }
}
@@ -0,0 +1,11 @@
package cloud.lunarsky.store;
final class StoreException extends RuntimeException {
final int status;
final String code;
StoreException(int status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
}
+104
View File
@@ -0,0 +1,104 @@
package cloud.lunarsky.store;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.concurrent.Executors;
public final class HttpTest {
private static final String ACCESS = "TESTACCESSKEY123";
private static final String SECRET = "test-secret-key-that-is-at-least-32-characters";
private static final String REGION = "us-east-1";
private static final DateTimeFormatter DATE =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
private static HttpRequest signed(String base, String method, String key, byte[] body) {
URI uri = URI.create(base + "/objects/" + SigV4.encode(key, true));
String host = uri.getAuthority();
String date = DATE.format(Instant.now());
String hash = SigV4.hex(SigV4.hash(body));
String names = "host;x-amz-content-sha256;x-amz-date";
String canonical = method + "\n" + uri.getRawPath() + "\n\n"
+ "host:" + host + "\n"
+ "x-amz-content-sha256:" + hash + "\n"
+ "x-amz-date:" + date + "\n\n" + names + "\n" + hash;
String scope = date.substring(0, 8) + "/" + REGION + "/s3/aws4_request";
String toSign = "AWS4-HMAC-SHA256\n" + date + "\n" + scope + "\n"
+ SigV4.hex(SigV4.hash(canonical.getBytes(StandardCharsets.UTF_8)));
String signature = SigV4.hex(SigV4.hmac(
SigV4.signingKey(SECRET, date.substring(0, 8), REGION), toSign));
return HttpRequest.newBuilder(uri)
.header("x-amz-date", date)
.header("x-amz-content-sha256", hash)
.header("authorization", "AWS4-HMAC-SHA256 Credential=" + ACCESS + "/"
+ scope + ",SignedHeaders=" + names + ",Signature=" + signature)
.method(method, body.length == 0
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body))
.build();
}
private static void status(int expected, HttpResponse<byte[]> response) {
if (response.statusCode() != expected) {
throw new AssertionError("Expected HTTP " + expected + ", got " + response.statusCode()
+ ": " + new String(response.body(), StandardCharsets.UTF_8));
}
}
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("store-http-test-");
var executor = Executors.newVirtualThreadPerTaskExecutor();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 16);
DiskStore store = new DiskStore(root, 1024, 4096);
try {
var app = new Main(store,
new SigV4(ACCESS, SECRET, REGION, Clock.systemUTC()), "objects");
server.setExecutor(executor);
server.createContext("/", app::handle);
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
HttpClient client = HttpClient.newHttpClient();
status(200, client.send(HttpRequest.newBuilder(URI.create(base + "/health")).GET().build(),
HttpResponse.BodyHandlers.ofByteArray()));
String key = "folder/moon-☾.txt";
byte[] body = "independent storage test".getBytes(StandardCharsets.UTF_8);
status(403, client.send(HttpRequest.newBuilder(URI.create(base + "/objects/" + key))
.GET().build(), HttpResponse.BodyHandlers.ofByteArray()));
status(200, client.send(signed(base, "PUT", key, body),
HttpResponse.BodyHandlers.ofByteArray()));
var get = client.send(signed(base, "GET", key, new byte[0]),
HttpResponse.BodyHandlers.ofByteArray());
status(200, get);
if (!java.util.Arrays.equals(body, get.body())) throw new AssertionError("GET body mismatch");
if (!"application/octet-stream".equals(get.headers().firstValue("content-type").orElse("")))
throw new AssertionError("Unexpected content type");
var head = client.send(signed(base, "HEAD", key, new byte[0]),
HttpResponse.BodyHandlers.ofByteArray());
status(200, head);
if (head.body().length != 0) throw new AssertionError("HEAD returned a body");
status(204, client.send(signed(base, "DELETE", key, new byte[0]),
HttpResponse.BodyHandlers.ofByteArray()));
status(404, client.send(signed(base, "GET", key, new byte[0]),
HttpResponse.BodyHandlers.ofByteArray()));
System.out.println("HTTP tests passed: health, authentication, PUT, GET, HEAD, DELETE");
} finally {
server.stop(0);
executor.close();
store.close();
try (var paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package cloud.lunarsky.store;
import java.nio.file.*;
import java.io.*;
import java.util.Arrays;
public final class StoreTest {
interface Operation {void run() throws Exception;}
static void fails(int status,Operation operation)throws Exception{
try{operation.run();throw new AssertionError("Expected "+status);}catch(StoreException error){if(error.status!=status)throw error;}
}
static DiskStore.Metadata put(DiskStore store,String key,byte[] body,boolean only)throws Exception{
return store.put("test",key,new ByteArrayInputStream(body),body.length,SigV4.hex(SigV4.hash(body)),null,only);
}
public static void main(String[] args)throws Exception{
var headers=new com.sun.net.httpserver.Headers();
headers.set("host","examplebucket.s3.amazonaws.com");headers.set("range","bytes=0-9");
headers.set("x-amz-date","20130524T000000Z");
headers.set("x-amz-content-sha256","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
headers.set("authorization","AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41");
var clock=java.time.Clock.fixed(java.time.Instant.parse("2013-05-24T00:00:00Z"),java.time.ZoneOffset.UTC);
var auth=new SigV4("AKIAIOSFODNN7EXAMPLE","wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","us-east-1",clock);
var uri=java.net.URI.create("/test.txt");auth.verify("GET",uri,headers);
fails(403,()->auth.verify("GET",java.net.URI.create("/other.txt"),headers));
fails(403,()->auth.verify("DELETE",uri,headers));
fails(403,()->new SigV4("AKIAIOSFODNN7EXAMPLE","wrong","us-east-1",clock).verify("GET",uri,headers));
fails(403,()->new SigV4("AKIAIOSFODNN7EXAMPLE","wrong","us-east-1",java.time.Clock.systemUTC()).verify("GET",uri,headers));
headers.add("host","duplicate");fails(403,()->auth.verify("GET",uri,headers));
System.out.println("SigV4 official vector and tampering tests passed");
Path root=Files.createTempDirectory("store-test-");
try{
try(var store=new DiskStore(root,8,10)){
byte[] body={1,2,3,4,5,6};
put(store,"../nested/☾",body,true);
try(var obj=store.open("test","../nested/☾")){if(!Arrays.equals(body,obj.stream().readAllBytes()))throw new AssertionError("Roundtrip");}
fails(412,()->put(store,"../nested/☾",new byte[]{9},true));
fails(507,()->put(store,"second",body,true));
fails(413,()->put(store,"large",new byte[9],true));
fails(400,()->store.put("test","bad",new ByteArrayInputStream(body),6,"0".repeat(64),null,true));
fails(400,()->store.put("test","short",new ByteArrayInputStream(body),7,SigV4.hex(SigV4.hash(body)),null,true));
fails(404,()->store.open("test","bad"));
put(store,"../nested/☾",new byte[]{9},false);
put(store,"empty",new byte[0],true);
try {
new DiskStore(root,8,10);
throw new AssertionError("A second process opened the same data directory");
} catch(IOException expected) {
if(!expected.getMessage().contains("already in use"))throw expected;
}
}
try(var restarted=new DiskStore(root,8,10)){
try(var obj=restarted.open("test","../nested/☾")){if(obj.stream().read()!=9)throw new AssertionError("Persistence");}
restarted.delete("test","../nested/☾");restarted.delete("test","../nested/☾");
fails(404,()->restarted.open("test","../nested/☾"));
}
String id=SigV4.hex(SigV4.hash("test/empty".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
Files.write(root.resolve("objects").resolve(id.substring(0,2)).resolve(id),new byte[]{1},StandardOpenOption.APPEND);
try {
new DiskStore(root,8,10);
throw new AssertionError("A damaged object record was accepted");
} catch(IOException expected) {
if(!expected.getMessage().contains("object record"))throw expected;
}
try(var pending=Files.list(root.resolve("pending"))){if(pending.count()!=0)throw new AssertionError("Pending cleanup");}
System.out.println("Java storage tests passed: roundtrip, quota, integrity, persistence, locking, corruption, delete");
}finally{try(var paths=Files.walk(root)){for(var p:paths.sorted(java.util.Comparator.reverseOrder()).toList())Files.delete(p);}}
}
}