remove awssdk

This commit is contained in:
iceBear67 committed 2026-07-17 22:45:25 +08:00
1 parent ea92ae5e2b
commit b21d0f7f61
6 files changed
+341 -24

No files matched your search

-3
View File
@@ -68,9 +68,7 @@ dependencies {
// Fabric API. This is technically optional, but you probably want it anyway.
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
// https://mvnrepository.com/artifact/software.amazon.awssdk/s3
shadow project(":bundler")
shadow 'software.amazon.awssdk:s3:2.34.0'
testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -84,7 +82,6 @@ shadowJar {
configurations = [project.configurations.shadow]
exclude("META-INF")
dependencies {
exclude(dependency("software.amazon.awssdk:aws-xml-protocol:.*"))
exclude(dependency("org.lz4:lz4-java:.*"))
exclude(dependency("it.unimi.dsi:fastutil:.*"))
exclude(dependency("io.netty:netty-all:.*"))
@@ -0,0 +1,81 @@
package io.ib67.sfcraft.s3;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.SortedMap;
import java.util.StringJoiner;
/**
* Minimal AWS Signature Version 4 signer for S3.
*
* @see <a href="https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html">
* Authenticating Requests: Using the Authorization Header (AWS Signature Version 4)</a>
*/
final class AwsV4Signer {
// SHA-256 of a zero-length payload
static final String EMPTY_PAYLOAD_SHA256 =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
private static final HexFormat HEX = HexFormat.of();
private AwsV4Signer() {
}
/**
* @param encodedPath already URI-encoded absolute path, exactly as sent on the wire
* @param headers headers to sign; keys must be lowercase, values trimmed.
* Must include host, x-amz-date and x-amz-content-sha256.
*/
static String authorization(String method, String encodedPath, String canonicalQuery,
SortedMap<String, String> headers, String payloadHash,
String amzDate, String region, String service,
String accessKey, String secretKey) {
var canonicalHeaders = new StringBuilder();
var signedHeaders = new StringJoiner(";");
for (var header : headers.entrySet()) {
canonicalHeaders.append(header.getKey()).append(':').append(header.getValue()).append('\n');
signedHeaders.add(header.getKey());
}
var canonicalRequest = method + '\n'
+ encodedPath + '\n'
+ canonicalQuery + '\n'
+ canonicalHeaders + '\n'
+ signedHeaders + '\n'
+ payloadHash;
var dateStamp = amzDate.substring(0, 8);
var scope = dateStamp + "/" + region + "/" + service + "/aws4_request";
var stringToSign = "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n"
+ sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8));
var signingKey = hmac(hmac(hmac(hmac(
("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8), dateStamp), region), service), "aws4_request");
return "AWS4-HMAC-SHA256 Credential=" + accessKey + "/" + scope
+ ",SignedHeaders=" + signedHeaders
+ ",Signature=" + HEX.formatHex(hmac(signingKey, stringToSign));
}
static String sha256Hex(byte[] data) {
return HEX.formatHex(sha256().digest(data));
}
static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (GeneralSecurityException e) {
throw new IllegalStateException("JVM lacks SHA-256", e);
}
}
private static byte[] hmac(byte[] key, String data) {
try {
var mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException e) {
throw new IllegalStateException("JVM lacks HmacSHA256", e);
}
}
}
@@ -0,0 +1,21 @@
package io.ib67.sfcraft.s3;
import java.io.IOException;
/**
* A non-2xx response from the S3 endpoint. Network failures surface as plain
* {@link IOException}s instead.
*/
public final class S3Exception extends IOException {
private final int statusCode;
public S3Exception(String request, int statusCode, String errorBody) {
super(request + " -> HTTP " + statusCode + (errorBody == null || errorBody.isBlank()
? "" : ": " + errorBody.strip()));
this.statusCode = statusCode;
}
public int statusCode() {
return statusCode;
}
}
@@ -0,0 +1,170 @@
package io.ib67.sfcraft.s3;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.TreeMap;
/**
* A tiny synchronous S3 client on top of {@link HttpClient}, covering only what
* this mod needs: headBucket, putObject, getObject and deleteObject. Requests
* are authenticated with AWS Signature Version 4 and work against any
* S3-compatible endpoint, in both path-style and virtual-hosted style.
*/
public final class SimpleS3Client implements AutoCloseable {
private static final DateTimeFormatter AMZ_DATE =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT).withZone(ZoneOffset.UTC);
private final HttpClient http;
private final String scheme;
// authority as HttpClient will put it into the Host header: default ports stripped
private final String hostPort;
private final String region;
private final String accessKey;
private final String secretKey;
private final boolean pathStyle;
public SimpleS3Client(String endpoint, String region, String accessKey, String secretKey, boolean pathStyle) {
var uri = URI.create(endpoint);
this.scheme = Objects.requireNonNull(uri.getScheme(), "endpoint has no scheme: " + endpoint);
Objects.requireNonNull(uri.getHost(), "endpoint has no host: " + endpoint);
var defaultPort = "https".equals(scheme) ? 443 : 80;
this.hostPort = uri.getPort() == -1 || uri.getPort() == defaultPort
? uri.getHost()
: uri.getHost() + ":" + uri.getPort();
this.region = Objects.requireNonNull(region);
this.accessKey = Objects.requireNonNull(accessKey);
this.secretKey = Objects.requireNonNull(secretKey);
this.pathStyle = pathStyle;
this.http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(10))
.build();
}
/**
* Throws {@link S3Exception} if the bucket does not exist or the credentials
* cannot access it.
*/
public void headBucket(String bucket) throws IOException, InterruptedException {
var target = target(bucket, null);
var request = signed("HEAD", target, AwsV4Signer.EMPTY_PAYLOAD_SHA256)
.HEAD().build();
var response = http.send(request, HttpResponse.BodyHandlers.discarding());
ensureSuccess("HEAD " + target.uri(), response.statusCode(), null);
}
public void putObject(String bucket, String key, Path file, String contentType)
throws IOException, InterruptedException {
var target = target(bucket, key);
var request = signed("PUT", target, sha256Hex(file))
.header("content-type", contentType)
.PUT(HttpRequest.BodyPublishers.ofFile(file))
.build();
var response = http.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccess("PUT " + target.uri(), response.statusCode(), response.body());
}
/**
* Returns the object body; the caller is responsible for closing it.
*/
public InputStream getObject(String bucket, String key) throws IOException, InterruptedException {
var target = target(bucket, key);
var request = signed("GET", target, AwsV4Signer.EMPTY_PAYLOAD_SHA256)
.GET().build();
var response = http.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) {
String body;
try (var in = response.body()) {
body = new String(in.readNBytes(4096), StandardCharsets.UTF_8);
}
throw new S3Exception("GET " + target.uri(), response.statusCode(), body);
}
return response.body();
}
public void deleteObject(String bucket, String key) throws IOException, InterruptedException {
var target = target(bucket, key);
var request = signed("DELETE", target, AwsV4Signer.EMPTY_PAYLOAD_SHA256)
.DELETE().build();
var response = http.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccess("DELETE " + target.uri(), response.statusCode(), response.body());
}
@Override
public void close() {
http.close();
}
private record Target(URI uri, String host, String encodedPath) {
}
private Target target(String bucket, String key) {
var host = pathStyle ? hostPort : bucket + "." + hostPort;
var path = new StringBuilder();
if (pathStyle) {
path.append('/').append(URLEncoder.encode(bucket, Charset.defaultCharset()));
}
if (key != null) {
for (var segment : key.split("/", -1)) {
path.append('/').append(URLEncoder.encode(segment, Charset.defaultCharset()));
}
}
if (path.isEmpty()) {
path.append('/');
}
return new Target(URI.create(scheme + "://" + host + path), host, path.toString());
}
/**
* Host is signed but never set explicitly — HttpClient forbids that and
* derives it from the URI; {@link #hostPort} mirrors that derivation.
*/
private HttpRequest.Builder signed(String method, Target target, String payloadHash) {
var amzDate = AMZ_DATE.format(Instant.now());
var headers = new TreeMap<String, String>();
headers.put("host", target.host());
headers.put("x-amz-content-sha256", payloadHash);
headers.put("x-amz-date", amzDate);
var authorization = AwsV4Signer.authorization(method, target.encodedPath(), "",
headers, payloadHash, amzDate, region, "s3", accessKey, secretKey);
return HttpRequest.newBuilder(target.uri())
.header("x-amz-content-sha256", payloadHash)
.header("x-amz-date", amzDate)
.header("authorization", authorization);
}
private static void ensureSuccess(String request, int statusCode, String body) throws S3Exception {
if (statusCode / 100 != 2) {
throw new S3Exception(request, statusCode, body);
}
}
private static String sha256Hex(Path file) throws IOException {
var digest = AwsV4Signer.sha256();
try (var in = Files.newInputStream(file)) {
var buffer = new byte[64 * 1024];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
}
@@ -3,15 +3,11 @@ package io.ib67.sfcraft.strategy;
import io.ib67.sfcraft.Backup;
import io.ib67.sfcraft.bundler.BundleReader;
import io.ib67.sfcraft.config.StorageOption;
import io.ib67.sfcraft.s3.SimpleS3Client;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import java.net.URI;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
@@ -20,22 +16,17 @@ import java.util.Objects;
@Log4j2
public class S3BackupStrategy implements BackupStrategy {
protected final StorageOption.S3 option;
protected final S3Client s3;
protected final SimpleS3Client s3;
protected final Path temporaryDownloadPath;
@SneakyThrows
public S3BackupStrategy(StorageOption.S3 option, Path tempDownloadPath) {
this.option = option;
var credential = AwsBasicCredentials.create(option.accessKey(), option.secretKey());
this.s3 = S3Client.builder()
.region(Region.of(option.region()))
.forcePathStyle(option.enforcePathStyle())
.endpointOverride(URI.create(option.endpoint()))
.credentialsProvider(StaticCredentialsProvider.create(credential))
.build();
this.s3 = new SimpleS3Client(option.endpoint(), option.region(),
option.accessKey(), option.secretKey(), option.enforcePathStyle());
log.info("Testing S3 credentials");
// throws exception if the bucket neither inaccessible nor not exist
s3.headBucket(b -> b.bucket(option.bucket()));
s3.headBucket(option.bucket());
this.temporaryDownloadPath = Objects.requireNonNull(tempDownloadPath);
if (Files.notExists(temporaryDownloadPath))
Files.createDirectories(temporaryDownloadPath);
@@ -49,15 +40,13 @@ public class S3BackupStrategy implements BackupStrategy {
int attempt = 0;
while (true) {
try {
s3.putObject(o -> o.bucket(option.bucket())
.contentType("application/octet-stream")
.key(object), bundle);
s3.putObject(option.bucket(), object, bundle, "application/octet-stream");
return new Backup(bundle.getFileName().toString(),
object, "s3",
source.toString(),
subject, incremental, createdAt,
Files.size(bundle));
} catch (SdkException e) {
} catch (IOException e) {
if (attempt >= option.retryAmount()) {
throw new IllegalStateException("Cannot upload " + bundle + " to S3 after "
+ attempt + " retries", e);
@@ -75,7 +64,7 @@ public class S3BackupStrategy implements BackupStrategy {
public void recoverBackup(Backup backup, Path restorePath, boolean ignoreDeletions) {
var target = temporaryDownloadPath.resolve("s3_" + System.currentTimeMillis());
try {
try (var resp = s3.getObject(b -> b.bucket(option.bucket()).key(backup.backupKey()));
try (var resp = s3.getObject(option.bucket(), backup.backupKey());
var fs = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
resp.transferTo(fs);
}
@@ -88,7 +77,7 @@ public class S3BackupStrategy implements BackupStrategy {
@Override
@SneakyThrows
public void deleteBackup(Backup backup) {
s3.deleteObject(b -> b.bucket(option.bucket()).key(backup.backupKey()).build());
s3.deleteObject(option.bucket(), backup.backupKey());
}
@Override
@@ -0,0 +1,59 @@
package io.ib67.sfcraft.s3;
import org.junit.jupiter.api.Test;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Test vectors from
* https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
*/
class AwsV4SignerTest {
private static final String ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE";
private static final String SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
@Test
void getObjectExample() {
var headers = new TreeMap<String, String>();
headers.put("host", "examplebucket.s3.amazonaws.com");
headers.put("range", "bytes=0-9");
headers.put("x-amz-content-sha256", AwsV4Signer.EMPTY_PAYLOAD_SHA256);
headers.put("x-amz-date", "20130524T000000Z");
var authorization = AwsV4Signer.authorization("GET", "/test.txt", "",
headers, AwsV4Signer.EMPTY_PAYLOAD_SHA256,
"20130524T000000Z", "us-east-1", "s3", ACCESS_KEY, SECRET_KEY);
assertEquals("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,"
+ "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,"
+ "Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41",
authorization);
}
@Test
void putObjectExample() {
var payloadHash = AwsV4Signer.sha256Hex("Welcome to Amazon S3.".getBytes(StandardCharsets.UTF_8));
assertEquals("44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072", payloadHash);
var headers = new TreeMap<String, String>();
headers.put("date", "Fri, 24 May 2013 00:00:00 GMT");
headers.put("host", "examplebucket.s3.amazonaws.com");
headers.put("x-amz-content-sha256", payloadHash);
headers.put("x-amz-date", "20130524T000000Z");
headers.put("x-amz-storage-class", "REDUCED_REDUNDANCY");
var authorization = AwsV4Signer.authorization("PUT", "/test%24file.text", "",
headers, payloadHash,
"20130524T000000Z", "us-east-1", "s3", ACCESS_KEY, SECRET_KEY);
assertEquals("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,"
+ "SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class,"
+ "Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd",
authorization);
}
}