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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# Changelog
## [Unreleased]
### Fixed
* `B2AccountAuthorizationCache` no longer caches (and returns on later calls) an authorization for a different account after the account-id mismatch check fails.
* `B2WebApiHttpClientImpl.getContent` now honors the connect, socket and pool timeouts configured on the `HttpClientFactory`. Added a default `HttpClientFactory.getRequestConfig()` that `HttpClientFactoryImpl` overrides.
* `B2LargeFileStorer` computes part indexes and start bytes from the sorted part list, so progress events are correct when parts are supplied out of order.
* `B2CancellableContentSource`'s input stream now closes the wrapped stream even when `close()` reports the cancellation.
* `B2EventNotificationRule.isSuspended()` and `compareTo()` no longer throw `NullPointerException` when suspension fields were not provided.
* `B2InputStreamExcerpt.skip()` no longer advances the position twice.
* JSON handlers for `LocalDate`, `ConcurrentMap` and `EnumSet` throw `B2JsonException` for invalid dates and null elements instead of unchecked exceptions.
### Tests
* Added and strengthened unit tests for the above, for the finish-large-file request payload, content writer sha1 handling, and several previously vacuous tests.

## [6.5.0] - 2025-12-15
* Add `readBucketLifecycleRules` and `writeBucketLifecycleRules` capabilities.
## [6.4.0] - 2025-05-14
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ B2AccountAuthorization get() throws B2Exception {
localAuthorization = authorization;
if (localAuthorization == null) {
localAuthorization = accountAuthorizer.authorize(webifier);
authorization = localAuthorization;

final String accountIdFromAuthorization = localAuthorization.getAccountId();
if (accountId == null) {
accountId = accountIdFromAuthorization;
Expand All @@ -67,6 +65,7 @@ B2AccountAuthorization get() throws B2Exception {
"but previously authorized as accountId " + accountId);
}
}
authorization = localAuthorization;
}
return localAuthorization;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ public int available() throws IOException {

@Override
public void close() throws IOException {
throwIfCancelled();

source.close();
try {
throwIfCancelled();
} finally {
// always release the wrapped stream, even if we report the cancellation.
source.close();
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ public class B2LargeFileStorer {
this.largeFileId = storeLargeFileRequest.getFileId();
this.serverSideEncryptionOrNull = storeLargeFileRequest.getServerSideEncryption();
this.partStorers = validateAndSortPartStorers(new ArrayList<>(partStorers), partNumberGapsAllowed);
this.startingBytePositions = computeStartingBytePositions(partStorers);
this.indexesByPartNumber = computeIndexesByPartNumbers(partStorers);
// use the sorted list: positions and indexes are looked up by part number, in part order.
this.startingBytePositions = computeStartingBytePositions(this.partStorers);
this.indexesByPartNumber = computeIndexesByPartNumbers(this.partStorers);

this.accountAuthCache = accountAuthCache;
this.uploadPartUrlCache = new B2UploadPartUrlCache(webifier, accountAuthCache, largeFileId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class B2EventNotificationRule implements Comparable<B2EventNotificationRu
.thenComparing(rule -> rule.getTargetConfiguration().toString())
.thenComparing(B2EventNotificationRule::isEnabled)
.thenComparing(B2EventNotificationRule::isSuspended)
.thenComparing(B2EventNotificationRule::getSuspensionReason);
.thenComparing(B2EventNotificationRule::getSuspensionReason, Comparator.nullsFirst(Comparator.naturalOrder()));

/**
* A name for identifying the rule. Names must be unique within a bucket.
Expand Down Expand Up @@ -118,7 +118,7 @@ public boolean isEnabled() {
}

public boolean isSuspended() {
return isSuspended;
return Boolean.TRUE.equals(isSuspended);
}

public String getSuspensionReason() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ public ConcurrentMap deserialize(B2JsonReader in, B2JsonOptions options) throws
Object key = keyHandler.deserialize(in, options);
in.skipObjectColon();
Object value = B2JsonUtil.deserializeMaybeNull(valueHandler, in, options);
if (value == null) {
// ConcurrentHashMap cannot hold null values.
throw new B2JsonException("null value is not allowed in a ConcurrentMap");
}
result.put(key, value);
} while (in.objectHasMoreFields());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,13 @@ public EnumSet deserialize(B2JsonReader in, B2JsonOptions options) throws B2Json
EnumSet result = EnumSet.noneOf(handledClass);
if (in.startArrayAndCheckForContents()) {
do {
final Object item = B2JsonUtil.deserializeMaybeNull(itemHandler, in, options);
if (item == null) {
// EnumSet cannot hold nulls.
throw new B2JsonException("null item is not allowed in an EnumSet");
}
//noinspection unchecked
result.add(B2JsonUtil.deserializeMaybeNull(itemHandler, in, options));
result.add(item);
} while (in.arrayHasMoreValues());
}
in.finishArray();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import java.io.IOException;
import java.lang.reflect.Type;
import java.time.DateTimeException;
import java.time.LocalDate;

import static com.backblaze.b2.util.B2DateTimeUtil.MAX_DAY;
Expand Down Expand Up @@ -38,18 +39,19 @@ public LocalDate deserialize(B2JsonReader in, B2JsonOptions options) throws B2Js
B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) &&
B2StringUtil.decimalNumberInRange(str, 5, 2, MIN_MONTH, MAX_MONTH) &&
B2StringUtil.decimalNumberInRange(str, 8, 2, MIN_DAY, MAX_DAY)) {
return LocalDate.parse(str);
return parseIso(str);
}

// Try Backblaze style: "20150315"
if (len == 8 &&
B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) &&
B2StringUtil.decimalNumberInRange(str, 4, 2, MIN_MONTH, MAX_MONTH) &&
B2StringUtil.decimalNumberInRange(str, 6, 2, MIN_DAY, MAX_DAY)) {
return LocalDate.of(
return dateOf(
B2StringUtil.decimalSubstringToInt(str, 0, 4),
B2StringUtil.decimalSubstringToInt(str, 4, 6),
B2StringUtil.decimalSubstringToInt(str, 6, 8)
B2StringUtil.decimalSubstringToInt(str, 6, 8),
str
);
}
throw new B2JsonException("not a valid date: " + str);
Expand All @@ -63,23 +65,41 @@ public LocalDate deserializeUrlParam(String str) throws B2JsonException {
B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) &&
B2StringUtil.decimalNumberInRange(str, 5, 2, MIN_MONTH, MAX_MONTH) &&
B2StringUtil.decimalNumberInRange(str, 8, 2, MIN_DAY, MAX_DAY)) {
return LocalDate.parse(str);
return parseIso(str);
}

// Try Backblaze style: "20150315"
if (len == 8 &&
B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) &&
B2StringUtil.decimalNumberInRange(str, 4, 2, MIN_MONTH, MAX_MONTH) &&
B2StringUtil.decimalNumberInRange(str, 6, 2, MIN_DAY, MAX_DAY)) {
return LocalDate.of(
return dateOf(
B2StringUtil.decimalSubstringToInt(str, 0, 4),
B2StringUtil.decimalSubstringToInt(str, 4, 6),
B2StringUtil.decimalSubstringToInt(str, 6, 8)
B2StringUtil.decimalSubstringToInt(str, 6, 8),
str
);
}
throw new B2JsonException("not a valid date: " + str);
}

// The range checks accept days 29 to 31 for every month, so the calendar can still reject the date.
private static LocalDate parseIso(String str) throws B2JsonException {
try {
return LocalDate.parse(str);
} catch (DateTimeException e) {
throw new B2JsonException("not a valid date: " + str);
}
}

private static LocalDate dateOf(int year, int month, int day, String str) throws B2JsonException {
try {
return LocalDate.of(year, month, day);
} catch (DateTimeException e) {
throw new B2JsonException("not a valid date: " + str);
}
}

public LocalDate defaultValueForOptional() {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ public long skip(long n) throws IOException {
if (atEnd()) {
return 0;
}
final long actuallySkipped = super.skip(Math.min(n,getRemaining()));
iPosition += actuallySkipped;
return actuallySkipped;
// super.skip() reads through our own read(), which already advances iPosition.
final long positionBefore = iPosition;
super.skip(Math.min(n,getRemaining()));
return iPosition - positionBefore;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,28 @@ public void testMismatchAccountId() throws B2Exception {

}

@Test
public void testMismatchAccountIdIsNotCached() throws B2Exception {
doReturn(makeAuthWithAccountId(accountId, 1)).when(authorizer).authorize(webifier);
cache.get();
cache.clear();
final B2AccountAuthorization auth2 = makeAuthWithAccountId("anotherAccountId", 2);
doReturn(auth2).when(authorizer).authorize(webifier);
try {
cache.get();
org.junit.Assert.fail("expected mismatch");
} catch (B2LocalException expected) {
// ok
}
// second call must not hand back the wrong-account authorization
try {
final B2AccountAuthorization got = cache.get();
org.junit.Assert.fail("wrong-account authorization was cached and returned: " + got.getAccountId());
} catch (B2LocalException expected) {
// ok
}
}

@Test
public void testGetAccountIdCallsGet() throws B2Exception {
final B2AccountAuthorization auth1 = makeAuthWithAccountId(accountId, 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ public void testInputStreamCloseCancelled() throws B2Exception, IOException {
inputStream.close();
}

@Test
public void testInputStreamCloseCancelledStillClosesWrappedStream() throws B2Exception, IOException {
final InputStream inputStream = cancellableContentSource.createInputStream();
cancellationToken.cancel();

try {
inputStream.close();
} catch (IOException e) {
// closing a cancelled stream is allowed to report the cancellation.
}

// whatever close() reports, the wrapped stream (a file handle or socket) must be released.
verify(mockInputStream).close();
}

@Test
public void testInputStreamResetCancelled() throws B2Exception, IOException {
final InputStream inputStream = cancellableContentSource.createInputStream();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,29 @@ public void testStartByte_partNumberGapsAllowed() throws IOException {
assertEquals(B2UploadProgress.UNKNOWN_PART_START_BYTE, largeFileStorer.getStartByteOrUnknown(4));
}

@Test
public void testStartByte_outOfOrderPartStorers() throws IOException {
// same parts as testStartByte, but handed over out of order. positions depend on part numbers, not list order.
final List<B2PartStorer> partStorers = Arrays.asList(
new B2AlreadyStoredPartStorer(part2),
new B2CopyingPartStorer(3, fileId(3)),
new B2UploadingPartStorer(1, createContentSourceWithSize(100)));

final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer(
B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(),
partStorers,
authCache,
webifier,
retryer,
retryPolicySupplier,
singleThreadedExecutor,
false);

assertEquals(0, largeFileStorer.getStartByteOrUnknown(1));
assertEquals(100, largeFileStorer.getStartByteOrUnknown(2));
assertEquals(100 + PART_SIZE_FOR_FIRST_TWO, largeFileStorer.getStartByteOrUnknown(3));
}

@Test(expected = IllegalArgumentException.class)
public void testStartByte_partNumberTooLow() throws IOException {
createLargeFileStorerForStartByteTests(false).getStartByteOrUnknown(0);
Expand Down Expand Up @@ -480,6 +503,14 @@ private void checkCannotUpload() throws B2Exception {
B2UploadState.SUCCEEDED));
}

private void drainSingleThreadedExecutor() {
try {
singleThreadedExecutor.submit(() -> { }).get(30, java.util.concurrent.TimeUnit.SECONDS);
} catch (Exception e) {
Assert.fail("executor did not drain: " + e);
}
}

@Test
public void testStoreFileAsyncCancelled() throws B2Exception, IOException {
final List<B2PartStorer> partStorers = new ArrayList<>();
Expand Down Expand Up @@ -521,6 +552,11 @@ public void testStoreFileAsyncCancelled() throws B2Exception, IOException {
future.get().get();
Assert.fail("we should have gotten a CancellationException");
} catch (CancellationException e) {
// The future is cancelled as soon as the first upload starts, so get() returns while the
// executor may still be running later tasks. Wait for everything already queued on the
// single-threaded executor to drain, or the verify below runs before part 2 could ever
// reach the webifier and cannot fail.
drainSingleThreadedExecutor();
// upload part should only be called once
verify(webifier, times(1)).uploadPart(anyObject(), anyObject());
verify(webifier, times(0)).finishLargeFile(anyObject(), anyObject());
Expand Down Expand Up @@ -570,6 +606,11 @@ public void testStorePartsAsyncCancelled() throws B2Exception, IOException {
future.get().get();
Assert.fail("we should have gotten a CancellationException");
} catch (CancellationException e) {
// The future is cancelled as soon as the first upload starts, so get() returns while the
// executor may still be running later tasks. Wait for everything already queued on the
// single-threaded executor to drain, or the verify below runs before part 2 could ever
// reach the webifier and cannot fail.
drainSingleThreadedExecutor();
// upload part should only be called once
verify(webifier, times(1)).uploadPart(anyObject(), anyObject());
} catch (Exception e) {
Expand Down Expand Up @@ -645,6 +686,24 @@ public void testCreateRangedContentSource() throws IOException {
final B2ContentSource secondRangedContentSource = B2LargeFileStorer.createRangedContentSource(
firstRangedContentSource, 10, 50);
assertTrue(secondRangedContentSource instanceof B2PartOfContentSource);
}

@Test
public void testCreateRangedContentSource_usesStartAndLength() throws IOException {
final B2ContentSource contentSource = new TestContentSource(100, 1000);

// the source can make its own range: start is relative to the source, length is the part length.
final B2ContentSource firstRangedContentSource = B2LargeFileStorer.createRangedContentSource(
contentSource, 5, 200);
assertEquals(new TestContentSource(105, 200, false), firstRangedContentSource);

// that one cannot, so a B2PartOfContentSource wraps it with the requested start and length.
final B2ContentSource secondRangedContentSource = B2LargeFileStorer.createRangedContentSource(
firstRangedContentSource, 10, 50);
assertTrue(secondRangedContentSource instanceof B2PartOfContentSource);
assertEquals(50, secondRangedContentSource.getContentLength());
assertTrue(secondRangedContentSource.toString(),
secondRangedContentSource.toString().startsWith("B2PartOfContentSource{start=10, length=50, source="));

}

Expand Down
Loading
Loading