From 8a68130537191aeb53555bbab0ad7de11bd3fbfe Mon Sep 17 00:00:00 2001 From: Sophie <49928680+sophiecarreras@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:08:28 -0300 Subject: [PATCH 1/2] Add eight sdkharness conformance checks: file operations and bucket CRUD --- .sdkharness/README.md | 31 +- .sdkharness/tests.tsv | 8 + .sdkharness/tests/conformance/BucketCrud.java | 89 +++++ .../tests/conformance/FilesDeleteVersion.java | 61 ++++ .../tests/conformance/FilesDownloadById.java | 48 +++ .../conformance/FilesDownloadContent.java | 58 ++++ .sdkharness/tests/conformance/FilesHide.java | 62 ++++ .sdkharness/tests/conformance/FilesList.java | 75 +++++ .../tests/conformance/FilesMetadata.java | 76 +++++ .../tests/conformance/FilesUpload.java | 68 ++++ .sdkharness/tests/conformance/Support.java | 311 ++++++++++++++++++ .sdkharness/tests/run-conformance | 64 ++++ 12 files changed, 949 insertions(+), 2 deletions(-) create mode 100644 .sdkharness/tests/conformance/BucketCrud.java create mode 100644 .sdkharness/tests/conformance/FilesDeleteVersion.java create mode 100644 .sdkharness/tests/conformance/FilesDownloadById.java create mode 100644 .sdkharness/tests/conformance/FilesDownloadContent.java create mode 100644 .sdkharness/tests/conformance/FilesHide.java create mode 100644 .sdkharness/tests/conformance/FilesList.java create mode 100644 .sdkharness/tests/conformance/FilesMetadata.java create mode 100644 .sdkharness/tests/conformance/FilesUpload.java create mode 100644 .sdkharness/tests/conformance/Support.java create mode 100755 .sdkharness/tests/run-conformance diff --git a/.sdkharness/README.md b/.sdkharness/README.md index fc2493412..52599ec1d 100644 --- a/.sdkharness/README.md +++ b/.sdkharness/README.md @@ -5,7 +5,7 @@ assertions in `tests/`; the harness builds this checkout, starts a fresh local B2 simulator, and retains and reports the evidence. -One scenario so far: `health/golden-path`, the customer golden path. `tests/run-health` runs +Scenarios so far: `health/golden-path` and eight `conformance` scenarios (below). `tests/run-health` runs `tests/health/GoldenPath.java` as a single-file Java program that authorizes, uploads a small file, downloads it and compares the bytes, lists it, deletes it and confirms it is gone, then prints one result line: @@ -14,13 +14,37 @@ prints one result line: SDKHARNESS_RESULT health golden-path PASS - ``` +## Conformance + +`tests/run-conformance` runs one capability per call, named by `SDKHARNESS_SCENARIO`; every row in +`tests.tsv` points at it. Each scenario is a Java program in `tests/conformance/` that talks to the +simulator through the SDK's public API and asserts what B2's documentation says happens (each file +cites its pages). They share `tests/conformance/Support.java`; a JDK 11 single-file launch cannot +import a second source file, so the wrapper compiles `Support.java` and the check with `javac` +against the harness-built jars, then runs the check class. Each check creates its own +`sdkharness-conf-*` buckets and deletes everything it made, on failure too. + +| Scenario | What it asserts | +|---|---| +| `files.upload` | the response (name, size, sha1, type, fileInfo), listing, byte- and sha1-identical download, an empty file, an unusual file name | +| `files.download_content` | download by name: bytes and headers, a Range, a missing name is 404 | +| `files.download_by_id` | two versions of one name each download as themselves; by name is the latest | +| `files.list` | prefix, paging (page size 2), start name, delimiter folders, versions newest first | +| `files.delete_version` | one version deleted: gone from the listing and 404 by id, the other survives | +| `files.hide` | hide marker is a new version; not in the default listing, visible in the version listing; 404 by name; the original still downloads by id | +| `bucket.crud` | create, duplicate name refused, list, update type and info (revision grows), delete refused while non-empty, delete | +| `files.metadata` | by id (`b2_get_file_info`) and by name (an HTTP HEAD of the download URL: the cell is `partial`); the check records the SDK's own requests and asserts that boundary | + +A failing scenario prints one `SDKHARNESS_RESULT ... FAIL` line with the step and the reason. + ## What the check can and cannot reach - **Only the loopback simulator.** `run-health` refuses any `SDKHARNESS_SIMULATOR_URL` that is not `http://127.0.0.1:`. The SDK's HTTP support for test servers (`HttpClientFactoryImpl.Builder.setSupportInsecureHttp`) is enabled for that reason only. - **Only the simulator's fixed test credential** (`test-key-id` / `test-key`, in the source). - Every ambient `B2_*` variable except `B2_BUCKET_NAME` is dropped first. + Every ambient `B2_*` variable except `B2_BUCKET_NAME` is dropped first (conformance drops all of + them and makes its own buckets). - **No build.** The harness builds the `core` and `httpclient` jars from this checkout and passes them, with their runtime dependencies, as `SDKHARNESS_JAVA_CLASSPATH`. The build files here are not changed. A missing `java` on `PATH` reports `SKIP`, which the harness @@ -28,6 +52,9 @@ SDKHARNESS_RESULT health golden-path PASS - ## Run it by hand +For conformance, set `SDKHARNESS_TEST_LEVEL=conformance`, `SDKHARNESS_SCENARIO=` and run +`.sdkharness/tests/run-conformance` (needs `javac`, and no `B2_BUCKET_NAME`). + Requires JDK 11 or later and a simulator (`backblaze-labs/b2-simulator`) with a bucket named `sdkharness-healthcheck`: diff --git a/.sdkharness/tests.tsv b/.sdkharness/tests.tsv index e57624581..e4dd1139e 100644 --- a/.sdkharness/tests.tsv +++ b/.sdkharness/tests.tsv @@ -1,2 +1,10 @@ test_level scenario target executable health golden-path simulator ./.sdkharness/tests/run-health +conformance files.upload simulator ./.sdkharness/tests/run-conformance +conformance files.download_content simulator ./.sdkharness/tests/run-conformance +conformance files.download_by_id simulator ./.sdkharness/tests/run-conformance +conformance files.list simulator ./.sdkharness/tests/run-conformance +conformance files.delete_version simulator ./.sdkharness/tests/run-conformance +conformance files.hide simulator ./.sdkharness/tests/run-conformance +conformance bucket.crud simulator ./.sdkharness/tests/run-conformance +conformance files.metadata simulator ./.sdkharness/tests/run-conformance diff --git a/.sdkharness/tests/conformance/BucketCrud.java b/.sdkharness/tests/conformance/BucketCrud.java new file mode 100644 index 000000000..7f5cf8412 --- /dev/null +++ b/.sdkharness/tests/conformance/BucketCrud.java @@ -0,0 +1,89 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.exceptions.B2Exception; +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2CreateBucketRequest; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.client.structures.B2UpdateBucketRequest; + +import java.util.Collections; +import java.util.Map; + +/** + * bucket.crud. Run by ../run-conformance (see ../../README.md). + * + *

