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