Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Changelog
- v4.3.1-SNAPSHOT
- Fixed GCS-backed internal stage PUT failing with opaque `invalid_gcs_credentials` in SPCS pods on GCP: the GCS SDK's Application Default Credentials (ADC) probe was reaching out to `metadata.google.internal` which is unreachable inside SPCS; explicit credentials are now always set when a `GCS_ACCESS_TOKEN` is present, suppressing the ADC probe entirely. Also fixed `GCSAccessStrategyAwsSdk` rejecting custom GCS endpoints that lack an `https://` scheme prefix (e.g. bare `storage.me-central2.rep.googleapis.com`), mirroring the existing handling in `GCSDefaultAccessStrategy`. The catch-all in `setupGCSClient` now chains and logs the original exception instead of swallowing it (snowflakedb/snowflake-jdbc#2664).
- Fixed Azure PUT memory leak where each PUT instantiated a fresh `BlobServiceClient` whose underlying reactor-netty stack the SDK exposes no API to release; the Azure SDK `HttpClient` and its `ConnectionProvider` are now shared across all PUTs in a session and disposed at session close, mirroring the existing S3 pattern (snowflakedb/snowflake-jdbc#2658).
- Fixed `SFResultJsonParser2Failed: invalid escaped unicode character` when a chunked JSON result contained UTF-16 surrogate-pair `\u` escapes (e.g. emoji) and the read buffer happened to split exactly 9 bytes after `\u`; the off-by-one boundary guard in `ResultJsonParserV2` now reserves the full 10 bytes a surrogate pair requires (snowflakedb/snowflake-jdbc#2660).
- Fixed (by removing) stale `com.amazonaws.util.Base16/Base64` bytecode references from the shaded JAR by excluding dead `SFBinary` and `SFBinaryFormat` classes from the bundled `snowflake-common` artifact. Security scanners shold no longer flag `snowflake-jdbc-thin` as containing AWS SDK v1 references. (snowflakedb/snowflake-jdbc#2665).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ class GCSAccessStrategyAwsSdk implements GCSAccessStrategy {
Optional<String> oEndpoint = stage.gcsCustomEndpoint();
String endpoint = "https://storage.googleapis.com";
if (oEndpoint.isPresent()) {
endpoint = oEndpoint.get();
String custom = oEndpoint.get();
Comment thread
sfc-gh-dheyman marked this conversation as resolved.
endpoint = custom.startsWith("https://") ? custom : "https://" + custom;
}
if (stage.getStorageAccount() != null && endpoint.startsWith(stage.getStorageAccount())) {
endpoint = endpoint.replaceFirst(stage.getStorageAccount() + ".", "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,18 @@ class GCSDefaultAccessStrategy implements GCSAccessStrategy {
StorageOptions.Builder builder = StorageOptions.newBuilder();
overrideHost(stage, builder);

if (SnowflakeGCSClient.areDisabledGcsDefaultCredentials(session)) {
Comment thread
sfc-gh-dheyman marked this conversation as resolved.
Comment thread
sfc-gh-dheyman marked this conversation as resolved.
logger.debug(
"Adding explicit credentials to avoid default credential lookup by the GCS client");
builder.setCredentials(GoogleCredentials.create(new AccessToken(accessToken, null)));
}
// Always set explicit credentials to prevent Application Default Credentials (ADC) lookup.
// Without this, StorageOptions.build().getService() probes metadata.google.internal which
// is unreachable in environments like SPCS, causing an opaque "invalid_gcs_credentials"
// error.
// Actual API authentication is handled by the Authorization header below, not this credential
// object — it exists solely to suppress the ADC probe.
builder.setCredentials(GoogleCredentials.create(new AccessToken(accessToken, null)));

if (transportFactory != null) {
builder.setTransportOptions(
HttpTransportOptions.newBuilder().setHttpTransportFactory(transportFactory).build());
}

// Using GoogleCredential with access token will cause IllegalStateException when the token
// is expired and trying to refresh, which cause error cannot be caught. Instead, set a
// header so we can caught the error code.
this.gcsClient =
builder
.setHeaderProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1262,8 +1262,12 @@ private void setupGCSClient(
encryptionKeySize);
}
}
} catch (SnowflakeSQLException ex) {
Comment thread
sfc-gh-dheyman marked this conversation as resolved.
logger.debug("Failed to initialize GCS client", ex);
throw ex;
} catch (Exception ex) {
throw new IllegalArgumentException("invalid_gcs_credentials");
logger.debug("Failed to initialize GCS client", ex);
throw new IllegalArgumentException("invalid_gcs_credentials", ex);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package net.snowflake.client.internal.jdbc.cloud.storage;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import net.snowflake.client.api.exception.SnowflakeSQLException;
import net.snowflake.client.internal.core.SFSession;
import net.snowflake.common.core.RemoteStoreFileEncryptionMaterial;
import org.junit.jupiter.api.Test;

class SnowflakeGCSClientTest {

private StageInfo createGCSStageInfo(Map<String, String> credentials) {
return createGCSStageInfo(credentials, "US-CENTRAL1", null, null);
}

private StageInfo createGCSStageInfo(
Map<String, String> credentials, String region, String endPoint, String storageAccount) {
return StageInfo.createStageInfo(
"GCS", "test-bucket/path", credentials, region, endPoint, storageAccount, true);
}

private SFSession createSession(boolean disableGcsDefaultCredentials) {
SFSession session = new SFSession();
session.setDisableGcsDefaultCredentials(disableGcsDefaultCredentials);
return session;
}

/**
* Core regression test for the SPCS ADC probe fix. When disableGcsDefaultCredentials is false and
* a GCS_ACCESS_TOKEN is present, the client must still initialize successfully. Before the fix,
* this path skipped setting explicit credentials on StorageOptions.Builder, causing the GCS SDK
* to probe metadata.google.internal via ADC — which fails in SPCS and on any non-GCP host.
*/
@Test
void testClientCreationSucceedsWithDisabledDefaultCredentialsFalse() {
Map<String, String> credentials = new HashMap<>();
credentials.put("GCS_ACCESS_TOKEN", "test-token");
StageInfo stage = createGCSStageInfo(credentials);

SFSession session = createSession(false);

assertDoesNotThrow(
() -> SnowflakeGCSClient.createSnowflakeGCSClient(stage, null, session),
"GCS client should initialize without ADC probe even when"
+ " disableGcsDefaultCredentials is false");
}

/**
* Verifies that setupGCSClient chains the original exception as the cause of the
* IllegalArgumentException instead of silently swallowing it. Before the fix, the catch block
* threw new IllegalArgumentException("invalid_gcs_credentials") with no cause, making root-cause
* diagnosis impossible.
*/
@Test
void testSetupGCSClientChainsExceptionCause() {
Map<String, String> credentials = new HashMap<>();
credentials.put("GCS_ACCESS_TOKEN", "test-token");
StageInfo stage = createGCSStageInfo(credentials);
SFSession session = createSession(true);

RemoteStoreFileEncryptionMaterial encMat =
new RemoteStoreFileEncryptionMaterial("not-valid-base64!@#$", "queryId", 123L);

IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> SnowflakeGCSClient.createSnowflakeGCSClient(stage, encMat, session));
assertEquals("invalid_gcs_credentials", ex.getMessage());
assertNotNull(ex.getCause(), "Original exception should be chained as cause");
}

/**
* Verifies that SnowflakeSQLException from the encryption key size validation propagates directly
* instead of being wrapped in IllegalArgumentException. Before the fix, the broad catch
* (Exception ex) caught SnowflakeSQLException and re-threw it as IllegalArgumentException, losing
* the specific error type and message.
*/
@Test
void testSnowflakeSQLExceptionPropagatesDirectly() {
Map<String, String> credentials = new HashMap<>();
credentials.put("GCS_ACCESS_TOKEN", "test-token");
StageInfo stage = createGCSStageInfo(credentials);
SFSession session = createSession(true);

// 10-byte key (80 bits) — not a valid key size (must be 128, 192, or 256)
byte[] invalidSizeKey = new byte[10];
String encodedKey = Base64.getEncoder().encodeToString(invalidSizeKey);
RemoteStoreFileEncryptionMaterial encMat =
new RemoteStoreFileEncryptionMaterial(encodedKey, "queryId", 123L);

assertThrows(
SnowflakeSQLException.class,
() -> SnowflakeGCSClient.createSnowflakeGCSClient(stage, encMat, session),
"SnowflakeSQLException should propagate directly, not wrapped in IllegalArgumentException");
}

/**
* Verifies that GCSAccessStrategyAwsSdk prepends https:// to custom endpoints that lack a scheme.
* Before the fix, a bare hostname like "storage.me-central2.rep.googleapis.com" was passed to the
* AWS SDK's URI parser as-is, which rejected it with NullPointerException because the URI scheme
* was null.
*/
@Test
void testAwsSdkStrategyPrependsSchemeToBarHostnameEndpoint() {
Map<String, String> credentials = new HashMap<>();
credentials.put("GCS_ACCESS_TOKEN", "test-token");

StageInfo stage =
createGCSStageInfo(
credentials, "ME-CENTRAL2", "storage.me-central2.rep.googleapis.com", null);
stage.setUseVirtualUrl(true);

SFSession session = createSession(true);

assertDoesNotThrow(
() -> SnowflakeGCSClient.createSnowflakeGCSClient(stage, null, session),
"Bare hostname endpoint should get https:// prepended automatically");
}

/**
* Verifies that GCSAccessStrategyAwsSdk does not double-prefix endpoints that already have a
* scheme.
*/
@Test
void testAwsSdkStrategyPreservesEndpointWithScheme() {
Map<String, String> credentials = new HashMap<>();
credentials.put("GCS_ACCESS_TOKEN", "test-token");

StageInfo stage =
createGCSStageInfo(
credentials, "ME-CENTRAL2", "https://storage.me-central2.rep.googleapis.com", null);
stage.setUseVirtualUrl(true);

SFSession session = createSession(true);

assertDoesNotThrow(
() -> SnowflakeGCSClient.createSnowflakeGCSClient(stage, null, session),
"Endpoint with https:// prefix should be passed through as-is");
}
}
Loading