Create, list, update and delete a bucket: b2_create_bucket returns the bucket with its type and + * info (and refuses a name that is taken, duplicate_bucket_name); b2_list_buckets finds it; b2_update_bucket + * changes the type and info and bumps the revision; b2_delete_bucket "can only delete buckets that + * contain no version of any files" (cannot_delete_non_empty_bucket) and afterwards the bucket is gone. + * https://www.backblaze.com/apidocs/b2-create-bucket + * https://www.backblaze.com/apidocs/b2-list-buckets + * https://www.backblaze.com/apidocs/b2-update-bucket + * https://www.backblaze.com/apidocs/b2-delete-bucket + */ +public class BucketCrud { + public static void main(String[] args) { + Support.run("bucket.crud", () -> { + Support.step("create"); + final String name = Support.newBucketName(); + final Map info = Collections.singletonMap("sdkharness-bucket", "created"); + final B2Bucket created = Support.client.createBucket(B2CreateBucketRequest.builder(name, "allPrivate").setBucketInfo(info).build()); + Support.track(created); + Support.eq("created bucketName", name, created.getBucketName()); + Support.eq("created bucketType", "allPrivate", created.getBucketType()); + Support.eq("created bucketInfo", "created", created.getBucketInfo().get("sdkharness-bucket")); + Support.check(created.getBucketId() != null && !created.getBucketId().isEmpty(), "bucketId is empty"); + + Support.step("duplicate name"); + final B2Exception duplicate = Support.expectError("create with a name already in use", + () -> Support.client.createBucket(name, "allPrivate")); + Support.eq("duplicate create status", 400, duplicate.getStatus()); + Support.eq("duplicate create code", "duplicate_bucket_name", duplicate.getCode()); + + Support.step("list"); + B2Bucket listed = null; + for (B2Bucket b : Support.client.buckets()) { + if (created.getBucketId().equals(b.getBucketId())) { + listed = b; + } + } + Support.check(listed != null, "the new bucket is not in the bucket list"); + Support.eq("listed bucketName", name, listed.getBucketName()); + Support.eq("listed bucketType", "allPrivate", listed.getBucketType()); + final B2Bucket byName = Support.client.getBucketOrNullByName(name); + Support.check(byName != null && created.getBucketId().equals(byName.getBucketId()), "the bucket is not found by name"); + + Support.step("update"); + final B2Bucket updated = Support.client.updateBucket(B2UpdateBucketRequest.builder(created) + .setBucketType("allPublic") + .setBucketInfo(Collections.singletonMap("sdkharness-bucket", "updated")) + .build()); + Support.eq("updated bucketType", "allPublic", updated.getBucketType()); + Support.eq("updated bucketInfo", "updated", updated.getBucketInfo().get("sdkharness-bucket")); + Support.check(updated.getRevision() > created.getRevision(), + "the revision did not increase: " + created.getRevision() + " then " + updated.getRevision()); + final B2Bucket reread = Support.client.getBucketOrNullByName(name); + Support.check(reread != null, "the bucket disappeared after the update"); + Support.eq("re-read bucketType", "allPublic", reread.getBucketType()); + Support.eq("re-read bucketInfo", "updated", reread.getBucketInfo().get("sdkharness-bucket")); + + Support.step("delete refuses a bucket with files"); + final B2FileVersion file = Support.upload(updated, "st/bucket/file.txt", Support.utf8("in the bucket"), "text/plain", null); + final B2Exception notEmpty = Support.expectError("delete of a bucket that holds a file", + () -> Support.client.deleteBucket(updated.getBucketId())); + Support.eq("non-empty delete status", 400, notEmpty.getStatus()); + Support.eq("non-empty delete code", "cannot_delete_non_empty_bucket", notEmpty.getCode()); + Support.check(Support.client.getBucketOrNullByName(name) != null, "the bucket was deleted although it held a file"); + + Support.step("delete"); + Support.client.deleteFileVersion(file); + Support.client.deleteBucket(updated.getBucketId()); + Support.untrack(updated); + Support.check(Support.client.getBucketOrNullByName(name) == null, "the bucket is still found by name after delete"); + for (B2Bucket b : Support.client.buckets()) { + Support.check(!updated.getBucketId().equals(b.getBucketId()), "the deleted bucket is still in the bucket list"); + } + }); + } +} diff --git a/.sdkharness/tests/conformance/FilesDeleteVersion.java b/.sdkharness/tests/conformance/FilesDeleteVersion.java new file mode 100644 index 000000000..c590fdd3d --- /dev/null +++ b/.sdkharness/tests/conformance/FilesDeleteVersion.java @@ -0,0 +1,61 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.exceptions.B2Exception; +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.client.structures.B2ListFileNamesRequest; +import com.backblaze.b2.client.structures.B2ListFileVersionsRequest; + +import java.util.Arrays; +import java.util.List; + +/** + * files.delete_version. Run by ../run-conformance (see ../../README.md). + * + *

b2_delete_file_version "deletes one version of a file"; the other versions of the name stay. + * Once deleted, the version is gone from the version listing and a download of its fileId is a 404 + * not_found, while the surviving version still downloads and is what the name listing shows. + * https://www.backblaze.com/apidocs/b2-delete-file-version + */ +public class FilesDeleteVersion { + public static void main(String[] args) { + Support.run("files.delete_version", () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + final String id = bucket.getBucketId(); + + Support.step("upload two versions"); + final String name = "st/delete/versioned.txt"; + final byte[] firstBytes = Support.utf8("first version"); + final byte[] secondBytes = Support.utf8("second version"); + final B2FileVersion v1 = Support.upload(bucket, name, firstBytes, "text/plain", null); + final B2FileVersion v2 = Support.upload(bucket, name, secondBytes, "text/plain", null); + + Support.step("delete the first version"); + Support.client.deleteFileVersion(v1); + + Support.step("the deleted version is gone"); + final List remaining = Support.versions(id, name); + Support.eq("versions left after deleting the first", 1, remaining.size()); + Support.eq("the version that is left", v2.getFileId(), remaining.get(0).getFileId()); + final B2Exception gone = Support.expectError("download of the deleted version", + () -> Support.downloadById(v1.getFileId())); + Support.eq("deleted version download status", 404, gone.getStatus()); + + Support.step("the other version survives"); + Support.check(Arrays.equals(secondBytes, Support.downloadById(v2.getFileId()).bytes), + "the surviving version's bytes changed"); + Support.eq("name listing", Arrays.asList(name), + Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix(name).build()))); + + Support.step("delete the last version"); + Support.client.deleteFileVersion(v2); + Support.eq("versions left after deleting both", 0, Support.versions(id, name).size()); + Support.eq("names left after deleting both", 0, + Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix(name).build())).size()); + Support.eq("whole-bucket version listing", 0, + Support.names(Support.client.fileVersions(B2ListFileVersionsRequest.builder(id).build())).size()); + }); + } +} diff --git a/.sdkharness/tests/conformance/FilesDownloadById.java b/.sdkharness/tests/conformance/FilesDownloadById.java new file mode 100644 index 000000000..7a1fa8b37 --- /dev/null +++ b/.sdkharness/tests/conformance/FilesDownloadById.java @@ -0,0 +1,48 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; + +import java.util.Arrays; + +/** + * files.download_by_id. Run by ../run-conformance (see ../../README.md). + * + *

b2_download_file_by_id "downloads one file by providing the ID of the file", so with two versions + * of one name each fileId returns its own bytes, while download by name returns the most recent + * version. + * https://www.backblaze.com/apidocs/b2-download-file-by-id + * https://www.backblaze.com/apidocs/b2-download-file-by-name + */ +public class FilesDownloadById { + public static void main(String[] args) { + Support.run("files.download_by_id", () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + + Support.step("upload two versions"); + final String name = "st/by-id/versioned.bin"; + final byte[] first = Support.payload(2048, 3); + final byte[] second = Support.payload(3000, 4); + final B2FileVersion v1 = Support.upload(bucket, name, first, "application/octet-stream", null); + final B2FileVersion v2 = Support.upload(bucket, name, second, "application/octet-stream", null); + Support.check(!v1.getFileId().equals(v2.getFileId()), "two uploads of one name returned the same fileId"); + + Support.step("download first version by id"); + final Support.Downloaded got1 = Support.downloadById(v1.getFileId()); + Support.check(Arrays.equals(first, got1.bytes), "the first fileId did not return the first version's bytes"); + Support.eq("first X-Bz-Content-Sha1", Support.sha1(first), got1.headers.getValueOrNull("X-Bz-Content-Sha1")); + Support.eq("first X-Bz-File-Id", v1.getFileId(), got1.headers.getValueOrNull("X-Bz-File-Id")); + + Support.step("download second version by id"); + final Support.Downloaded got2 = Support.downloadById(v2.getFileId()); + Support.check(Arrays.equals(second, got2.bytes), "the second fileId did not return the second version's bytes"); + Support.eq("second X-Bz-File-Id", v2.getFileId(), got2.headers.getValueOrNull("X-Bz-File-Id")); + + Support.step("by name is the latest"); + final Support.Downloaded byName = Support.downloadByName(bucket.getBucketName(), name); + Support.check(Arrays.equals(second, byName.bytes), "download by name did not return the most recent version"); + }); + } +} diff --git a/.sdkharness/tests/conformance/FilesDownloadContent.java b/.sdkharness/tests/conformance/FilesDownloadContent.java new file mode 100644 index 000000000..800dff951 --- /dev/null +++ b/.sdkharness/tests/conformance/FilesDownloadContent.java @@ -0,0 +1,58 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.exceptions.B2Exception; +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2DownloadByNameRequest; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.util.B2ByteRange; + +import java.util.Arrays; + +/** + * files.download_content. Run by ../run-conformance (see ../../README.md). + * + *

