Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ data:
connectorSubtype: file
connectorType: source
definitionId: 31e3242f-dee7-4cdc-a4b8-8e06c5458517
dockerImageTag: 1.9.0
dockerImageTag: 1.9.1
dockerRepository: airbyte/source-sftp-bulk
documentationUrl: https://docs.airbyte.com/integrations/sources/sftp-bulk
githubIssueLabel: source-sftp-bulk
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = [ "poetry-core>=1.0.0",]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
version = "1.9.0"
version = "1.9.1"
name = "source-sftp-bulk"
description = "Source implementation for SFTP Bulk."
authors = [ "Airbyte <contact@airbyte.io>",]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@
from airbyte_cdk import AirbyteTracedException, FailureType


_KEY_CLASSES = [
paramiko.RSAKey,
paramiko.Ed25519Key,
paramiko.ECDSAKey,
paramiko.DSSKey,
]


def _parse_private_key(private_key: str) -> paramiko.PKey:
"""Try each paramiko key class to auto-detect the private key type."""
for key_class in _KEY_CLASSES:
try:
return key_class.from_private_key(io.StringIO(private_key))
except (paramiko.SSHException, ValueError):
continue
raise AirbyteTracedException(
failure_type=FailureType.config_error,
message="Private key format is not recognized. Supported types: RSA, Ed25519, ECDSA, DSS.",
internal_message="Failed to parse private key with any supported paramiko key class: RSAKey, Ed25519Key, ECDSAKey, DSSKey.",
)


# set default timeout to 300 seconds
REQUEST_TIMEOUT = 300

Expand Down Expand Up @@ -41,7 +63,7 @@ def __init__(
self.password = password
self.port = int(port) or 22

self.key = paramiko.RSAKey.from_private_key(io.StringIO(private_key)) if private_key else None
self.key = _parse_private_key(private_key) if private_key else None
self.timeout = float(timeout) if timeout else REQUEST_TIMEOUT

self._connect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import paramiko
import pytest
from paramiko.ssh_exception import SSHException
from source_sftp_bulk.client import SFTPClient
from source_sftp_bulk.client import SFTPClient, _parse_private_key

from airbyte_cdk import AirbyteTracedException


def test_client_exception():
Expand All @@ -28,3 +30,86 @@ def test_client_connection():
port=123,
)
assert SFTPClient


@pytest.mark.parametrize(
"failing_classes,succeeding_class",
[
pytest.param([], paramiko.RSAKey, id="rsa_key"),
pytest.param([paramiko.RSAKey], paramiko.Ed25519Key, id="ed25519_key"),
pytest.param([paramiko.RSAKey, paramiko.Ed25519Key], paramiko.ECDSAKey, id="ecdsa_key"),
pytest.param([paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey], paramiko.DSSKey, id="dss_key"),
],
)
def test_parse_private_key_auto_detects_key_type(failing_classes, succeeding_class):
"""_parse_private_key tries key classes in order and returns the first that succeeds."""
mock_key = MagicMock(spec=succeeding_class)
patches = [patch.object(cls, "from_private_key", side_effect=paramiko.SSHException("wrong type")) for cls in failing_classes]
patches.append(patch.object(succeeding_class, "from_private_key", return_value=mock_key))
with patches[0] if len(patches) == 1 else patches[0]:
for p in patches[1:]:
p.start()
try:
result = _parse_private_key("fake-key-content")
assert result is mock_key
finally:
for p in patches[1:]:
p.stop()


def test_parse_private_key_unrecognized_format_raises_config_error():
"""All key classes fail => AirbyteTracedException with config_error."""
with (
patch.object(paramiko.RSAKey, "from_private_key", side_effect=paramiko.SSHException("fail")),
patch.object(paramiko.Ed25519Key, "from_private_key", side_effect=paramiko.SSHException("fail")),
patch.object(paramiko.ECDSAKey, "from_private_key", side_effect=paramiko.SSHException("fail")),
patch.object(paramiko.DSSKey, "from_private_key", side_effect=paramiko.SSHException("fail")),
):
with pytest.raises(AirbyteTracedException, match="Failed to parse private key"):
_parse_private_key("invalid-key-content")


def test_parse_private_key_catches_value_error():
"""ValueError from a key class is caught and the next class is tried."""
mock_key = MagicMock(spec=paramiko.Ed25519Key)
with (
patch.object(paramiko.RSAKey, "from_private_key", side_effect=ValueError("bad data")),
patch.object(paramiko.Ed25519Key, "from_private_key", return_value=mock_key),
):
result = _parse_private_key("fake-key-content")
assert result is mock_key


def test_client_with_private_key_calls_parse():
"""SFTPClient passes private_key through _parse_private_key."""
mock_key = MagicMock(spec=paramiko.RSAKey)
with (
patch("source_sftp_bulk.client._parse_private_key", return_value=mock_key) as mock_parse,
patch.object(paramiko, "Transport", MagicMock()),
patch.object(paramiko, "SFTPClient", MagicMock()),
):
client = SFTPClient(
host="localhost",
username="username",
private_key="fake-key",
port=123,
)
mock_parse.assert_called_once_with("fake-key")
assert client.key is mock_key


def test_client_without_private_key_skips_parse():
"""SFTPClient with no private_key does not call _parse_private_key."""
with (
patch("source_sftp_bulk.client._parse_private_key") as mock_parse,
patch.object(paramiko, "Transport", MagicMock()),
patch.object(paramiko, "SFTPClient", MagicMock()),
):
client = SFTPClient(
host="localhost",
username="username",
password="password",
port=123,
)
mock_parse.assert_not_called()
assert client.key is None
5 changes: 3 additions & 2 deletions docs/integrations/sources/sftp-bulk.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,10 @@ For more information about delivery methods and their limitations, see the [Deli

| Version | Date | Pull Request | Subject |
|:--------|:-----------|:---------------------------------------------------------|:------------------------------------------------------------|
| 1.9.1 | 2026-04-01 | [75967](https://github.com/airbytehq/airbyte/pull/75967) | Support non-RSA private key types (Ed25519, ECDSA, DSS) for SSH authentication |
| 1.9.0 | 2026-01-08 | [71225](https://github.com/airbytehq/airbyte/pull/71225) | Promoting release candidate 1.9.0-rc.2 to a main version. |
| 1.9.0-rc.2 | 2026-01-05 | [71038](https://github.com/airbytehq/airbyte/pull/71038) | Fix directory could match globs logic |
| 1.9.0-rc.1 | 2025-12-09 | [69167](https://github.com/airbytehq/airbyte/pull/69167) | Fix OOM on check, update airbyte-cdk version |
| 1.9.0-rc.2 | 2026-01-05 | [71038](https://github.com/airbytehq/airbyte/pull/71038) | Fix directory could match globs logic |
| 1.9.0-rc.1 | 2025-12-09 | [69167](https://github.com/airbytehq/airbyte/pull/69167) | Fix OOM on check, update airbyte-cdk version |
| 1.8.9 | 2025-11-24 | | Increase `maxSecondsBetweenMessages` to 3 hours |
| 1.8.8 | 2025-11-10 | [69257](https://github.com/airbytehq/airbyte/pull/69257) | Update error message when file exceeds size limit |
| 1.8.6 | 2025-10-14 | [67923](https://github.com/airbytehq/airbyte/pull/67923) | Update dependencies |
Expand Down
Loading