Add ObjectStore source
This commit is contained in:
1 parent
8a3bee5dcc
commit
51e206f8df
14 files changed
+673
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.env
|
||||
data
|
||||
out
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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 .
|
||||
@@ -0,0 +1,4 @@
|
||||
out/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
+14
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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);}}
|
||||
}
|
||||
@@ -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("&","&").replace("<","<").replace(">",">").replace("\"",""");}
|
||||
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;}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);}}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user