Download by name returns the stored bytes with the headers B2 documents (Content-Length, + * Content-Type, X-Bz-File-Id, X-Bz-File-Name, X-Bz-Content-Sha1); a Range request returns just that + * byte range; a name that does not exist is a 404 not_found. + * https://www.backblaze.com/apidocs/b2-download-file-by-name + */ +public class FilesDownloadContent { + public static void main(String[] args) { + Support.run("files.download_content", () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + + Support.step("upload"); + final String name = "st/download/content.bin"; + final byte[] data = Support.payload(4096, 2); + final B2FileVersion uploaded = Support.upload(bucket, name, data, "application/x-sdkharness", null); + + Support.step("download by name"); + final Support.Downloaded got = Support.downloadByName(bucket.getBucketName(), name); + Support.check(Arrays.equals(data, got.bytes), "downloaded bytes differ from the uploaded bytes"); + Support.eq("X-Bz-Content-Sha1", Support.sha1(data), got.headers.getValueOrNull("X-Bz-Content-Sha1")); + Support.eq("Content-Length", String.valueOf(data.length), got.headers.getValueOrNull("Content-Length")); + Support.eq("Content-Type", "application/x-sdkharness", got.headers.getContentType()); + Support.eq("X-Bz-File-Id", uploaded.getFileId(), got.headers.getValueOrNull("X-Bz-File-Id")); + + Support.step("range"); + final int first = 100; + final int last = 199; + final Support.Downloaded part = new Support.Downloaded(); + Support.client.downloadByName( + B2DownloadByNameRequest.builder(bucket.getBucketName(), name).setRange(B2ByteRange.between(first, last)).build(), + (headers, in) -> { + part.headers = headers; + part.bytes = in.readAllBytes(); + }); + Support.check(Arrays.equals(Arrays.copyOfRange(data, first, last + 1), part.bytes), + "a Range of bytes " + first + "-" + last + " did not return exactly those bytes (got " + part.bytes.length + " bytes)"); + + Support.step("missing name"); + final B2Exception missing = Support.expectError("download of a missing name", + () -> Support.downloadByName(bucket.getBucketName(), "st/download/no-such-file")); + Support.eq("missing name status", 404, missing.getStatus()); + }); + } +} diff --git a/.sdkharness/tests/conformance/FilesHide.java b/.sdkharness/tests/conformance/FilesHide.java new file mode 100644 index 000000000..391edd940 --- /dev/null +++ b/.sdkharness/tests/conformance/FilesHide.java @@ -0,0 +1,62 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.exceptions.B2Exception; +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.client.structures.B2ListFileNamesRequest; + +import java.util.Arrays; +import java.util.List; + +/** + * files.hide. Run by ../run-conformance (see ../../README.md). + * + *

b2_hide_file "hides a file so that downloading by name will not find the file, but previous + * versions of the file are still stored": the hide is a new version with action "hide", the name + * disappears from b2_list_file_names, it stays visible in b2_list_file_versions, and the original + * version still downloads by its fileId. + * https://www.backblaze.com/apidocs/b2-hide-file + */ +public class FilesHide { + public static void main(String[] args) { + Support.run("files.hide", () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + final String id = bucket.getBucketId(); + + Support.step("upload"); + final String name = "st/hide/visible.txt"; + final byte[] data = Support.utf8("hide me"); + final B2FileVersion original = Support.upload(bucket, name, data, "text/plain", null); + Support.eq("name listing before the hide", Arrays.asList(name), + Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix(name).build()))); + + Support.step("hide"); + final B2FileVersion hidden = Support.client.hideFile(id, name); + Support.eq("hide marker action", "hide", hidden.getAction()); + Support.eq("hide marker fileName", name, hidden.getFileName()); + Support.check(!original.getFileId().equals(hidden.getFileId()), "the hide marker reused the uploaded version's fileId"); + + Support.step("not in the default listing"); + Support.eq("name listing after the hide", 0, + Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix(name).build())).size()); + + Support.step("visible as a hidden version"); + final List versions = Support.versions(id, name); + Support.eq("versions after the hide", 2, versions.size()); + Support.eq("newest version is the hide marker", hidden.getFileId(), versions.get(0).getFileId()); + Support.check(versions.get(0).isHide(), "the newest version is not marked as a hide"); + Support.eq("older version is the upload", original.getFileId(), versions.get(1).getFileId()); + + Support.step("download by name is not found"); + final B2Exception notFound = Support.expectError("download by name of a hidden file", + () -> Support.downloadByName(bucket.getBucketName(), name)); + Support.eq("hidden download status", 404, notFound.getStatus()); + + Support.step("the original version is still stored"); + Support.check(Arrays.equals(data, Support.downloadById(original.getFileId()).bytes), + "the hidden file's original version no longer downloads by fileId"); + }); + } +} diff --git a/.sdkharness/tests/conformance/FilesList.java b/.sdkharness/tests/conformance/FilesList.java new file mode 100644 index 000000000..0a33334a8 --- /dev/null +++ b/.sdkharness/tests/conformance/FilesList.java @@ -0,0 +1,75 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.client.structures.B2ListFileNamesRequest; +import com.backblaze.b2.client.structures.B2ListFileVersionsRequest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * files.list. Run by ../run-conformance (see ../../README.md). + * + *

b2_list_file_names returns the newest version of each file "in alphabetical order by file name", + * honours prefix, delimiter (a folder entry per common prefix) and startFileName, and continues across + * pages; b2_list_file_versions returns every version, newest first within a name. The SDK's iterable + * fetches page after page, so a page size of 2 over six files proves it follows the continuation. + * https://www.backblaze.com/apidocs/b2-list-file-names + * https://www.backblaze.com/apidocs/b2-list-file-versions + */ +public class FilesList { + public static void main(String[] args) { + Support.run("files.list", () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + final String id = bucket.getBucketId(); + + Support.step("upload"); + final List listed = Arrays.asList("st/list/a.txt", "st/list/b.txt", "st/list/c.txt", + "st/list/d.txt", "st/list/e.txt", "st/list/sub/f.txt"); + for (String name : listed) { + Support.upload(bucket, name, Support.utf8(name), "text/plain", null); + } + Support.upload(bucket, "st/other/z.txt", Support.utf8("other"), "text/plain", null); + + Support.step("paged listing by prefix"); + final List paged = Support.names(Support.client.fileNames( + B2ListFileNamesRequest.builder(id).setPrefix("st/list/").setMaxFileCount(2).build())); + Support.eq("names under st/list/ with a page size of 2", listed, paged); + + Support.step("whole bucket"); + Support.eq("name count", 7, Support.names(Support.client.fileNames(id)).size()); + + Support.step("start name"); + Support.eq("names from c.txt", Arrays.asList("st/list/c.txt", "st/list/d.txt", "st/list/e.txt", "st/list/sub/f.txt"), + Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id) + .setPrefix("st/list/").setStartFileName("st/list/c.txt").build()))); + + Support.step("delimiter"); + final List folders = new ArrayList<>(); + for (B2FileVersion entry : Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix("st/").setDelimiter("/").build())) { + Support.check(entry.isFolder(), "an entry directly under st/ is not a folder: " + entry.getFileName()); + folders.add(entry.getFileName()); + } + Support.eq("folder entries under st/", Arrays.asList("st/list/", "st/other/"), folders); + + Support.step("versions"); + final B2FileVersion newer = Support.upload(bucket, "st/list/a.txt", Support.utf8("newer a"), "text/plain", null); + final List versions = new ArrayList<>(); + for (B2FileVersion v : Support.client.fileVersions(B2ListFileVersionsRequest.builder(id).setPrefix("st/list/a.txt").build())) { + versions.add(v); + } + Support.eq("versions of st/list/a.txt", 2, versions.size()); + Support.eq("newest version comes first", newer.getFileId(), versions.get(0).getFileId()); + final List current = new ArrayList<>(); + for (B2FileVersion v : Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix("st/list/a.txt").build())) { + current.add(v); + } + Support.eq("names listing shows one entry for st/list/a.txt", 1, current.size()); + Support.eq("names listing shows the newest version", newer.getFileId(), current.get(0).getFileId()); + }); + } +} diff --git a/.sdkharness/tests/conformance/FilesMetadata.java b/.sdkharness/tests/conformance/FilesMetadata.java new file mode 100644 index 000000000..6ffc27715 --- /dev/null +++ b/.sdkharness/tests/conformance/FilesMetadata.java @@ -0,0 +1,76 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * files.metadata, a PARTIAL cell. Run by ../run-conformance (see ../../README.md). + * + *

