Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions src/main/java/io/floci/az/services/blob/BlobServiceHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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__:";
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -1851,7 +1857,7 @@ private Response getBlob(AzureRequest request, String containerName, String blob
if (authFailure != null) {
return authFailure;
}
Optional<StoredObject> object = store.get(objKey(request.accountName(), containerName, blobName));
Optional<StoredObject> object = findBlob(request, containerName, blobName);

if (object.isEmpty()) {
return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.")
Expand Down Expand Up @@ -2100,7 +2106,7 @@ private Response deleteBlob(AzureRequest request, String containerName, String b
return authFailure;
}
return leaseService.exclusively(() -> {
Optional<StoredObject> object = store.get(objKey(request.accountName(), containerName, blobName));
Optional<StoredObject> object = findBlob(request, containerName, blobName);
if (object.isEmpty()) {
return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.")
.toXmlResponse(Response.Status.NOT_FOUND.getStatusCode());
Expand All @@ -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<StoredObject> 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<StoredObject> 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<StoredObject> object = store.get(objKey(request.accountName(), containerName, blobName));
Optional<StoredObject> object = findBlob(request, containerName, blobName);
if (object.isEmpty()) {
return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.")
.toXmlResponse(Response.Status.NOT_FOUND.getStatusCode());
Expand Down Expand Up @@ -2546,7 +2590,7 @@ private Response getBlockList(AzureRequest request, String containerName, String
List<BlobModels.BlockItem> 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()) {
Expand All @@ -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))
Expand Down Expand Up @@ -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<String, String> readUserMetadata(AzureRequest request) {
Map<String, String> metadata = new HashMap<>();
request.headers().getRequestHeaders().forEach((name, values) -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
60 changes: 60 additions & 0 deletions src/test/java/io/floci/az/services/BlobServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down