field.onChange(event.target.value)}
actions={
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.test.ts
new file mode 100644
index 00000000000..07daecd3a4c
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.test.ts
@@ -0,0 +1,196 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ getSnowflakeValidationIssues,
+ isPrivateKey,
+ MAX_PRIVATE_KEY_LENGTH,
+ readPrivateKeyFile,
+ SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE,
+} from './Snowflake.utils'
+
+describe('isPrivateKey', () => {
+ it.each([
+ ['a plain PKCS#8 key', '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', true],
+ [
+ 'a PKCS#1 RSA key',
+ '-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----',
+ true,
+ ],
+ [
+ 'an encrypted PKCS#8 key',
+ '-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----',
+ true,
+ ],
+ [
+ 'a legacy encrypted RSA key with headers',
+ '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,ABC\n\nabc\n-----END RSA PRIVATE KEY-----',
+ true,
+ ],
+ [
+ 'a key using CRLF line endings',
+ '-----BEGIN PRIVATE KEY-----\r\nabc\r\n-----END PRIVATE KEY-----',
+ true,
+ ],
+ ['a public key', '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----', false],
+ [
+ 'mismatched BEGIN/END markers',
+ '-----BEGIN PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----',
+ false,
+ ],
+ [
+ 'a body with no newline before the END marker',
+ '-----BEGIN PRIVATE KEY-----\nabc-----END PRIVATE KEY-----',
+ false,
+ ],
+ [
+ 'trailing content after the END marker',
+ '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\nextra',
+ false,
+ ],
+ [
+ 'a body that is only whitespace',
+ '-----BEGIN PRIVATE KEY-----\n \n-----END PRIVATE KEY-----',
+ false,
+ ],
+ ['plain text', 'not a private key', false],
+ ])('returns %s as %s', (_, contents, expected) => {
+ expect(isPrivateKey(contents)).toBe(expected)
+ })
+})
+
+describe('getSnowflakeValidationIssues', () => {
+ const VALID_DATA = {
+ snowflakeAccountId: 'MYORG-MYACCOUNT',
+ snowflakeUser: 'PIPELINES_USER',
+ snowflakePrivateKey: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----',
+ snowflakeDatabase: 'PIPELINES_DB',
+ snowflakeSchema: 'REPLICATED',
+ }
+
+ it('flags a pasted private key that is not a real key', () => {
+ const issues = getSnowflakeValidationIssues({
+ ...VALID_DATA,
+ snowflakePrivateKey: 'asdasdasda',
+ })
+
+ expect(issues).toContainEqual({
+ path: 'snowflakePrivateKey',
+ message: SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE,
+ })
+ })
+
+ it('does not flag a valid private key', () => {
+ expect(getSnowflakeValidationIssues(VALID_DATA)).toEqual([])
+ })
+
+ it('skips the format check when validatePrivateKeyFormat is false', () => {
+ const issues = getSnowflakeValidationIssues(
+ { ...VALID_DATA, snowflakePrivateKey: 'asdasdasda' },
+ { validatePrivateKeyFormat: false }
+ )
+
+ expect(issues).toEqual([])
+ })
+
+ it('does not flag an empty private key left blank while editing', () => {
+ const issues = getSnowflakeValidationIssues(
+ { ...VALID_DATA, snowflakePrivateKey: '' },
+ { secretsOptional: true }
+ )
+
+ expect(issues).toEqual([])
+ })
+})
+
+describe('readPrivateKeyFile', () => {
+ const PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nprivate-key\n-----END PRIVATE KEY-----'
+
+ const createForm = () => ({
+ setError: vi.fn(),
+ setValue: vi.fn(),
+ clearErrors: vi.fn(),
+ })
+
+ const isCurrent = () => true
+
+ it('sets a size error when the file is larger than the limit', async () => {
+ const file = new File([PRIVATE_KEY], 'rsa_key.p8')
+ Object.defineProperty(file, 'size', { value: MAX_PRIVATE_KEY_LENGTH + 1 })
+ const form = createForm()
+
+ await readPrivateKeyFile(file, form, isCurrent)
+
+ expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
+ message: 'Private key must be 10,000 characters or fewer.',
+ })
+ expect(form.setValue).not.toHaveBeenCalled()
+ })
+
+ it('sets a size error when the file contents exceed the limit after reading', async () => {
+ const file = new File([PRIVATE_KEY], 'rsa_key.p8')
+ const longContents = 'a'.repeat(MAX_PRIVATE_KEY_LENGTH + 1)
+ Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(longContents) })
+ const form = createForm()
+
+ await readPrivateKeyFile(file, form, isCurrent)
+
+ expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
+ message: 'Private key must be 10,000 characters or fewer.',
+ })
+ expect(form.setValue).not.toHaveBeenCalled()
+ })
+
+ it('sets a format error for a file that is not a private key', async () => {
+ const publicKey = '-----BEGIN PUBLIC KEY-----\npublic-key\n-----END PUBLIC KEY-----'
+ const file = new File([publicKey], 'rsa_key.pub')
+ Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(publicKey) })
+ const form = createForm()
+
+ await readPrivateKeyFile(file, form, isCurrent)
+
+ expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
+ message: 'Select a P8 or PEM private key.',
+ })
+ expect(form.setValue).not.toHaveBeenCalled()
+ })
+
+ it('applies the contents of a valid private key file', async () => {
+ const file = new File([PRIVATE_KEY], 'rsa_key.p8')
+ Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) })
+ const form = createForm()
+
+ await readPrivateKeyFile(file, form, isCurrent)
+
+ expect(form.setValue).toHaveBeenCalledWith('snowflakePrivateKey', PRIVATE_KEY, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ })
+ expect(form.clearErrors).toHaveBeenCalledWith('snowflakePrivateKey')
+ expect(form.setError).not.toHaveBeenCalled()
+ })
+
+ it('sets a read error when the file cannot be read', async () => {
+ const file = new File([PRIVATE_KEY], 'rsa_key.p8')
+ Object.defineProperty(file, 'text', { value: vi.fn().mockRejectedValue(new Error('boom')) })
+ const form = createForm()
+
+ await readPrivateKeyFile(file, form, isCurrent)
+
+ expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
+ message: 'Could not read the selected private key.',
+ })
+ })
+
+ it('discards the result when the request is no longer current', async () => {
+ const file = new File([PRIVATE_KEY], 'rsa_key.p8')
+ Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) })
+ const form = createForm()
+
+ await readPrivateKeyFile(file, form, () => false)
+
+ expect(form.setValue).not.toHaveBeenCalled()
+ expect(form.setError).not.toHaveBeenCalled()
+ expect(form.clearErrors).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts
index 8a2a5f4d56c..ff04542c9a0 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts
@@ -1,3 +1,5 @@
+import type { UseFormReturn } from 'react-hook-form'
+
import { type DestinationPanelSchemaType } from '../DestinationForm.schema'
export type SnowflakeApiConfig = {
@@ -30,12 +32,86 @@ const SNOWFLAKE_REQUIRED_FIELDS: { path: SnowflakeFieldPath; message: string }[]
{ path: 'snowflakeSchema', message: 'Schema is required.' },
]
+export const SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE =
+ 'Enter a valid RSA private key in PKCS #8 or PKCS #1 PEM format.'
+
+export const isPrivateKey = (contents: string) => {
+ const match = contents.match(
+ /^\s*-----BEGIN ((?:ENCRYPTED |RSA )?PRIVATE KEY)-----\r?\n([\s\S]*?)\r?\n-----END \1-----\s*$/
+ )
+
+ return match !== null && match[2].trim().length > 0
+}
+
export const getSnowflakeValidationIssues = (
data: Pick,
- options: { secretsOptional?: boolean } = {}
-): SnowflakeValidationIssue[] =>
- SNOWFLAKE_REQUIRED_FIELDS.filter(({ path }) => {
- if (options.secretsOptional && path === 'snowflakePrivateKey') return false
+ options: { secretsOptional?: boolean; validatePrivateKeyFormat?: boolean } = {}
+): SnowflakeValidationIssue[] => {
+ const { secretsOptional = false, validatePrivateKeyFormat = true } = options
+
+ const issues: SnowflakeValidationIssue[] = SNOWFLAKE_REQUIRED_FIELDS.filter(({ path }) => {
+ if (secretsOptional && path === 'snowflakePrivateKey') return false
return !data[path]?.trim().length
})
+
+ const privateKey = data.snowflakePrivateKey?.trim() ?? ''
+
+ // Format is checked on submit only. Live onChange validation would fail on every
+ // keystroke while the user is still pasting or typing a key.
+ if (privateKey && validatePrivateKeyFormat && !isPrivateKey(privateKey)) {
+ issues.push({ path: 'snowflakePrivateKey', message: SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE })
+ }
+
+ return issues
+}
+
+export const MAX_PRIVATE_KEY_LENGTH = 10000
+
+type PrivateKeyForm = Pick<
+ UseFormReturn,
+ 'setError' | 'setValue' | 'clearErrors'
+>
+
+export const readPrivateKeyFile = async (
+ file: File,
+ form: PrivateKeyForm,
+ isCurrentRequest: () => boolean
+) => {
+ if (file.size > MAX_PRIVATE_KEY_LENGTH) {
+ if (isCurrentRequest()) {
+ form.setError('snowflakePrivateKey', {
+ message: 'Private key must be 10,000 characters or fewer.',
+ })
+ }
+ return
+ }
+
+ try {
+ const contents = await file.text()
+ if (!isCurrentRequest()) return
+
+ if (contents.length > MAX_PRIVATE_KEY_LENGTH) {
+ form.setError('snowflakePrivateKey', {
+ message: 'Private key must be 10,000 characters or fewer.',
+ })
+ return
+ }
+
+ if (!isPrivateKey(contents)) {
+ form.setError('snowflakePrivateKey', { message: 'Select a P8 or PEM private key.' })
+ return
+ }
+
+ form.setValue('snowflakePrivateKey', contents, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ })
+ form.clearErrors('snowflakePrivateKey')
+ } catch {
+ if (isCurrentRequest()) {
+ form.setError('snowflakePrivateKey', { message: 'Could not read the selected private key.' })
+ }
+ }
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
index 608e955ce02..29ac41fd49f 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
@@ -45,7 +45,10 @@ import { PipelineCostDialog } from './PipelineCostDialog'
import { PipelineRegionField } from './PipelineRegionField'
import { PublicationSelection } from './PublicationSelection'
import { SnowflakeFields } from './Snowflake/Fields'
-import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils'
+import {
+ getSnowflakeValidationIssues,
+ SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE,
+} from './Snowflake/Snowflake.utils'
import { TableCopySelection } from './TableCopySelection'
import { useDestinationForm } from './useDestinationForm'
import { ValidationFailuresSection } from './ValidationFailuresSection'
@@ -223,11 +226,12 @@ export const DestinationForm = ({
}
)
} else if (selectedType === 'Snowflake') {
- getSnowflakeValidationIssues(data, { secretsOptional: editMode }).forEach(
- ({ path, message }) => {
- addRequiredFieldError(path, message)
- }
- )
+ getSnowflakeValidationIssues(data, {
+ secretsOptional: editMode,
+ validatePrivateKeyFormat: false,
+ }).forEach(({ path, message }) => {
+ addRequiredFieldError(path, message)
+ })
} else if (selectedType === 'ClickHouse') {
getClickHouseValidationIssues(data).forEach(({ path, message }) => {
addRequiredFieldError(path, message)
@@ -345,6 +349,16 @@ export const DestinationForm = ({
}
}
+ if (selectedType === 'Snowflake') {
+ const privateKeyIssue = getSnowflakeValidationIssues(data, {
+ secretsOptional: editMode,
+ }).find((issue) => issue.message === SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE)
+ if (privateKeyIssue) {
+ form.setError(privateKeyIssue.path, { message: privateKeyIssue.message })
+ return
+ }
+ }
+
// Pipeline prerequisite validation models a new pipeline and cannot
// account for resources already owned by an existing pipeline. Edits keep
// the established direct-update flow after pruning stale table ids.