By id, b2_get_file_info returns contentType, contentLength, contentSha1 and fileInfo ("the custom + * information that was uploaded with the file"; keys come back lower-cased). + * https://www.backblaze.com/apidocs/b2-get-file-info + * B2 has no get-file-info by NAME; that read is the headers of b2_download_file_by_name, which "contain + * the same information they did when the file was uploaded". + * https://www.backblaze.com/apidocs/b2-download-file-by-name + * + *

The boundary is asserted, not routed around: the card says by-id is an endpoint call and by-name is + * emulated with an HTTP HEAD against the download URL. The check records the SDK's own requests and + * requires exactly that, so a change in how the SDK reads by name fails here and the card is corrected. + */ +public class FilesMetadata { + public static void main(String[] args) { + Support.run("files.metadata", true, () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + + Support.step("upload"); + final String name = "st/metadata/meta.bin"; + final byte[] data = Support.payload(1500, 5); + final Map info = Collections.singletonMap("sdkharness-meta", "meta-value"); + final B2FileVersion uploaded = Support.upload(bucket, name, data, "application/x-sdkharness", info); + + Support.step("read by id"); + int mark = Support.wire.size(); + final B2FileVersion byId = Support.client.getFileInfo(uploaded.getFileId()); + final List idRequests = new ArrayList<>(Support.wire.subList(mark, Support.wire.size())); + expectMetadata("by id", byId, uploaded.getFileId(), name, data, info); + + Support.step("by-id request path"); + Support.eq("requests sent for a by-id read", 1, idRequests.size()); + Support.check(idRequests.get(0).startsWith("POST ") && idRequests.get(0).endsWith("/b2_get_file_info"), + "a by-id read was not a POST to b2_get_file_info: " + idRequests); + + Support.step("read by name"); + mark = Support.wire.size(); + final B2FileVersion byName = Support.client.getFileInfoByName(bucket.getBucketName(), name); + final List nameRequests = new ArrayList<>(Support.wire.subList(mark, Support.wire.size())); + expectMetadata("by name", byName, uploaded.getFileId(), name, data, info); + + Support.step("by-name request path"); + Support.eq("requests sent for a by-name read", 1, nameRequests.size()); + final String request = nameRequests.get(0); + Support.check(request.startsWith("HEAD ") && request.contains("/file/" + bucket.getBucketName() + "/"), + "a by-name read was not a HEAD of the download URL: " + request); + Support.note("PARTIAL: by id is the b2_get_file_info endpoint; by name has no endpoint, so the SDK sends " + + "an HTTP HEAD to the b2_download_file_by_name URL and builds the file version from its headers"); + }); + } + + private static void expectMetadata(String how, B2FileVersion got, String fileId, String name, byte[] data, + Map info) { + Support.eq(how + " fileId", fileId, got.getFileId()); + Support.eq(how + " fileName", name, got.getFileName()); + Support.eq(how + " contentLength", (long) data.length, got.getContentLength()); + Support.eq(how + " contentSha1", Support.sha1(data), got.getContentSha1()); + Support.eq(how + " contentType", "application/x-sdkharness", got.getContentType()); + for (Map.Entry entry : info.entrySet()) { + Support.eq(how + " fileInfo " + entry.getKey(), entry.getValue(), got.getFileInfo().get(entry.getKey())); + } + } +} diff --git a/.sdkharness/tests/conformance/FilesUpload.java b/.sdkharness/tests/conformance/FilesUpload.java new file mode 100644 index 000000000..91fd758ef --- /dev/null +++ b/.sdkharness/tests/conformance/FilesUpload.java @@ -0,0 +1,68 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.client.structures.B2ListFileNamesRequest; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +/** + * files.upload. Run by ../run-conformance (see ../../README.md). + * + *

Upload, then check what B2 documents about the stored file: b2_upload_file returns the file's + * name, size, contentSha1 (the SHA1 of the bytes stored), content type, fileId and fileInfo, with + * custom keys lower-cased, and the bytes read back are the bytes sent. + * https://www.backblaze.com/apidocs/b2-upload-file + */ +public class FilesUpload { + public static void main(String[] args) { + Support.run("files.upload", () -> { + Support.step("bucket"); + final B2Bucket bucket = Support.bucket(); + + Support.step("upload"); + final String name = "st/upload/data.bin"; + final byte[] data = Support.payload(64 * 1024 + 17, 1); + final Map info = Collections.singletonMap("sdkharness-key", "sdkharness-value"); + final B2FileVersion uploaded = Support.upload(bucket, name, data, "application/x-sdkharness", info); + + Support.step("response"); + Support.eq("action", "upload", uploaded.getAction()); + Support.eq("fileName", name, uploaded.getFileName()); + Support.eq("contentLength", (long) data.length, uploaded.getContentLength()); + Support.eq("contentSha1", Support.sha1(data), uploaded.getContentSha1()); + Support.eq("contentType", "application/x-sdkharness", uploaded.getContentType()); + Support.eq("fileInfo", "sdkharness-value", uploaded.getFileInfo().get("sdkharness-key")); + Support.check(uploaded.getFileId() != null && !uploaded.getFileId().isEmpty(), "fileId is empty"); + + Support.step("list"); + boolean listed = false; + for (B2FileVersion v : Support.client.fileNames(B2ListFileNamesRequest.builder(bucket.getBucketId()).setPrefix(name).build())) { + listed |= name.equals(v.getFileName()) && uploaded.getFileId().equals(v.getFileId()); + } + Support.check(listed, "the uploaded file is not listed under its fileId"); + + Support.step("download"); + final Support.Downloaded got = Support.downloadById(uploaded.getFileId()); + Support.check(Arrays.equals(data, got.bytes), "downloaded bytes differ from the uploaded bytes"); + Support.eq("download X-Bz-Content-Sha1", Support.sha1(data), got.headers.getValueOrNull("X-Bz-Content-Sha1")); + + Support.step("empty file"); + final B2FileVersion empty = Support.upload(bucket, "st/upload/empty.bin", new byte[0], "application/octet-stream", null); + Support.eq("empty contentLength", 0L, empty.getContentLength()); + Support.eq("empty contentSha1", "da39a3ee5e6b4b0d3255bfef95601890afd80709", empty.getContentSha1()); + Support.eq("empty download length", 0, Support.downloadById(empty.getFileId()).bytes.length); + + Support.step("unusual name"); + final String odd = "st/upload/sp ace/ünï+cöde.txt"; + final byte[] oddData = Support.utf8("unusual name"); + final B2FileVersion oddVersion = Support.upload(bucket, odd, oddData, "text/plain", null); + Support.eq("unusual fileName", odd, oddVersion.getFileName()); + final Support.Downloaded oddGot = Support.downloadByName(bucket.getBucketName(), odd); + Support.check(Arrays.equals(oddData, oddGot.bytes), "bytes of the unusually named file differ"); + }); + } +} diff --git a/.sdkharness/tests/conformance/Support.java b/.sdkharness/tests/conformance/Support.java new file mode 100644 index 000000000..5977dfea9 --- /dev/null +++ b/.sdkharness/tests/conformance/Support.java @@ -0,0 +1,311 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.B2ClientConfig; +import com.backblaze.b2.client.B2StorageClient; +import com.backblaze.b2.client.contentSources.B2ByteArrayContentSource; +import com.backblaze.b2.client.contentSources.B2Headers; +import com.backblaze.b2.client.exceptions.B2Exception; +import com.backblaze.b2.client.structures.B2Bucket; +import com.backblaze.b2.client.structures.B2FileVersion; +import com.backblaze.b2.client.structures.B2ListFileVersionsRequest; +import com.backblaze.b2.client.structures.B2UploadFileRequest; +import com.backblaze.b2.client.webApiHttpClient.B2StorageHttpClientBuilder; +import com.backblaze.b2.client.webApiHttpClient.HttpClientFactory; +import com.backblaze.b2.client.webApiHttpClient.HttpClientFactoryImpl; +import org.apache.http.HttpRequestInterceptor; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; + +/** + * Shared plumbing for the sdkharness conformance checks. Compiled together with one check class + * by ../run-conformance (a JDK 11 single-file launch cannot import a second source file). + * + *

