diff --git a/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java b/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java index 3eb458ed..3b2c0a00 100644 --- a/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java +++ b/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java @@ -42,6 +42,7 @@ public class BlobServiceHandler implements AzureServiceHandler, Resettable { private static final String NS_PREFIX = "__ns__:"; private static final String BLK_PREFIX = "__blk__:"; + private static final String SNAPSHOT_PREFIX = "__snapshot__:"; private static final String USER_METADATA_PREFIX = "UserMeta:"; private static final String CREATION_TIME_KEY = "CreationTime"; private static final String DATALAKE_APPEND_PREFIX = "__abfs_append__:"; @@ -258,6 +259,11 @@ public Response handle(AzureRequest request) { response = dataLakeNotImplemented(); } else if ("PUT".equalsIgnoreCase(method) && "lease".equals(comp)) { response = leaseBlob(request, containerName, blobName); + } else if ("PUT".equalsIgnoreCase(method) && "snapshot".equals(comp)) { + response = snapshotBlob(request, containerName, blobName); + } else if (request.queryParams().containsKey("snapshot") && !"GET".equalsIgnoreCase(method) + && !"HEAD".equalsIgnoreCase(method) && !"DELETE".equalsIgnoreCase(method)) { + response = snapshotIsImmutable(); } else if ("PUT".equalsIgnoreCase(method) && "metadata".equals(comp)) { response = setBlobMetadata(request, containerName, blobName); } else if (("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)) @@ -1851,7 +1857,7 @@ private Response getBlob(AzureRequest request, String containerName, String blob if (authFailure != null) { return authFailure; } - Optional object = store.get(objKey(request.accountName(), containerName, blobName)); + Optional object = findBlob(request, containerName, blobName); if (object.isEmpty()) { return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") @@ -2100,7 +2106,7 @@ private Response deleteBlob(AzureRequest request, String containerName, String b return authFailure; } return leaseService.exclusively(() -> { - Optional object = store.get(objKey(request.accountName(), containerName, blobName)); + Optional object = findBlob(request, containerName, blobName); if (object.isEmpty()) { return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); @@ -2109,23 +2115,61 @@ private Response deleteBlob(AzureRequest request, String containerName, String b if (conditionFailure != null) { return conditionFailure; } - Response leaseFailure = leaseService.validateWrite(request, - objKey(request.accountName(), containerName, blobName)); + String key = blobKey(request, containerName, blobName); + Response leaseFailure = leaseService.validateWrite(request, key); if (leaseFailure != null) { return leaseFailure; } - store.delete(objKey(request.accountName(), containerName, blobName)); - leaseService.onBlobDeleted(objKey(request.accountName(), containerName, blobName)); + store.delete(key); + leaseService.onBlobDeleted(key); return Response.status(Response.Status.ACCEPTED).build(); }); } + private Response snapshotBlob(AzureRequest request, String containerName, String blobName) { + Response authFailure = authorizeCreate(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } + Optional existing = store.get(objKey(request.accountName(), containerName, blobName)); + if (existing.isEmpty()) { + return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") + .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); + } + String snapshot = Instant.now().toString(); + StoredObject blob = existing.get(); + store.put(snapshotKey(request.accountName(), containerName, blobName, snapshot), + new StoredObject(blob.key(), Arrays.copyOf(blob.data(), blob.data().length), + new HashMap<>(blob.metadata()), blob.lastModified(), blob.etag())); + return Response.status(Response.Status.CREATED) + .header("x-ms-snapshot", snapshot) + .header("ETag", blob.etag()) + .build(); + } + + private static Response snapshotIsImmutable() { + return new AzureErrorResponse("SnapshotOperationNotSupported", + "This operation is not supported on a blob snapshot.") + .toXmlResponse(Response.Status.CONFLICT.getStatusCode()); + } + + private Optional findBlob(AzureRequest request, String containerName, String blobName) { + return store.get(blobKey(request, containerName, blobName)); + } + + private static String blobKey(AzureRequest request, String containerName, String blobName) { + String snapshot = request.queryParams().get("snapshot"); + return snapshot == null + ? objKey(request.accountName(), containerName, blobName) + : snapshotKey(request.accountName(), containerName, blobName, snapshot); + } + private Response getBlobMetadata(AzureRequest request, String containerName, String blobName) { Response authFailure = authorizeRead(request, containerName, blobName); if (authFailure != null) { return authFailure; } - Optional object = store.get(objKey(request.accountName(), containerName, blobName)); + Optional object = findBlob(request, containerName, blobName); if (object.isEmpty()) { return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); @@ -2546,7 +2590,7 @@ private Response getBlockList(AzureRequest request, String containerName, String List uncommitted = new ArrayList<>(); if ("committed".equals(listType) || "all".equals(listType)) { - store.get(objKey(request.accountName(), containerName, blobName)) + findBlob(request, containerName, blobName) .ifPresent(blob -> { String meta = blob.metadata().getOrDefault("CommittedBlocks", ""); if (!meta.isBlank()) { @@ -2564,7 +2608,8 @@ private Response getBlockList(AzureRequest request, String containerName, String }); } - if ("uncommitted".equals(listType) || "all".equals(listType)) { + if (("uncommitted".equals(listType) || "all".equals(listType)) + && !request.queryParams().containsKey("snapshot")) { String stagePrefix = blockStagingPrefix(request.accountName(), containerName, blobName); store.scan(k -> k.startsWith(stagePrefix)).stream() .map(so -> new BlobModels.BlockItem(so.key(), (long) so.data().length)) @@ -2715,6 +2760,10 @@ private static String objKey(String accountName, String containerName, String bl return accountName + "/" + containerName + "/" + blobName; } + private static String snapshotKey(String accountName, String containerName, String blobName, String snapshot) { + return SNAPSHOT_PREFIX + objKey(accountName, containerName, blobName) + ":" + snapshot; + } + private static Map readUserMetadata(AzureRequest request) { Map metadata = new HashMap<>(); request.headers().getRequestHeaders().forEach((name, values) -> { diff --git a/src/test/java/io/floci/az/services/BlobCompDispatchTest.java b/src/test/java/io/floci/az/services/BlobCompDispatchTest.java index d733c80f..85978ce5 100644 --- a/src/test/java/io/floci/az/services/BlobCompDispatchTest.java +++ b/src/test/java/io/floci/az/services/BlobCompDispatchTest.java @@ -56,8 +56,7 @@ private void assertBlobIntact() { */ @ParameterizedTest @ValueSource(strings = { - "snapshot", "properties", "tier", "tags", - "page", "undelete", "expiry", "seal" + "properties", "tier", "tags", "page", "undelete", "expiry", "seal" }) void unimplementedBlobCompIsNotMistakenForPutBlob(String comp) { given() diff --git a/src/test/java/io/floci/az/services/BlobServiceTest.java b/src/test/java/io/floci/az/services/BlobServiceTest.java index 29b124ad..d8a9aee2 100644 --- a/src/test/java/io/floci/az/services/BlobServiceTest.java +++ b/src/test/java/io/floci/az/services/BlobServiceTest.java @@ -601,6 +601,41 @@ void appendBlockHonorsConditionsAndLeaseGuards() { .body(equalTo("appended")); } + @Test + void snapshotScopedSasCanReadOnlyItsSnapshot() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body("original") + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + String snapshot = given() + .when().put("/{account}/{container}/{blob}?comp=snapshot", ACCOUNT, CONTAINER, BLOB) + .then() + .statusCode(201) + .extract() + .header("x-ms-snapshot"); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("changed") + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + String snapshotSas = snapshotSas("r", CONTAINER, BLOB, snapshot); + given() + .when().get("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, snapshotSas) + .then() + .statusCode(200) + .body(equalTo("original")); + + given() + .when().get("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, + snapshotSas.replace("snapshot=" + snapshot + "&", "")) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthenticationFailed"); + } + @Test void createOnlySasCanCreateButCannotOverwriteBlob() { given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); @@ -2571,6 +2606,31 @@ private static String sasSignedWith( return sasSignedWith(base64Key, permissions, resource, container, blobName, keyStart, keyExpiry); } + private String snapshotSas(String permissions, String container, String blobName, String snapshot) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0); + OffsetDateTime expiry = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).withNano(0); + String version = "2024-11-04"; + String key = keyMaterial.signingKeyForAccount(ACCOUNT); + String stringToSign = String.join("\n", + permissions, start.toString(), expiry.toString(), canonicalName(container, blobName), + UserDelegationKeyMaterial.SIGNED_OBJECT_ID, UserDelegationKeyMaterial.SIGNED_TENANT_ID, + start.toString(), expiry.toString(), "b", version, + "", "", "", "", "", version, "bs", snapshot, "", "", "", "", "", ""); + return "sv=" + version + + "&st=" + start + + "&se=" + expiry + + "&skoid=" + UserDelegationKeyMaterial.SIGNED_OBJECT_ID + + "&sktid=" + UserDelegationKeyMaterial.SIGNED_TENANT_ID + + "&skt=" + start + + "&ske=" + expiry + + "&sks=b" + + "&skv=" + version + + "&sr=bs" + + "&snapshot=" + snapshot + + "&sp=" + permissions + + "&sig=" + hmac(key, stringToSign); + } + private static String sasSignedWith( String base64Key, String permissions,