package cloud.lunarsky.store; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.ByteArrayInputStream; import java.net.InetSocketAddress; import java.net.URI; 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.Base64; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; public final class Main { private final ObjectStorage store; private final SigV4 authentication; private final String bucket; private final MultipartStorage multipart; private final Semaphore slots = new Semaphore(16); Main(DiskStore store, SigV4 authentication, String bucket) throws IOException { this(store, new MultipartStore(store), authentication, bucket); } Main(ObjectStorage store, MultipartStorage multipart, SigV4 authentication, String bucket) { this.store = store; this.multipart = multipart; 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 (handleStatus(exchange)) return; String hash = authentication.verify(exchange.getRequestMethod(), exchange.getRequestURI(), exchange.getRequestHeaders()); String path = SigV4.decode(exchange.getRequestURI().getRawPath()); Map query = query(exchange.getRequestURI().getRawQuery()); if (path.equals("/" + bucket) || path.equals("/" + bucket + "/")) { handleBucket(exchange, query, hash); } else { handleObject(exchange, path, query, hash); } } 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 boolean handleStatus(HttpExchange exchange) throws IOException { if (!exchange.getRequestMethod().equals("GET")) return false; String path = exchange.getRequestURI().getRawPath(); if (path.equals("/health")) { 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 true; } if (!path.equals("/ready")) return false; boolean ready = store.ready(); byte[] body = (ready ? "ready" : "unavailable").getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); exchange.sendResponseHeaders(ready ? 200 : 503, body.length); exchange.getResponseBody().write(body); return true; } private void handleBucket(HttpExchange exchange, Map query, String hash) throws IOException { if (!exchange.getRequestMethod().equals("GET") || !"2".equals(query.get("list-type")) || !query.keySet().stream().allMatch(java.util.Set.of("list-type", "prefix", "delimiter", "max-keys", "continuation-token", "start-after", "encoding-type", "x-id")::contains) || (query.containsKey("x-id") && !"ListObjectsV2".equals(query.get("x-id")))) unsupported("Bucket operation"); requireEmptyBody(exchange, hash); listObjects(exchange, query); } private void handleObject(HttpExchange exchange, String path, Map query, String hash) throws IOException { 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 method = exchange.getRequestMethod(); boolean multipartRequest = multipartRequest(method, query); if (!multipartRequest && !query.isEmpty() && !(query.size() == 1 && ("PutObject".equals(query.get("x-id")) || "GetObject".equals(query.get("x-id")) || "HeadObject".equals(query.get("x-id")) || "DeleteObject".equals(query.get("x-id"))))) unsupported("Query operation"); validateObjectHeaders(exchange.getRequestHeaders()); if (multipartRequest) { handleMultipart(exchange, method, query, key, hash); return; } if (!method.equals("PUT")) requireEmptyBody(exchange, hash); switch (method) { case "PUT" -> putObject(exchange, key, hash); case "GET", "HEAD" -> readObject(exchange, key); case "DELETE" -> deleteObject(exchange, key); default -> unsupported("HTTP method"); } } private static void validateObjectHeaders(com.sun.net.httpserver.Headers headers) { 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 algorithm = SigV4.single(headers, "x-amz-sdk-checksum-algorithm"); if (algorithm != null && !algorithm.equals("SHA256")) unsupported("Checksum algorithm"); } private void putObject(HttpExchange exchange, String key, String hash) throws IOException { var headers = exchange.getRequestHeaders(); 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"); String type = contentType(headers); ObjectStorage.Metadata data = store.put(bucket, key, exchange.getRequestBody(), bytes, hash, SigV4.single(headers, "x-amz-checksum-sha256"), condition != null, type); exchange.getResponseHeaders().set("ETag", "\"" + data.etag() + "\""); exchange.getResponseHeaders().set("x-amz-checksum-sha256", Base64.getEncoder().encodeToString(data.sha256())); exchange.sendResponseHeaders(200, -1); } private void deleteObject(HttpExchange exchange, String key) throws IOException { if (exchange.getRequestHeaders().containsKey("if-none-match")) unsupported("Conditional delete"); store.delete(bucket, key); exchange.sendResponseHeaders(204, -1); } private static String contentType(com.sun.net.httpserver.Headers headers) { String value = SigV4.single(headers, "content-type"); if (value == null) return "application/octet-stream"; if (value.isBlank() || value.getBytes(StandardCharsets.UTF_8).length > 255 || !value.chars().allMatch(c -> c >= 32 && c <= 126)) throw new StoreException(400, "InvalidArgument", "Invalid Content-Type"); return value; } private static void requireEmptyBody(HttpExchange exchange, String hash) { var headers = exchange.getRequestHeaders(); if (headers.containsKey("transfer-encoding") || (headers.containsKey("content-length") && !"0".equals(SigV4.single(headers, "content-length"))) || !hash.equals(SigV4.hex(SigV4.hash(new byte[0])))) throw new StoreException(400, "InvalidRequest", "Request must have an empty body"); } private static boolean multipartRequest(String method, Map query) { if (query.containsKey("uploads")) return method.equals("POST") && query.get("uploads").isEmpty() && query.keySet().stream().allMatch(java.util.Set.of("uploads", "x-id")::contains) && (!query.containsKey("x-id") || query.get("x-id").equals("CreateMultipartUpload")); if (!query.containsKey("uploadId") || !query.keySet().stream().allMatch(java.util.Set.of("uploadId", "partNumber", "x-id")::contains)) return false; String xId = query.get("x-id"); if (method.equals("PUT")) return query.containsKey("partNumber") && (xId == null || xId.equals("UploadPart")); if (query.containsKey("partNumber")) return false; return (method.equals("POST") && (xId == null || xId.equals("CompleteMultipartUpload"))) || (method.equals("DELETE") && (xId == null || xId.equals("AbortMultipartUpload"))); } private void handleMultipart(HttpExchange exchange, String method, Map query, String key, String hash) throws IOException { var headers = exchange.getRequestHeaders(); if (headers.containsKey("content-encoding") || headers.containsKey("if-none-match")) unsupported("Multipart request header"); if (query.containsKey("uploads")) { requireEmptyBody(exchange, hash); String id = multipart.create(bucket, key, contentType(headers)); sendXml(exchange, 200, "" + xml(bucket) + "" + xml(key) + "" + id + ""); return; } String id = query.get("uploadId"); switch (method) { case "PUT" -> { int number; try { number = Integer.parseInt(query.get("partNumber")); } catch (NumberFormatException e) { throw new StoreException(400, "InvalidArgument", "Invalid part number"); } long length = contentLength(headers); String etag = multipart.putPart(id, bucket, key, number, exchange.getRequestBody(), length, hash, SigV4.single(headers, "x-amz-checksum-sha256")); exchange.getResponseHeaders().set("ETag", "\"" + etag + "\""); exchange.sendResponseHeaders(200, -1); } case "POST" -> { byte[] body = signedBody(exchange, hash, 65536); List parts = completedParts(body); var meta = multipart.complete(id, bucket, key, parts); sendXml(exchange, 200, "" + xml(bucket) + "" + xml(key) + """ + meta.etag() + """); } case "DELETE" -> { requireEmptyBody(exchange, hash); multipart.abort(id, bucket, key); exchange.sendResponseHeaders(204, -1); } default -> unsupported("Multipart operation"); } } private static long contentLength(com.sun.net.httpserver.Headers headers) { String text = SigV4.single(headers, "content-length"); if (text == null) return -1; try { return Long.parseLong(text); } catch (NumberFormatException e) { throw new StoreException(400, "InvalidArgument", "Invalid Content-Length"); } } private static byte[] signedBody(HttpExchange exchange, String hash, int limit) throws IOException { long length = contentLength(exchange.getRequestHeaders()); if (length < 0) throw new StoreException(411, "MissingContentLength", "Content-Length is required"); if (length > limit) throw new StoreException(413, "EntityTooLarge", "Request body is too large"); byte[] body = exchange.getRequestBody().readNBytes(limit + 1); if (body.length != length) throw new StoreException(400, "IncompleteBody", "Body length does not match Content-Length"); if (!SigV4.hex(SigV4.hash(body)).equals(hash)) throw new StoreException(400, "XAmzContentSHA256Mismatch", "Payload hash mismatch"); return body; } private static List completedParts(byte[] body) { try { var factory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true); factory.setExpandEntityReferences(false); var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(body)); if (!document.getDocumentElement().getNodeName().equals("CompleteMultipartUpload")) throw new IllegalArgumentException(); var nodes = document.getDocumentElement().getChildNodes(); List parts = new ArrayList<>(); for (int i = 0; i < nodes.getLength(); i++) { if (!(nodes.item(i) instanceof org.w3c.dom.Element element)) continue; if (!element.getTagName().equals("Part")) throw new IllegalArgumentException(); String number = null, etag = null; var fields = element.getChildNodes(); for (int j = 0; j < fields.getLength(); j++) { if (!(fields.item(j) instanceof org.w3c.dom.Element field)) continue; if (field.getTagName().equals("PartNumber")) number = field.getTextContent().trim(); else if (field.getTagName().equals("ETag")) etag = field.getTextContent().trim(); else throw new IllegalArgumentException(); } if (number == null || etag == null || !etag.matches("\"?[0-9a-f]{32}\"?")) throw new IllegalArgumentException(); parts.add(new MultipartStorage.Part(Integer.parseInt(number), etag)); if (parts.size() > 10000) throw new IllegalArgumentException(); } return parts; } catch (Exception error) { throw new StoreException(400, "MalformedXML", "Invalid multipart completion body"); } } private static void sendXml(HttpExchange exchange, int status, String xml) throws IOException { byte[] body = ("" + xml).getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/xml"); exchange.sendResponseHeaders(status, body.length); exchange.getResponseBody().write(body); } private void readObject(HttpExchange exchange, String key) throws IOException { var headers = exchange.getRequestHeaders(); if (headers.containsKey("if-match") || headers.containsKey("if-modified-since") || headers.containsKey("if-unmodified-since")) unsupported("Conditional read"); try (var object = store.open(bucket, key)) { var meta = object.metadata(); String etag = "\"" + meta.etag() + "\""; String noneMatch = SigV4.single(headers, "if-none-match"); if (noneMatch != null && (noneMatch.equals("*") || java.util.Arrays.stream(noneMatch.split(",")).map(String::trim).anyMatch(etag::equals))) { exchange.getResponseHeaders().set("ETag", etag); exchange.sendResponseHeaders(304, -1); return; } Range range; try { range = range(SigV4.single(headers, "range"), meta.length()); } catch (StoreException error) { if (error.status == 416) exchange.getResponseHeaders().set("Content-Range", "bytes */" + meta.length()); throw error; } var response = exchange.getResponseHeaders(); response.set("Content-Type", meta.contentType()); response.set("Content-Length", Long.toString(range.length())); response.set("Accept-Ranges", "bytes"); response.set("ETag", etag); response.set("Last-Modified", DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC) .format(Instant.ofEpochMilli(meta.modified()))); if (range.partial()) response.set("Content-Range", "bytes " + range.start() + "-" + range.end() + "/" + meta.length()); int status = range.partial() ? 206 : 200; if (exchange.getRequestMethod().equals("HEAD") || range.length() == 0) exchange.sendResponseHeaders(status, -1); else { object.stream().skipNBytes(range.start()); exchange.sendResponseHeaders(status, range.length()); byte[] buffer = new byte[65536]; long left = range.length(); while (left > 0) { int n = object.stream().read(buffer, 0, (int) Math.min(buffer.length, left)); if (n < 0) throw new IOException("Object body ended before its recorded length"); exchange.getResponseBody().write(buffer, 0, n); left -= n; } } } } private record Range(long start, long end, boolean partial) { long length() { return end < start ? 0 : end - start + 1; } } private static Range range(String header, long size) { if (header == null) return new Range(0, size - 1, false); if (!header.matches("bytes=[0-9]*-[0-9]*") || header.equals("bytes=-") || size == 0) throw new StoreException(416, "InvalidRange", "The requested range is not satisfiable"); String[] parts = header.substring(6).split("-", -1); try { long start, end; if (parts[0].isEmpty()) { long suffix = Long.parseLong(parts[1]); if (suffix == 0) throw new NumberFormatException(); start = Math.max(0, size - suffix); end = size - 1; } else { start = Long.parseLong(parts[0]); end = parts[1].isEmpty() ? size - 1 : Math.min(Long.parseLong(parts[1]), size - 1); } if (start >= size || end < start) throw new NumberFormatException(); return new Range(start, end, true); } catch (NumberFormatException e) { throw new StoreException(416, "InvalidRange", "The requested range is not satisfiable"); } } private void listObjects(HttpExchange exchange, Map query) throws IOException { ListRequest request = listRequest(query); var page = store.list(bucket, request.prefix(), request.delimiter(), request.maxKeys(), request.after()); StringBuilder xml = new StringBuilder(""); appendListHeader(xml, query, request, page); appendListEntries(xml, page, request.encoding()); if (page.truncated()) xml.append("") .append(Base64.getUrlEncoder().withoutPadding().encodeToString(page.nextKey().getBytes(StandardCharsets.UTF_8))) .append(""); xml.append(""); byte[] body = xml.toString().getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/xml"); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); } private record ListRequest(String prefix, String delimiter, String encoding, int maxKeys, String after) { } private static ListRequest listRequest(Map query) { String prefix = query.getOrDefault("prefix", ""), delimiter = query.getOrDefault("delimiter", ""); String encoding = query.get("encoding-type"); if (encoding != null && !encoding.equals("url")) unsupported("Encoding type"); if (query.containsKey("continuation-token") && query.containsKey("start-after")) throw new StoreException(400, "InvalidArgument", "Use either continuation-token or start-after"); int maxKeys; try { maxKeys = Integer.parseInt(query.getOrDefault("max-keys", "1000")); } catch (NumberFormatException e) { throw new StoreException(400, "InvalidArgument", "Invalid max-keys"); } if (maxKeys < 0 || maxKeys > 1000) throw new StoreException(400, "InvalidArgument", "Invalid max-keys"); String after = query.get("start-after"); if (query.containsKey("continuation-token")) { try { byte[] decoded = Base64.getUrlDecoder().decode(query.get("continuation-token")); after = StandardCharsets.UTF_8.newDecoder().onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) .decode(java.nio.ByteBuffer.wrap(decoded)).toString(); } catch (IllegalArgumentException | java.nio.charset.CharacterCodingException e) { throw new StoreException(400, "InvalidArgument", "Invalid continuation token"); } } return new ListRequest(prefix, delimiter, encoding, maxKeys, after); } private void appendListHeader(StringBuilder xml, Map query, ListRequest request, ObjectStorage.ListPage page) { String prefix = request.prefix(), delimiter = request.delimiter(), encoding = request.encoding(); xml.append("").append(xml(bucket)).append("").append(xml(listKey(prefix, encoding))).append(""); if (!delimiter.isEmpty()) xml.append("").append(xml(listKey(delimiter, encoding))).append(""); if (encoding != null) xml.append("url"); if (query.containsKey("continuation-token")) xml.append("") .append(xml(query.get("continuation-token"))).append(""); if (query.containsKey("start-after")) xml.append("") .append(xml(listKey(query.get("start-after"), encoding))).append(""); xml.append("").append(page.keyCount()).append("").append(request.maxKeys()) .append("").append(page.truncated()).append(""); } private static void appendListEntries(StringBuilder xml, ObjectStorage.ListPage page, String encoding) { int objectAt = 0, prefixAt = 0; while (objectAt < page.objects().size() || prefixAt < page.prefixes().size()) { if (objectAt < page.objects().size() && (prefixAt == page.prefixes().size() || page.objects().get(objectAt).key().compareTo(page.prefixes().get(prefixAt)) < 0)) { var entry = page.objects().get(objectAt++); var meta = entry.metadata(); xml.append("").append(xml(listKey(entry.key(), encoding))).append("") .append(Instant.ofEpochMilli(meta.modified())).append(""") .append(meta.etag()).append(""").append(meta.length()) .append("STANDARD"); } else { xml.append("") .append(xml(listKey(page.prefixes().get(prefixAt++), encoding))) .append(""); } } } private static String listKey(String key, String encoding) { return encoding == null ? key : SigV4.encode(key, false); } private static Map query(String raw) { Map result = new HashMap<>(); if (raw == null || raw.isEmpty()) return result; for (String part : raw.split("&", -1)) { String[] pair = part.split("=", 2); String name = SigV4.decode(pair[0]); String value = SigV4.decode(pair.length == 2 ? pair[1] : ""); if (result.put(name, value) != null) throw new StoreException(400, "InvalidArgument", "Duplicate query parameter"); } return result; } private static void unsupported(String feature) { throw new StoreException(501, "NotImplemented", feature + " is not supported"); } 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(code) + "" + xml(message) + "" + id + "") .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 env = System.getenv(); String access = required(env, "S3_ACCESS_KEY"), secret = required(env, "S3_SECRET_KEY"); String 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", "134217728")); long maxTotal = Long.parseLong(env.getOrDefault("MAX_TOTAL_BYTES", "2147483648")); if (maxObject < 1 || maxObject > 1073741824L || maxTotal < maxObject) throw new IllegalArgumentException("Invalid size limits"); String mode = env.getOrDefault("STORE_MODE", "disk"); ObjectStorage store; MultipartStorage multipart; if (mode.equals("cluster")) { if (!"true".equals(env.get("CLUSTER_LOCAL_DEV"))) throw new IllegalArgumentException("Cluster mode is local development only; set CLUSTER_LOCAL_DEV=true"); String[] urls = required(env, "CLUSTER_NODES").split(",", -1); if (urls.length < 2) throw new IllegalArgumentException("CLUSTER_NODES requires at least two URLs"); store = new ClusterStore(required(env, "POSTGRES_JDBC_URL"), required(env, "POSTGRES_USER"), required(env, "POSTGRES_PASSWORD"), bucket, java.util.Arrays.stream(urls).map(URI::create).toList(), required(env, "CLUSTER_TOKEN"), null, maxObject, maxTotal, "true".equals(env.get("CLUSTER_TEST_NODE_DOMAINS"))); multipart = new UnavailableMultipart(); } else if (mode.equals("disk")) { DiskStore disk = new DiskStore(Path.of(env.getOrDefault("DATA_DIR", "/data")), maxObject, maxTotal); store = disk; multipart = new MultipartStore(disk); } else throw new IllegalArgumentException("Invalid STORE_MODE"); var app = new Main(store, multipart, new SigV4(access, secret, region, Clock.systemUTC()), bucket); int port = Integer.parseInt(env.getOrDefault("PORT", "9000")); var server = HttpServer.create(mode.equals("cluster") ? new InetSocketAddress(env.getOrDefault("BIND_ADDRESS", "127.0.0.1"), port) : new InetSocketAddress(port), 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(); if (store instanceof DiskStore disk) printStartup(disk, app.multipart, bucket, region, port); else System.out.println("ObjectStore local cluster prototype v" + Version.VALUE + " listening on :" + port); } private static void printStartup(DiskStore store, MultipartStorage multipart, String bucket, String region, int port) { System.out.println(" *"); System.out.println(" / \\ LUNARSKY"); System.out.println(" / L \\ ObjectStore"); System.out.println(" /_____\\ v" + Version.VALUE); System.out.println(); System.out.println(" Bucket " + bucket + " (" + region + ")"); System.out.println(" Objects " + store.objectCount() + " total, " + store.indexedObjects() + " indexed, " + size(store.usedBytes()) + " / " + size(store.maxTotal())); if (store.legacyObjects() > 0) System.out.println(" Legacy " + store.legacyObjects() + " objects without stored keys"); System.out.println(" Multipart " + multipart.activeUploads() + " active, " + size(multipart.stagedBytes()) + " staged"); System.out.println(" Listening :" + port); } private static String size(long bytes) { if (bytes < 1024) return bytes + " B"; String[] units = {"KiB", "MiB", "GiB", "TiB"}; double value = bytes; int unit = -1; do { value /= 1024; unit++; } while (value >= 1024 && unit < units.length - 1); return String.format(Locale.ROOT, "%.1f %s", value, units[unit]); } private static String required(Map env, String key) { String value = env.get(key); if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key); return value; } }