Each check is one {@code main} that calls {@link #run}. This prints exactly one result line, + * {@code CONFORMANCE b2-sdk-java @simulator: PASS | FAIL ( -- )}, after + * deleting every bucket and file the check created. Anything else it prints is a {@code NOTE} line. + * It never prints a response body or a credential. + */ +public final class Support { + static final String SLUG = "b2-sdk-java"; + // The simulator's fixed test credential. No other credential is ever used. + private static final String KEY_ID = "test-key-id"; + private static final String KEY = "test-key"; + + /** The body of one check. */ + interface Body { + void run() throws Exception; + } + + /** One SDK call that is expected to throw. */ + interface Call { + void run() throws Exception; + } + + /** A failed assertion; the step is whatever {@link #step} last named. */ + static final class Failure extends RuntimeException { + Failure(String detail) { + super(detail); + } + } + + /** What a download delivered: the response headers and every byte. */ + static final class Downloaded { + B2Headers headers; + byte[] bytes; + } + + static B2StorageClient client; + /** "METHOD uri" of every HTTP request the SDK sent, when the check asked for the recording client. */ + static final List wire = Collections.synchronizedList(new ArrayList<>()); + + private static String capability = "unknown"; + private static String step = "configuration"; + private static final List created = new ArrayList<>(); + + private Support() { + } + + // ---- the harness around one check ---------------------------------------------------------- + + static void run(String cap, Body body) { + run(cap, false, body); + } + + static void run(String cap, boolean recordWire, Body body) { + capability = cap; + String failure = null; + try { + final String url = System.getenv("SDKHARNESS_SIMULATOR_URL"); + // run-conformance checked this already; the SDK's http switch must never reach anything else. + if (url == null || !url.matches("http://127\\.0\\.0\\.1:[0-9]{1,5}/?")) { + throw new Failure("simulator URL must be an IPv4 loopback HTTP origin"); + } + client = newClient(url, recordWire); + body.run(); + } catch (Failure f) { + failure = step + " -- " + tidy(f.getMessage()); + } catch (Throwable t) { + failure = step + " -- " + describe(t); + } finally { + cleanup(); + } + if (failure == null) { + System.out.println("CONFORMANCE " + SLUG + " " + capability + " @simulator: PASS"); + } else { + System.out.println("CONFORMANCE " + SLUG + " " + capability + " @simulator: FAIL (" + failure + ")"); + System.exit(1); + } + } + + private static B2StorageClient newClient(String masterUrl, boolean recordWire) { + final B2ClientConfig config = B2ClientConfig.builder(KEY_ID, KEY, "b2-sdk-java-sdkharness-conformance") + .setMasterUrl(masterUrl) + .build(); + // The simulator speaks plain HTTP on loopback, so this enables the SDK's documented + // test-environment switch; everything else is the default client. + final HttpClientFactoryImpl.Builder httpBuilder = HttpClientFactoryImpl.builder().setSupportInsecureHttp(true); + final HttpClientFactory factory = recordWire ? recordingFactory(httpBuilder.createRequestConfig()) + : httpBuilder.build(); + return B2StorageHttpClientBuilder.builder(config).setHttpClientFactory(factory).build(); + } + + /** The SDK's own request settings, plus a record of each request's method and URI. */ + private static HttpClientFactory recordingFactory(RequestConfig requestConfig) { + return new HttpClientFactory() { + @Override + public CloseableHttpClient create() { + return HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .addInterceptorFirst((HttpRequestInterceptor) (request, context) -> + wire.add(request.getRequestLine().getMethod() + " " + request.getRequestLine().getUri())) + .build(); + } + + @Override + public void close() { + } + }; + } + + /** Deletes every file version and bucket this check created, on the error path too. */ + private static void cleanup() { + if (client == null) { + return; + } + for (B2Bucket bucket : created) { + try { + client.deleteAllFilesInBucket(bucket.getBucketId()); + client.deleteBucket(bucket.getBucketId()); + } catch (B2Exception | RuntimeException e) { + note("cleanup of " + bucket.getBucketName() + " failed: " + describe(e)); + } + } + try { + client.close(); + } catch (RuntimeException e) { + // nothing left to release + } + } + + // ---- reporting ------------------------------------------------------------------------------ + + static void step(String name) { + step = name; + } + + static void note(String text) { + System.out.println("NOTE " + SLUG + " " + capability + ": " + tidy(text)); + } + + static void fail(String detail) { + throw new Failure(detail); + } + + static void check(boolean ok, String detail) { + if (!ok) { + fail(detail); + } + } + + static void eq(String what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + fail(what + ": expected " + expected + " but got " + actual); + } + } + + /** One line, printable, capped: a detail never carries a response body or a multi-line diagnostic. */ + static String tidy(String s) { + final String line = String.valueOf(s).replaceAll("\\s+", " ").replaceAll("[^\\x20-\\x7E]", "?"); + return line.length() > 200 ? line.substring(0, 200) : line; + } + + static String describe(Throwable t) { + final StringBuilder sb = new StringBuilder(t.getClass().getSimpleName()); + if (t instanceof B2Exception) { + sb.append(" status=").append(((B2Exception) t).getStatus()).append(" code=").append(((B2Exception) t).getCode()); + } else if (t.getMessage() != null) { + sb.append(' ').append(t.getMessage()); + } + return tidy(sb.toString()); + } + + /** Runs {@code call}, which must fail with a B2 service error, and returns that error. */ + static B2Exception expectError(String what, Call call) { + try { + call.run(); + } catch (Failure f) { + throw f; + } catch (B2Exception e) { + return e; + } catch (Exception e) { + fail(what + ": expected a B2 error but got " + describe(e)); + } + fail(what + ": expected an error but the call succeeded"); + return null; + } + + // ---- fixtures ------------------------------------------------------------------------------- + + /** A fresh private bucket named sdkharness-conf-, deleted (with its files) on exit. */ + static B2Bucket bucket() throws B2Exception { + final B2Bucket bucket = client.createBucket(newBucketName(), "allPrivate"); + created.add(bucket); + return bucket; + } + + static String newBucketName() { + return "sdkharness-conf-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + /** Records a bucket the check created itself, so cleanup deletes it if the check does not. */ + static void track(B2Bucket bucket) { + created.add(bucket); + } + + /** The check deleted this bucket itself, so cleanup has nothing left to do for it. */ + static void untrack(B2Bucket bucket) { + created.removeIf(b -> b.getBucketId().equals(bucket.getBucketId())); + } + + static B2FileVersion upload(B2Bucket bucket, String name, byte[] data, String contentType, + Map fileInfo) throws B2Exception { + final B2UploadFileRequest.Builder request = B2UploadFileRequest + .builder(bucket.getBucketId(), name, contentType, B2ByteArrayContentSource.build(data)); + if (fileInfo != null) { + request.setCustomFields(fileInfo); + } + return client.uploadSmallFile(request.build()); + } + + /** Deterministic bytes that cover all 256 values, so a text-only path cannot pass. */ + static byte[] payload(int length, long seed) { + final byte[] data = new byte[length]; + new Random(seed).nextBytes(data); + for (int i = 0; i < Math.min(256, length); i++) { + data[i] = (byte) i; + } + return data; + } + + static String sha1(byte[] data) { + try { + final StringBuilder hex = new StringBuilder(); + for (byte b : MessageDigest.getInstance("SHA-1").digest(data)) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + static Downloaded downloadById(String fileId) throws B2Exception { + final Downloaded got = new Downloaded(); + client.downloadById(fileId, (headers, in) -> { + got.headers = headers; + got.bytes = in.readAllBytes(); + }); + return got; + } + + static Downloaded downloadByName(String bucketName, String fileName) throws B2Exception { + final Downloaded got = new Downloaded(); + client.downloadByName(bucketName, fileName, (headers, in) -> { + got.headers = headers; + got.bytes = in.readAllBytes(); + }); + return got; + } + + /** Every version (uploads and hide markers) whose name starts with {@code prefix}, as listed by B2. */ + static List versions(String bucketId, String prefix) throws B2Exception { + final List all = new ArrayList<>(); + for (B2FileVersion version : client.fileVersions(B2ListFileVersionsRequest.builder(bucketId).setPrefix(prefix).build())) { + all.add(version); + } + return all; + } + + static List names(Iterable versions) { + final List names = new ArrayList<>(); + for (B2FileVersion version : versions) { + names.add(version.getFileName()); + } + return names; + } + + static byte[] utf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/.sdkharness/tests/run-conformance b/.sdkharness/tests/run-conformance new file mode 100755 index 000000000..b9bc1ccba --- /dev/null +++ b/.sdkharness/tests/run-conformance @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Copyright 2026, Backblaze Inc. All Rights Reserved. +# License https://www.backblaze.com/using_b2_code.html +# +# sdkharness conformance checks for this checkout: one scenario per run, named by +# SDKHARNESS_SCENARIO (a capability id). See ../README.md. +# +# Each check is a Java program in conformance/ that shares conformance/Support.java. A JDK 11 +# single-file launch cannot import a second source file, so both are compiled here with javac +# against the SDK jars the harness built, then the check class is run. +set -u + +LEVEL=conformance +SLUG=b2-sdk-java +SCENARIO="${SDKHARNESS_SCENARIO:-}" +HERE="$(cd "$(dirname "$0")" && pwd -P)" +result() { printf 'SDKHARNESS_RESULT\t%s\t%s\t%s\t%s\n' "$LEVEL" "$SCENARIO" "$1" "$2"; } +fail() { result FAIL "$1"; exit 1; } +# A detail is one printable line, capped: a compiler diagnostic never goes into a result as a block. +tidy() { printf '%s' "$1" | tr '\n\r\t' ' ' | tr -cd '[:print:]' | cut -c1-200; } + +[ "${SDKHARNESS_TEST_LEVEL:-}" = "$LEVEL" ] || fail 'configuration: unexpected test level' +case "$SCENARIO" in + files.upload) class=FilesUpload ;; + files.download_content) class=FilesDownloadContent ;; + files.download_by_id) class=FilesDownloadById ;; + files.list) class=FilesList ;; + files.delete_version) class=FilesDeleteVersion ;; + files.hide) class=FilesHide ;; + files.metadata) class=FilesMetadata ;; + bucket.crud) class=BucketCrud ;; + *) fail 'configuration: no check for this scenario' ;; +esac +[[ "${SDKHARNESS_SIMULATOR_URL:-}" =~ ^http://127\.0\.0\.1:[0-9]{1,5}/?$ ]] \ + || fail 'configuration: simulator URL must be an IPv4 loopback HTTP origin' +[ -n "${SDKHARNESS_JAVA_CLASSPATH:-}" ] \ + || fail 'configuration: SDKHARNESS_JAVA_CLASSPATH is unset (the core and httpclient jars built from this checkout, plus their runtime dependencies)' +command -v javac >/dev/null 2>&1 && command -v java >/dev/null 2>&1 \ + || { result SKIP 'missing-runtime -- no JDK (javac and java) on PATH'; exit 0; } + +# Only the simulator's fixed credential (in Support.java) reaches the SDK: every ambient B2_* is dropped. +for name in $(compgen -e); do case "$name" in B2_*) unset "$name" ;; esac; done + +work="$(mktemp -d "${TMPDIR:-/tmp}/sdkharness-java-conformance.XXXXXX")" || fail 'setup: could not create a scratch directory' +trap 'rm -rf "$work"' EXIT + +if ! javac --release 11 -nowarn -d "$work/classes" -cp "$SDKHARNESS_JAVA_CLASSPATH" \ + "$HERE/conformance/Support.java" "$HERE/conformance/$class.java" >"$work/javac.log" 2>&1; then + fail "build: the check did not compile against this checkout: $(tidy "$(head -c 400 "$work/javac.log")")" +fi + +log="$work/check.log" +if java -cp "$work/classes:$SDKHARNESS_JAVA_CLASSPATH" "$class" >"$log" 2>&1; then rc=0; else rc=$?; fi + +prefix="CONFORMANCE $SLUG $SCENARIO @" +grep -Fv "$prefix" "$log" || true +count="$(grep -Fc "$prefix" "$log" 2>/dev/null || true)" +[ "$count" = 1 ] || fail "contract: expected one result line, got $count (exit $rc)" +verdict="$(grep -F "$prefix" "$log")"; verdict="${verdict#*: }" +case "$verdict" in + PASS) [ "$rc" = 0 ] || fail "contract: PASS exited $rc"; result PASS - ;; + 'FAIL ('*')') reason="${verdict#FAIL (}"; result FAIL "${reason%)}"; exit 1 ;; + *) fail 'contract: malformed result line' ;; +esac From 4eb31bcd2799f3e7a5749a59f2869b42869cae3a Mon Sep 17 00:00:00 2001 From: scarreras Date: Wed, 14 Oct 2026 13:19:05 -0300 Subject: [PATCH 2/2] Address review: fail on cleanup errors, enforce loopback at request time, anchor the verdict match - A bucket or file the check could not delete now fails the verdict; every deletion is still attempted and the single result line is still printed. - Every check builds its client from one guarded HTTP client factory that refuses any request whose target host is not 127.0.0.1 (redirect hops included) and fails the verdict if one was attempted. GuardSelfTest proves it. - run-conformance matches the result line at column 1 with index(), not anywhere in a line. - README: say what is guaranteed (formatting and capping, not redaction). Co-Authored-By: Claude Sonnet 5.5 --- .sdkharness/README.md | 25 ++++- .../tests/conformance/GuardSelfTest.java | 86 ++++++++++++++++ .sdkharness/tests/conformance/Support.java | 97 ++++++++++++++++--- .sdkharness/tests/run-conformance | 7 +- 4 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 .sdkharness/tests/conformance/GuardSelfTest.java diff --git a/.sdkharness/README.md b/.sdkharness/README.md index 52599ec1d..3c4d0bac1 100644 --- a/.sdkharness/README.md +++ b/.sdkharness/README.md @@ -22,7 +22,8 @@ simulator through the SDK's public API and asserts what B2's documentation says cites its pages). They share `tests/conformance/Support.java`; a JDK 11 single-file launch cannot import a second source file, so the wrapper compiles `Support.java` and the check with `javac` against the harness-built jars, then runs the check class. Each check creates its own -`sdkharness-conf-*` buckets and deletes everything it made, on failure too. +`sdkharness-conf-*` buckets and tries to delete everything it made, on failure too; it attempts every +deletion, and a deletion that fails turns the verdict into a `FAIL` (`cleanup -- left N bucket(s) behind`). | Scenario | What it asserts | |---|---| @@ -35,13 +36,20 @@ against the harness-built jars, then runs the check class. Each check creates it | `bucket.crud` | create, duplicate name refused, list, update type and info (revision grows), delete refused while non-empty, delete | | `files.metadata` | by id (`b2_get_file_info`) and by name (an HTTP HEAD of the download URL: the cell is `partial`); the check records the SDK's own requests and asserts that boundary | -A failing scenario prints one `SDKHARNESS_RESULT ... FAIL` line with the step and the reason. +A failing scenario prints one `SDKHARNESS_RESULT ... FAIL` line with the step and the reason. Details are +formatted to one capped printable line from exception class names, status codes and the check's own +assertions; that is formatting, not redaction, and other lines the check prints are forwarded as they +are. The only credential in play is the simulator's fixed test one. ## What the check can and cannot reach -- **Only the loopback simulator.** `run-health` refuses any `SDKHARNESS_SIMULATOR_URL` that - is not `http://127.0.0.1:`. The SDK's HTTP support for test servers - (`HttpClientFactoryImpl.Builder.setSupportInsecureHttp`) is enabled for that reason only. +- **Only the loopback simulator.** `run-health` and `run-conformance` refuse any + `SDKHARNESS_SIMULATOR_URL` that is not `http://127.0.0.1:`. In conformance the SDK is also + held to it at request time: every check builds its client from one guarded HTTP client factory + (`Support.guardedFactory`, plain HTTP only) that refuses any request whose target host is not + `127.0.0.1`, redirect hops included, and a refused request fails the verdict + (`loopback guard -- blocked a request to non-loopback host ...`). `health` is guarded by its origin + check only. `conformance/GuardSelfTest.java` proves the guard (see below). - **Only the simulator's fixed test credential** (`test-key-id` / `test-key`, in the source). Every ambient `B2_*` variable except `B2_BUCKET_NAME` is dropped first (conformance drops all of them and makes its own buckets). @@ -55,6 +63,13 @@ A failing scenario prints one `SDKHARNESS_RESULT ... FAIL` line with the step an For conformance, set `SDKHARNESS_TEST_LEVEL=conformance`, `SDKHARNESS_SCENARIO=` and run `.sdkharness/tests/run-conformance` (needs `javac`, and no `B2_BUCKET_NAME`). +To re-prove the loopback guard (no simulator needed; `$CP` is the same class path): + +```bash +javac --release 11 -d /tmp/guard -cp "$CP" .sdkharness/tests/conformance/Support.java .sdkharness/tests/conformance/GuardSelfTest.java +java -cp "/tmp/guard:$CP" GuardSelfTest # GUARD-SELFTEST b2-sdk-java: PASS +``` + Requires JDK 11 or later and a simulator (`backblaze-labs/b2-simulator`) with a bucket named `sdkharness-healthcheck`: diff --git a/.sdkharness/tests/conformance/GuardSelfTest.java b/.sdkharness/tests/conformance/GuardSelfTest.java new file mode 100644 index 000000000..01e2a644f --- /dev/null +++ b/.sdkharness/tests/conformance/GuardSelfTest.java @@ -0,0 +1,86 @@ +// Copyright 2026, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +import com.backblaze.b2.client.webApiHttpClient.HttpClientFactory; +import com.backblaze.b2.client.webApiHttpClient.HttpClientFactoryImpl; +import com.sun.net.httpserver.HttpServer; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; + +import java.net.InetAddress; +import java.net.InetSocketAddress; + +/** + * Proves the loopback guard in Support. Not a harness scenario (it has no row in tests.tsv); run by + * hand, see ../../README.md. Needs no simulator: it starts its own 127.0.0.1 server. + * + *

It checks that a request to a loopback server passes, that an absolute request URI to a + * non-loopback host (a documentation address, never contacted) is refused before it is sent, and that + * a loopback server redirecting to a non-loopback host is refused on the redirect hop. + */ +public final class GuardSelfTest { + private GuardSelfTest() { + } + + public static void main(String[] args) throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0); + server.createContext("/ok", ex -> { + ex.sendResponseHeaders(200, -1); + ex.close(); + }); + server.createContext("/redirect", ex -> { + ex.getResponseHeaders().add("Location", "http://203.0.113.7:9/elsewhere"); + ex.sendResponseHeaders(302, -1); + ex.close(); + }); + server.start(); + final String base = "http://127.0.0.1:" + server.getAddress().getPort(); + final HttpClientFactory factory = Support.guardedFactory( + HttpClientFactoryImpl.builder().setSupportInsecureHttp(true).createRequestConfig(), false); + String problem = null; + try (CloseableHttpClient client = factory.create()) { + try (CloseableHttpResponse r = client.execute(new HttpGet(base + "/ok"))) { + if (r.getStatusLine().getStatusCode() != 200) { + problem = "a loopback request did not reach the server"; + } + } + if (problem == null && !Support.blocked.isEmpty()) { + problem = "a loopback request was blocked"; + } + if (problem == null) { + problem = mustBeBlocked(client, new HttpGet("http://203.0.113.7:9/absolute"), "an absolute non-loopback URL", "203.0.113.7"); + } + if (problem == null) { + problem = mustBeBlocked(client, new HttpGet(base + "/redirect"), "a redirect to a non-loopback host", "203.0.113.7"); + } + if (problem == null) { + problem = mustBeBlocked(client, new HttpGet("http://localhost:" + server.getAddress().getPort() + "/ok"), + "a loopback name that is not 127.0.0.1", "localhost"); + } + } catch (Exception e) { + problem = "unexpected " + Support.describe(e); + } finally { + factory.close(); + server.stop(0); + } + if (problem == null) { + System.out.println("GUARD-SELFTEST " + Support.SLUG + ": PASS"); + } else { + System.out.println("GUARD-SELFTEST " + Support.SLUG + ": FAIL (" + Support.tidy(problem) + ")"); + System.exit(1); + } + } + + private static String mustBeBlocked(CloseableHttpClient client, HttpGet get, String what, String host) { + final int before = Support.blocked.size(); + try (CloseableHttpResponse r = client.execute(get)) { + return what + " was sent (status " + r.getStatusLine().getStatusCode() + ")"; + } catch (Exception e) { + if (Support.blocked.size() != before + 1 || !host.equals(Support.blocked.get(before))) { + return what + " failed without being blocked by the guard: " + Support.describe(e); + } + return null; + } + } +} diff --git a/.sdkharness/tests/conformance/Support.java b/.sdkharness/tests/conformance/Support.java index 5977dfea9..6b6b8f973 100644 --- a/.sdkharness/tests/conformance/Support.java +++ b/.sdkharness/tests/conformance/Support.java @@ -13,11 +13,21 @@ import com.backblaze.b2.client.webApiHttpClient.B2StorageHttpClientBuilder; import com.backblaze.b2.client.webApiHttpClient.HttpClientFactory; import com.backblaze.b2.client.webApiHttpClient.HttpClientFactoryImpl; +import org.apache.http.HttpException; +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; import org.apache.http.HttpRequestInterceptor; import org.apache.http.client.config.RequestConfig; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.conn.socket.PlainConnectionSocketFactory; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.protocol.HttpCoreContext; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; @@ -33,8 +43,14 @@ * *

Each check is one {@code main} that calls {@link #run}. This prints exactly one result line, * {@code CONFORMANCE b2-sdk-java @simulator: PASS | FAIL ( -- )}, after - * deleting every bucket and file the check created. Anything else it prints is a {@code NOTE} line. - * It never prints a response body or a credential. + * deleting every bucket and file the check created; a deletion that fails turns the verdict into a + * FAIL. Anything else it prints is a {@code NOTE} line. Its own lines are one capped printable line + * ({@link #tidy}) built from exception class names, status codes and the check's own assertions, never + * from a response body; this is formatting, not redaction (the only credential is the simulator's fixed + * test one). + * + *

Every SDK request goes through {@link #guardedFactory}, which refuses any request whose target + * host is not 127.0.0.1 and fails the verdict if one was attempted. {@code GuardSelfTest} proves it. */ public final class Support { static final String SLUG = "b2-sdk-java"; @@ -72,6 +88,10 @@ static final class Downloaded { private static String capability = "unknown"; private static String step = "configuration"; private static final List created = new ArrayList<>(); + private static final String LOOPBACK_HOST = "127.0.0.1"; + /** Hosts of requests the guard refused (the SDK may swallow the exception, so the verdict reads this). */ + static final List blocked = Collections.synchronizedList(new ArrayList<>()); + private static final List cleanupErrors = new ArrayList<>(); private Support() { } @@ -87,7 +107,7 @@ static void run(String cap, boolean recordWire, Body body) { String failure = null; try { final String url = System.getenv("SDKHARNESS_SIMULATOR_URL"); - // run-conformance checked this already; the SDK's http switch must never reach anything else. + // run-conformance checked this already; the guard in newClient enforces it again on every request. if (url == null || !url.matches("http://127\\.0\\.0\\.1:[0-9]{1,5}/?")) { throw new Failure("simulator URL must be an IPv4 loopback HTTP origin"); } @@ -100,6 +120,11 @@ static void run(String cap, boolean recordWire, Body body) { } finally { cleanup(); } + if (!blocked.isEmpty()) { + failure = "loopback guard -- blocked a request to non-loopback host " + tidy(blocked.get(0)); + } else if (failure == null && !cleanupErrors.isEmpty()) { + failure = "cleanup -- left " + cleanupErrors.size() + " bucket(s) behind: " + tidy(String.join("; ", cleanupErrors)); + } if (failure == null) { System.out.println("CONFORMANCE " + SLUG + " " + capability + " @simulator: PASS"); } else { @@ -112,33 +137,75 @@ private static B2StorageClient newClient(String masterUrl, boolean recordWire) { final B2ClientConfig config = B2ClientConfig.builder(KEY_ID, KEY, "b2-sdk-java-sdkharness-conformance") .setMasterUrl(masterUrl) .build(); - // The simulator speaks plain HTTP on loopback, so this enables the SDK's documented - // test-environment switch; everything else is the default client. - final HttpClientFactoryImpl.Builder httpBuilder = HttpClientFactoryImpl.builder().setSupportInsecureHttp(true); - final HttpClientFactory factory = recordWire ? recordingFactory(httpBuilder.createRequestConfig()) - : httpBuilder.build(); - return B2StorageHttpClientBuilder.builder(config).setHttpClientFactory(factory).build(); + // The SDK's own request settings (timeouts, no URI normalization); the guarded factory below + // supplies plain HTTP for the simulator, which is the SDK's documented test-environment switch. + final RequestConfig requestConfig = HttpClientFactoryImpl.builder().setSupportInsecureHttp(true).createRequestConfig(); + return B2StorageHttpClientBuilder.builder(config).setHttpClientFactory(guardedFactory(requestConfig, recordWire)).build(); } - /** The SDK's own request settings, plus a record of each request's method and URI. */ - private static HttpClientFactory recordingFactory(RequestConfig requestConfig) { + /** + * The one HTTP client factory every check uses. It mirrors HttpClientFactoryImpl (same request + * config, pooled connections revalidated after 2 s, 4 s keep-alive) with plain HTTP only, and an + * interceptor that refuses any request whose target host is not 127.0.0.1 before it is sent, + * including each hop of a redirect. A refused request is recorded in {@link #blocked}, which fails + * the verdict even if the SDK handles the exception. + */ + static HttpClientFactory guardedFactory(RequestConfig requestConfig, boolean recordWire) { + final Registry registry = RegistryBuilder.create() + .register("http", new PlainConnectionSocketFactory()).build(); + final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry); + connectionManager.setMaxTotal(100); + connectionManager.setDefaultMaxPerRoute(100); + connectionManager.setValidateAfterInactivity(2000); return new HttpClientFactory() { @Override public CloseableHttpClient create() { return HttpClients.custom() + .setConnectionManager(connectionManager) + .setConnectionManagerShared(true) .setDefaultRequestConfig(requestConfig) - .addInterceptorFirst((HttpRequestInterceptor) (request, context) -> - wire.add(request.getRequestLine().getMethod() + " " + request.getRequestLine().getUri())) + .setKeepAliveStrategy((response, context) -> 4000) + .addInterceptorFirst((HttpRequestInterceptor) (request, context) -> { + requireLoopback(request, (HttpHost) context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST)); + if (recordWire) { + wire.add(request.getRequestLine().getMethod() + " " + request.getRequestLine().getUri()); + } + }) .build(); } @Override public void close() { + connectionManager.shutdown(); } }; } - /** Deletes every file version and bucket this check created, on the error path too. */ + /** + * Throws, and records the host, unless the request's target is 127.0.0.1. The target host is the + * one the client is about to connect to (set on the context before interceptors run, also on each + * redirect hop); an absolute request URI is checked as well, and a missing target fails closed. + */ + static void requireLoopback(HttpRequest request, HttpHost target) throws HttpException { + String host = target == null ? null : target.getHostName(); + if (LOOPBACK_HOST.equals(host)) { + final String uri = request.getRequestLine().getUri(); + try { + final URI parsed = URI.create(uri); + if (parsed.isAbsolute()) { + host = parsed.getHost(); + } + } catch (IllegalArgumentException e) { + host = "unparseable-uri"; + } + } + if (!LOOPBACK_HOST.equals(host)) { + blocked.add(String.valueOf(host)); + throw new HttpException("blocked non-loopback host: " + host); + } + } + + /** Tries to delete every file version and bucket this check created, on the error path too; a failure is collected, and {@link #run} turns it into a FAIL. */ private static void cleanup() { if (client == null) { return; @@ -148,7 +215,7 @@ private static void cleanup() { client.deleteAllFilesInBucket(bucket.getBucketId()); client.deleteBucket(bucket.getBucketId()); } catch (B2Exception | RuntimeException e) { - note("cleanup of " + bucket.getBucketName() + " failed: " + describe(e)); + cleanupErrors.add(bucket.getBucketName() + ": " + describe(e)); } } try { diff --git a/.sdkharness/tests/run-conformance b/.sdkharness/tests/run-conformance index b9bc1ccba..38fdb2093 100755 --- a/.sdkharness/tests/run-conformance +++ b/.sdkharness/tests/run-conformance @@ -53,10 +53,11 @@ log="$work/check.log" if java -cp "$work/classes:$SDKHARNESS_JAVA_CLASSPATH" "$class" >"$log" 2>&1; then rc=0; else rc=$?; fi prefix="CONFORMANCE $SLUG $SCENARIO @" -grep -Fv "$prefix" "$log" || true -count="$(grep -Fc "$prefix" "$log" 2>/dev/null || true)" +# A result line starts at column 1 (index(), not a regex: the dots in a scenario id are literal). +awk -v p="$prefix" 'index($0, p) != 1' "$log" || true +count="$(awk -v p="$prefix" 'index($0, p) == 1 { n++ } END { print n + 0 }' "$log")" [ "$count" = 1 ] || fail "contract: expected one result line, got $count (exit $rc)" -verdict="$(grep -F "$prefix" "$log")"; verdict="${verdict#*: }" +verdict="$(awk -v p="$prefix" 'index($0, p) == 1' "$log")"; verdict="${verdict#*: }" case "$verdict" in PASS) [ "$rc" = 0 ] || fail "contract: PASS exited $rc"; result PASS - ;; 'FAIL ('*')') reason="${verdict#FAIL (}"; result FAIL "${reason%)}"; exit 1 ;;