diff --git a/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF new file mode 100644 index 000000000..58630c02e --- /dev/null +++ b/META-INF/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 + diff --git a/META-INF/native-image/io.ballerina.stdlib/crypto-native/native-image.properties b/META-INF/native-image/io.ballerina.stdlib/crypto-native/native-image.properties new file mode 100644 index 000000000..45f2f156e --- /dev/null +++ b/META-INF/native-image/io.ballerina.stdlib/crypto-native/native-image.properties @@ -0,0 +1,19 @@ +# Copyright (c) 2022, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +Args = --initialize-at-run-time=org.bouncycastle.jcajce.provider.drbg.DRBG\$Default \ + --initialize-at-run-time=org.bouncycastle.jcajce.provider.drbg.DRBG\$NonceAndIV \ + --features=io.ballerina.stdlib.crypto.svm.BouncyCastleFeature diff --git a/META-INF/native-image/io.ballerina.stdlib/crypto-native/reflect-config.json b/META-INF/native-image/io.ballerina.stdlib/crypto-native/reflect-config.json new file mode 100644 index 000000000..ddcafcaa7 --- /dev/null +++ b/META-INF/native-image/io.ballerina.stdlib/crypto-native/reflect-config.json @@ -0,0 +1,506 @@ +[ + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.COMPOSITE$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.DH$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.DSTU4145$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.Dilithium$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.EC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.ECGOST$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.EXTERNAL$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.EdEC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.ElGamal$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.Falcon$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.GM$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.GOST$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.IES$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.LMS$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.NTRU$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.SPHINCSPlus$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.X509$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.ec.KeyFactorySpi$ECDSA", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$MD5", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA1", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA256", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA384", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA512", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.asymmetric.rsa.KeyFactorySpi", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Blake2b$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Blake2s$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Blake3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.DSTU7564$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.GOST3411$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Haraka$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Keccak$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.MD2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.MD4$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.MD5$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD160$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD256$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.RIPEMD320$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SHA1$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SHA224$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SHA256$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SHA3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SHA384$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SHA512$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.SM3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Skein$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Tiger$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.digest.Whirlpool$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.drbg.DRBG$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.keystore.BC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.keystore.BCFKS$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.keystore.PKCS12$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.AES$ECB", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.AES$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.ARC4$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.ARIA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Blowfish$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.CAST5$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.CAST6$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Camellia$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.ChaCha$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.DES$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.DESede$CBC", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.DESede$ECB", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.DESede$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.DESede$PBEWithSHAAndDES2Key", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.DSTU7624$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.GOST28147$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.GOST3412_2015$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Grain128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Grainv1$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.HC128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.HC256$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.IDEA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Noekeon$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.OpenSSLPBKDF$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.OpenSSLPBKDF$PBKDF", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF1$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF2$PBKDF2withUTF8", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.PBEPKCS12$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Poly1305$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.RC2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.RC5$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.RC6$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Rijndael$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.SCRYPT$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.SEED$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.SM4$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Salsa20$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Serpent$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Shacal2$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.SipHash$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.SipHash128$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Skipjack$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.TEA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.TLSKDF$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Threefish$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Twofish$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.VMPC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.VMPCKSA3$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.XSalsa20$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.XTEA$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.jcajce.provider.symmetric.Zuc$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.BIKE$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.CMCE$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.Dilithium$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.Falcon$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.Frodo$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.HQC$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.Kyber$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.LMS$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.NH$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.NTRU$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.NTRUPrime$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.Picnic$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.Rainbow$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.SABER$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.SPHINCS$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.SPHINCSPlus$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.XMSS$Mappings", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.dilithium.DilithiumKeyFactorySpi$Base3", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.dilithium.SignatureSpi$Base3", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.bouncycastle.pqc.jcajce.provider.kyber.KyberKeyFactorySpi$Kyber768", + "methods":[{"name":"","parameterTypes":[] }] + } +] diff --git a/README.md b/README.md index 0cbce4eec..f2f908299 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ generic FTP operations; `get`, `delete`, `put`, `append`, `mkdir`, `rmdir`, `isD `list`. The client also provides typed data operations for reading and writing files as text, JSON, XML, CSV, and binary data, with streaming support for handling large files efficiently. An FTP client is defined using the `protocol` and `host` parameters and optionally, the `port` and -`auth`. Authentication configuration can be configured using the `auth` parameter for Basic Auth and -private key. +`auth`. The protocol can be `FTP` (unsecured), `FTPS` (FTP over SSL/TLS), or `SFTP` (SSH File Transfer Protocol). +Authentication configuration can be configured using the `auth` parameter for Basic Auth, private key (for SFTP), or secure socket (for FTPS). An authentication-related configuration can be given to the FTP client with the `auth` configuration. @@ -353,11 +353,86 @@ service on remoteServer { The FTP listener automatically routes files to the appropriate content handler based on file extension: `.txt` → `onFileText()`, `.json` → `onFileJson()`, `.xml` → `onFileXml()`, `.csv` → `onFileCsv()`, and other extensions → `onFile()` (fallback handler). You can override the default routing using the `@ftp:FunctionConfig` annotation to specify a custom file name pattern for each handler method. +### Secure access with FTPS + +FTPS (FTP over SSL/TLS) is a secure protocol that extends FTP with SSL/TLS encryption. Unlike SFTP which uses SSH, FTPS uses SSL/TLS certificates for secure communication. + +The protocol selection is explicit - you must specify `protocol: ftp:FTPS` to use FTPS. The `secureSocket` configuration is used for SSL/TLS certificate configuration (keystore and truststore). + +FTPS supports two connection modes: +- **IMPLICIT**: SSL/TLS connection is established immediately upon connection (typically uses port 990) +- **EXPLICIT** (default): Starts as regular FTP, then upgrades to SSL/TLS using AUTH TLS command (typically uses port 21) + +You can specify the mode using the `mode` field in `secureSocket` configuration. If not specified, it defaults to `EXPLICIT`. + +#### FTPS client configuration + +```ballerina +ftp:ClientConfiguration ftpsConfig = { + protocol: ftp:FTPS, + host: "", + port: , + auth: { + credentials: { + username: "", + password: "" + }, + secureSocket: { + key: { + path: "", + password: "" + }, + cert: { + path: "", + password: "" + }, + mode: ftp:EXPLICIT // or ftp:IMPLICIT for implicit FTPS + } + } +}; + +// Create the FTPS client. +ftp:Client|ftp:Error ftpsClient = new(ftpsConfig); +``` + +#### FTPS listener configuration + +```ballerina +listener ftp:Listener remoteServer = check new({ + protocol: ftp:FTPS, + host: "", + port: , + path: "", + pollingInterval: , + fileNamePattern: "", + auth: { + credentials: { + username: "", + password: "" + }, + secureSocket: { + key: { + path: "", + password: "" + }, + cert: { + path: "", + password: "" + }, + mode: ftp:EXPLICIT // or ftp:IMPLICIT for implicit FTPS + } + } +}); +``` + ### Secure access with SFTP -SFTP is a secure protocol alternative to the FTP, which runs on top of the SSH protocol. +SFTP (SSH File Transfer Protocol) is a secure protocol that runs on top of the SSH protocol. There are several ways to authenticate an SFTP server. One is using the username and the password. Another way is using the client's private key. The Ballerina SFTP client and the listener support only those authentication standards. + +**Important:** The protocol selection is explicit - you must specify `protocol: ftp:SFTP` to use SFTP. The `privateKey` configuration is only valid for SFTP protocol. For FTPS, use `secureSocket` configuration instead. + An authentication-related configuration can be given to the SFTP client/listener with the `auth` configuration. Password-based authentication is defined with the `credentials` configuration while the private key based authentication is defined with the `privateKey` configuration. @@ -453,7 +528,7 @@ Execute the commands below to build from source. 7. Publish the generated artifacts to the local Ballerina central repository: ``` ./gradlew clean build -PpublishToLocalCentral=true - ``` + ```` 8. Publish the generated artifacts to the Ballerina central repository: ``` diff --git a/ballerina/Ballerina.toml b/ballerina/Ballerina.toml index d69c2349f..a216ad7f7 100644 --- a/ballerina/Ballerina.toml +++ b/ballerina/Ballerina.toml @@ -51,8 +51,8 @@ path = "../native/build/libs/ftp-native-2.16.0-SNAPSHOT.jar" [[platform.java21.dependency]] groupId = "io.ballerina.lib" artifactId = "data.jsondata-native" -version = "1.1.2" -path = "./lib/data.jsondata-native-1.1.2.jar" +version = "1.1.3" +path = "./lib/data.jsondata-native-1.1.3.jar" [[platform.java21.dependency]] groupId = "io.ballerina.lib" diff --git a/ballerina/Dependencies.toml b/ballerina/Dependencies.toml index ca814f4cf..76be136c1 100644 --- a/ballerina/Dependencies.toml +++ b/ballerina/Dependencies.toml @@ -6,12 +6,12 @@ [ballerina] dependencies-toml-version = "2" distribution-version = "2201.12.0" +distribution-version = "2201.13.1" [[package]] org = "ballerina" name = "crypto" version = "2.10.0" -scope = "testOnly" dependencies = [ {org = "ballerina", name = "jballerina.java"}, {org = "ballerina", name = "time"} @@ -101,7 +101,6 @@ modules = [ org = "ballerina" name = "lang.__internal" version = "0.0.0" -scope = "testOnly" dependencies = [ {org = "ballerina", name = "jballerina.java"}, {org = "ballerina", name = "lang.object"} @@ -126,6 +125,16 @@ dependencies = [ {org = "ballerina", name = "jballerina.java"} ] +[[package]] +org = "ballerina" +name = "lang.int" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"}, + {org = "ballerina", name = "lang.object"} +] + [[package]] org = "ballerina" name = "lang.object" @@ -176,7 +185,7 @@ dependencies = [ [[package]] org = "ballerina" name = "log" -version = "2.12.0" +version = "2.13.0" dependencies = [ {org = "ballerina", name = "io"}, {org = "ballerina", name = "jballerina.java"}, @@ -198,10 +207,11 @@ dependencies = [ [[package]] org = "ballerina" name = "task" -version = "2.11.0" +version = "2.10.0" dependencies = [ {org = "ballerina", name = "jballerina.java"}, - {org = "ballerina", name = "time"} + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "uuid"} ] modules = [ {org = "ballerina", packageName = "task", moduleName = "task"} @@ -224,7 +234,7 @@ modules = [ [[package]] org = "ballerina" name = "time" -version = "2.8.0" +version = "2.7.0" dependencies = [ {org = "ballerina", name = "jballerina.java"} ] @@ -232,3 +242,14 @@ modules = [ {org = "ballerina", packageName = "time", moduleName = "time"} ] +[[package]] +org = "ballerina" +name = "uuid" +version = "1.10.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "time"} +] + diff --git a/ballerina/commons.bal b/ballerina/commons.bal index 2f9de4ac1..0502bf56d 100644 --- a/ballerina/commons.bal +++ b/ballerina/commons.bal @@ -15,17 +15,41 @@ // under the License. import ballerina/io; +import ballerina/crypto; # Protocol to use for FTP server connections. -# Determines whether to use basic FTP (unsecure) or SFTP (secure over SSH). +# Determines whether to use basic FTP, FTPS, or SFTP. # FTP - Unsecure File Transfer Protocol +# FTPS - Secure File Transfer Protocol (FTP over SSL/TLS) # SFTP - File Transfer Protocol over SSH public enum Protocol { FTP = "ftp", + FTPS = "ftps", SFTP = "sftp" } -# Private key configuration for SSH-based authentication. +# FTPS connection mode. +# IMPLICIT - SSL/TLS connection is established immediately upon connection (typically port 990) +# EXPLICIT - Starts as regular FTP, then upgrades to SSL/TLS using AUTH TLS command (typically port 21) +public enum FtpsMode { + IMPLICIT, + EXPLICIT +} + +# FTPS data channel protection level. +# Controls whether the data channel (file transfers) is encrypted. +# CLEAR - Data channel is not encrypted (PROT C). Not recommended for security. +# PRIVATE - Data channel is encrypted (PROT P). Recommended for secure transfers. +# SAFE - Data channel has integrity protection only (PROT S). Rarely used. +# CONFIDENTIAL - Data channel is encrypted (PROT E). Similar to PRIVATE. +public enum FtpsDataChannelProtection { + CLEAR, + PRIVATE, + SAFE, + CONFIDENTIAL +} + +# Private key configuration for SSH-based authentication (used with SFTP). # # + path - Path to the private key file # + password - Optional password for the private key @@ -34,7 +58,23 @@ public type PrivateKey record {| string password?; |}; -# Basic authentication credentials for connecting to FTP servers using username and password. +# Secure socket configuration for FTPS (FTP over SSL/TLS). +# Used for configuring SSL/TLS certificates and keystores for FTPS connections. +# +# + key - Keystore configuration for client authentication +# + cert - Certificate configuration for server certificate validation +# + mode - FTPS connection mode (IMPLICIT or EXPLICIT). Defaults to EXPLICIT if not specified. +# + dataChannelProtection - Data channel protection level (CLEAR, PRIVATE, SAFE, or CONFIDENTIAL). +# Controls encryption of the data channel used for file transfers. +# Defaults to PRIVATE (encrypted) for secure transfers. +public type SecureSocket record {| + crypto:KeyStore key?; + crypto:TrustStore cert?; + FtpsMode mode = EXPLICIT; + FtpsDataChannelProtection dataChannelProtection = PRIVATE; +|}; + +# Basic authentication credentials for connecting to FTP/FTPS servers using username and password. # # + username - Username for authentication # + password - Optional password for authentication @@ -46,11 +86,13 @@ public type Credentials record {| # Specifies authentication options for FTP server connections. # # + credentials - Username and password for basic authentication -# + privateKey - Private key and password for key-based authentication -# + preferredMethods - Preferred authentication methods +# + privateKey - Private key and password for SSH-based authentication (used with SFTP protocol) +# + secureSocket - Secure socket configuration for SSL/TLS (used with FTPS protocol) +# + preferredMethods - Preferred authentication methods (used with SFTP protocol) public type AuthConfiguration record {| Credentials credentials?; PrivateKey privateKey?; + SecureSocket secureSocket?; PreferredMethod[] preferredMethods = [PUBLICKEY, PASSWORD]; |}; diff --git a/ballerina/tests/secure_ftps_advanced_test.bal b/ballerina/tests/secure_ftps_advanced_test.bal new file mode 100644 index 000000000..7dcfd44fa --- /dev/null +++ b/ballerina/tests/secure_ftps_advanced_test.bal @@ -0,0 +1,102 @@ +// Copyright (c) 2025 WSO2 Inc. (http://www.wso2.org) All Rights Reserved. +// +// WSO2 Inc. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/lang.runtime as runtime; +import ballerina/test; + +// Reusing the Explicit Config from your existing tests +ClientConfiguration ftpsAdvClientConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: {path: "tests/resources/keystore.jks", password: "changeit"}, + cert: {path: "tests/resources/keystore.jks", password: "changeit"}, + mode: EXPLICIT + } + } +}; + +isolated boolean ftpsAgeEventReceived = false; + +@test:Config { groups: ["ftpsAdvanced"] } +function testFtpsFileAgeFilter() returns error? { + string watchDir = "/ftps-listener"; + string ageFileName = "ftps-age-filter.txt"; + string ageFilePath = watchDir + "/" + ageFileName; + + Client ftpsClient = check new (ftpsAdvClientConfig); + + // Cleanup + check removeIfExists(ftpsClient, ageFilePath); + + resetFtpsAgeState(); + + Service ageService = service object { + remote function onFileChange(WatchEvent & readonly event) { + foreach FileInfo file in event.addedFiles { + if file.pathDecoded.endsWith(ageFileName) { + lock { ftpsAgeEventReceived = true; } + } + } + } + }; + + // Initialize Listener with FTPS and Age Filter + Listener ageListener = check new ({ + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: ftpsAdvClientConfig.auth, + path: watchDir, + pollingInterval: 2, + fileAgeFilter: { maxAge: 300 }, // Hits RemoteFileSystemConsumer.passesAgeFilter + fileNamePattern: ".*\\.txt" // Use standard wildcard pattern, filter in service + }); + + check ageListener.attach(ageService); + check ageListener.'start(); + runtime:registerListener(ageListener); + + // Trigger + check ftpsClient->putText(ageFilePath, "data"); + + // Wait loop + int waitCount = 0; + while waitCount < 15 { + boolean seen; + lock { seen = ftpsAgeEventReceived; } + if seen { break; } + runtime:sleep(1); + waitCount += 1; + } + + check ageListener.gracefulStop(); + runtime:deregisterListener(ageListener); + + boolean result; + lock { result = ftpsAgeEventReceived; } + test:assertTrue(result, "FTPS Listener failed to respect Age Filter (File not detected)"); + + // Cleanup + check removeIfExists(ftpsClient, ageFilePath); +} + +function resetFtpsAgeState() { + lock { ftpsAgeEventReceived = false; } +} \ No newline at end of file diff --git a/ballerina/tests/secure_ftps_client_endpoint_test.bal b/ballerina/tests/secure_ftps_client_endpoint_test.bal new file mode 100644 index 000000000..9685f64fe --- /dev/null +++ b/ballerina/tests/secure_ftps_client_endpoint_test.bal @@ -0,0 +1,514 @@ +// Copyright (c) 2025 WSO2 Inc. (http://www.wso2.org) All Rights Reserved. +// +// WSO2 Inc. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/io; +import ballerina/test; + +// Create the config to access mock FTPS server in EXPLICIT mode +ClientConfiguration ftpsExplicitConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT, + dataChannelProtection: PRIVATE + } + } +}; + +// Create the config to access mock FTPS server in IMPLICIT mode +ClientConfiguration ftpsImplicitConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 990, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: IMPLICIT, + dataChannelProtection: PRIVATE + } + } +}; + +// Create the config with CLEAR data channel protection for testing +ClientConfiguration ftpsClearDataChannelConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT, + dataChannelProtection: CLEAR + } + } +}; + +// Config to test default port logic. +// We set port 21, and the backend should detect IMPLICIT mode and swap it to 990. +ClientConfiguration ftpsImplicitDefaultPortConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: {path: "tests/resources/keystore.jks", password: "changeit"}, + cert: {path: "tests/resources/keystore.jks", password: "changeit"}, + mode: IMPLICIT + } + } +}; + +Client? ftpsExplicitClientEp = (); +Client? ftpsImplicitClientEp = (); +Client? ftpsClearDataChannelClientEp = (); + +// Root path for client isolation +const string FTPS_CLIENT_ROOT = "/ftps-client"; + +@test:BeforeSuite +function initFtpsTestEnvironment() returns error? { + io:println("Initializing FTPS test clients"); + ftpsExplicitClientEp = check new (ftpsExplicitConfig); + ftpsImplicitClientEp = check new (ftpsImplicitConfig); + ftpsClearDataChannelClientEp = check new (ftpsClearDataChannelConfig); + + // Clean the sandbox + check cleanFtpsTarget(); +} + +@test:AfterSuite +function cleanupFtpsTestEnvironment() returns error? { + io:println("Cleaning up FTPS test files..."); + check cleanFtpsTarget(); +} + +function cleanFtpsTarget() returns error? { + if ftpsExplicitClientEp is () { + return; + } + // Helper to clean up specific files used in tests if they exist + string[] files = ["file2.txt", "tempFtpsFile1.txt", "tempFtpsFile2.txt", "tempFtpsPrivate.txt", + "tempFtpsClear.txt", "tempFtpsFile3.txt", "tempFtpsFile4.txt", "tempFtpsFile5.txt", "tempFtpsFile6.txt"]; + foreach string f in files { + var deleteResult = trap (ftpsExplicitClientEp)->delete(FTPS_CLIENT_ROOT + "/" + f); + // Ignore errors for files that don't exist + if deleteResult is error { + // Silently ignore - file may not exist + } + } +} + + +@test:Config {} +public function testFtpsExplicitGetFileContent() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/file2.txt"; + + // Setup: Put file first + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + check (ftpsExplicitClientEp)->put(filePath, bStream); + + stream|Error str = (ftpsExplicitClientEp)->get(filePath); + if str is stream { + test:assertTrue(check matchStreamContent(str, "Put content"), + msg = "Found unexpected content from FTPS EXPLICIT `get` operation"); + check str.close(); + } else { + test:assertFail("Found unexpected response type" + str.message()); + } +} + +@test:Config {} +public function testFtpsImplicitGetFileContent() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/file2.txt"; + + // Setup: Put file first using implicit client + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + check (ftpsImplicitClientEp)->put(filePath, bStream); + + stream|Error str = (ftpsImplicitClientEp)->get(filePath); + if str is stream { + test:assertTrue(check matchStreamContent(str, "Put content"), + msg = "Found unexpected content from FTPS IMPLICIT `get` operation"); + check str.close(); + } else { + test:assertFail("Found unexpected response type" + str.message()); + } +} + +@test:Config {} +public function testFtpsExplicitPutFileContent() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/tempFtpsFile1.txt"; + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + + Error? response = (ftpsExplicitClientEp)->put(filePath, bStream); + if response is Error { + test:assertFail(msg = "Error in FTPS EXPLICIT `put`: " + response.message()); + } + + stream|Error str = (ftpsExplicitClientEp)->get(filePath); + if str is stream { + test:assertTrue(check matchStreamContent(str, "Put content")); + check str.close(); + } else { + test:assertFail(msg = "Found unexpected response type" + str.message()); + } +} + +@test:Config {} +public function testFtpsImplicitPutFileContent() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/tempFtpsFile2.txt"; + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + + Error? response = (ftpsImplicitClientEp)->put(filePath, bStream); + if response is Error { + test:assertFail(msg = "Error in FTPS IMPLICIT `put`: " + response.message()); + } + + stream|Error str = (ftpsImplicitClientEp)->get(filePath); + if str is stream { + test:assertTrue(check matchStreamContent(str, "Put content")); + check str.close(); + } else { + test:assertFail(msg = "Found unexpected response type" + str.message()); + } + + check (ftpsImplicitClientEp)->delete(filePath); +} + +@test:Config {} +public function testFtpsImplicitDefaultsTo990() returns error? { + // Uses the config with Port 21 to test the swap logic + Client|Error ftpClient = new (ftpsImplicitDefaultPortConfig); + + if ftpClient is Error { + test:assertFail("Failed to connect using default IMPLICIT port logic: " + ftpClient.message()); + } else { + FileInfo[]|error result = ftpClient->list(FTPS_CLIENT_ROOT); + if result is error { + test:assertFail("Connection established but failed to list files: " + result.message()); + } else { + test:assertTrue(result.length() >= 0, "Connected and listed using default port logic (21 -> 990)"); + } + } +} + +@test:Config {} +public function testFtpsExplicitDeleteFileContent() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/tempFtpsFile1.txt"; + + // Ensure file exists first (robustness) + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + check (ftpsExplicitClientEp)->put(filePath, bStream); + + Error? response = (ftpsExplicitClientEp)->delete(filePath); + if response is Error { + test:assertFail(msg = "Error in FTPS EXPLICIT `delete`: " + response.message()); + } + + stream|Error str = (ftpsExplicitClientEp)->get(filePath); + if str is stream { + check str.close(); + test:assertFail(msg = "File was not deleted with FTPS EXPLICIT `delete` operation"); + } else { + // Assert specific error message if possible, or just that it failed + test:assertTrue(str.message().includes("not found"), + msg = "Expected 'not found' error, got: " + str.message()); + } +} + +@test:Config {} +public function testFtpsDataChannelProtectionPrivate() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/tempFtpsPrivate.txt"; + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + + check (ftpsExplicitClientEp)->put(filePath, bStream); + + stream|Error str = (ftpsExplicitClientEp)->get(filePath); + if str is stream { + test:assertTrue(check matchStreamContent(str, "Put content")); + check str.close(); + } else { + test:assertFail(msg = "Failed to get file with PRIVATE protection: " + str.message()); + } + check (ftpsExplicitClientEp)->delete(filePath); +} + +@test:Config {} +public function testFtpsDataChannelProtectionClear() returns error? { + string filePath = FTPS_CLIENT_ROOT + "/tempFtpsClear.txt"; + stream bStream = check io:fileReadBlocksAsStream(putFilePath, 5); + + check (ftpsClearDataChannelClientEp)->put(filePath, bStream); + + stream|Error str = (ftpsClearDataChannelClientEp)->get(filePath); + if str is stream { + test:assertTrue(check matchStreamContent(str, "Put content")); + check str.close(); + } else { + test:assertFail(msg = "Failed to get file with CLEAR protection: " + str.message()); + } + check (ftpsClearDataChannelClientEp)->delete(filePath); +} + +@test:Config {} +public function testFtpsConnectWithWrongProtocol() returns error? { + ClientConfiguration ftpsConfig = { + protocol: FTP, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + } + }; + + Client|Error ftpsClientEp = new (ftpsConfig); + if ftpsClientEp is Error { + test:assertTrue(ftpsClientEp.message().startsWith("Error while connecting to the FTP server with URL: ") || + ftpsClientEp.message().includes("secureSocket can only be used with FTPS protocol"), + msg = "Unexpected error during the FTP client initialization with a FTPS server. " + ftpsClientEp.message()); + } else { + test:assertFail(msg = "Found a non-error response while initializing FTP client with a FTPS server."); + } +} + +@test:Config {} +public function testFtpsConnectWithEmptySecureSocket() returns error? { + ClientConfiguration emptyFtpsConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"} + } + }; + + Client|Error emptyFtpsClientEp = new (emptyFtpsConfig); + if emptyFtpsClientEp is Error { + test:assertTrue(emptyFtpsClientEp.message().startsWith("Error while connecting to the FTP server with URL: "), + msg = "Unexpected error during the FTPS client initialization with no secureSocket configs. " + emptyFtpsClientEp.message()); + } else { + test:assertFail(msg = "Found a non-error response while initializing FTPS client with no secureSocket configs."); + } +} + +@test:Config {} +public function testFtpsConnectWithInvalidKeystorePath() returns error? { + ClientConfiguration ftpsConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/invalid_resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + } + }; + + Client|Error ftpsClientEp = new (ftpsConfig); + if ftpsClientEp is Error { + test:assertTrue(ftpsClientEp.message().startsWith("Error while connecting to the FTP server with URL: ") || + ftpsClientEp.message().includes("Failed to load KeyStore"), + msg = "Unexpected error during the FTPS client initialization with an invalid keystore path. " + ftpsClientEp.message()); + } else { + test:assertFail(msg = "Found a non-error response while initializing FTPS client with an invalid keystore path."); + } +} + +@test:Config {} +public function testFtpsConnectWithInvalidTruststorePath() returns error? { + ClientConfiguration ftpsConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/invalid_resources/truststore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + } + }; + + Client|Error ftpsClientEp = new (ftpsConfig); + if ftpsClientEp is Error { + test:assertTrue(ftpsClientEp.message().startsWith("Error while connecting to the FTP server with URL: ") || + ftpsClientEp.message().includes("Failed to load KeyStore"), + msg = "Unexpected error during the FTPS client initialization with an invalid truststore path. " + ftpsClientEp.message()); + } else { + test:assertFail(msg = "Found a non-error response while initializing FTPS client with an invalid truststore path."); + } +} + +@test:Config {} +public function testFtpsConnectWithWrongPort() returns error? { + ClientConfiguration ftpsConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21299, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + } + }; + + Client|Error ftpsClientEp = new (ftpsConfig); + if ftpsClientEp is Error { + test:assertTrue(ftpsClientEp.message().startsWith("Error while connecting to the FTP server with URL: "), + msg = "Unexpected error during the FTPS client initialization with an invalid port. " + ftpsClientEp.message()); + test:assertTrue(ftpsClientEp.message().length() > "Error while connecting to the FTP server with URL: ftps://wso2:***@127.0.0.1:21299".length(), + msg = "Error message should contain detailed root cause information"); + } else { + test:assertFail(msg = "Found a non-error response while initializing FTPS client with an invalid port."); + } +} + +@test:Config {} +public function testFtpsConnectWithInvalidHost() returns error? { + ClientConfiguration ftpsConfig = { + protocol: FTPS, + host: "nonexistent.invalid.host.example", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + } + }; + + Client|Error ftpsClientEp = new (ftpsConfig); + if ftpsClientEp is Error { + test:assertTrue(ftpsClientEp.message().startsWith("Error while connecting to the FTP server with URL: "), + msg = "Unexpected error during the FTPS client initialization with an invalid host. " + ftpsClientEp.message()); + test:assertTrue(ftpsClientEp.message().length() > "Error while connecting to the FTP server with URL: ".length(), + msg = "Error message should contain detailed root cause information"); + } else { + test:assertFail(msg = "Found a non-error response while initializing FTPS client with an invalid host."); + } +} + +@test:Config {} +public function testFtpsFileStreamReuse() returns error? { + string path1 = FTPS_CLIENT_ROOT + "/tempFtpsFile3.txt"; + string path2 = FTPS_CLIENT_ROOT + "/tempFtpsFile4.txt"; + + stream localFileStream = check io:fileReadBlocksAsStream(putFilePath, 5); + check (ftpsExplicitClientEp)->put(path1, localFileStream); + + stream remoteFileStream = check (ftpsExplicitClientEp)->get(path1); + check (ftpsExplicitClientEp)->put(path2, remoteFileStream); + + stream remoteFileStream2 = check (ftpsExplicitClientEp)->get(path2); + + test:assertTrue(check matchStreamContent(remoteFileStream2, "Put content")); + + check (ftpsExplicitClientEp)->delete(path1); + check (ftpsExplicitClientEp)->delete(path2); +} + +@test:Config {} +public function testFtpsLargeFileStreamReuse() returns error? { + string path1 = FTPS_CLIENT_ROOT + "/tempFtpsFile5.txt"; + string path2 = FTPS_CLIENT_ROOT + "/tempFtpsFile6.txt"; + + int i = 0; + string nonFittingContent = ""; + while i < 1000 { + nonFittingContent += "123456789"; + i += 1; + } + check (ftpsExplicitClientEp)->put(path1, nonFittingContent); + stream remoteFileStream = check (ftpsExplicitClientEp)->get(path1); + check (ftpsExplicitClientEp)->put(path2, remoteFileStream); + stream remoteFileStream2 = check (ftpsExplicitClientEp)->get(path2); + + test:assertTrue(check matchStreamContent(remoteFileStream2, nonFittingContent)); + check (ftpsExplicitClientEp)->delete(path1); + check (ftpsExplicitClientEp)->delete(path2); +} diff --git a/ballerina/tests/secure_ftps_config_test.bal b/ballerina/tests/secure_ftps_config_test.bal new file mode 100644 index 000000000..31aef397d --- /dev/null +++ b/ballerina/tests/secure_ftps_config_test.bal @@ -0,0 +1,83 @@ +// Copyright (c) 2025 WSO2 Inc. (http://www.wso2.org) All Rights Reserved. +// +// WSO2 Inc. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/test; + +// 1. Working config for Timeout/ASCII test (Uses PRIVATE) +ClientConfiguration ftpsDetailedConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + connectTimeout: 5.0, + ftpFileTransfer: ASCII, // Hits setFileType(ASCII) + socketConfig: { + ftpDataTimeout: 10.0, + ftpSocketTimeout: 5.0 + }, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: {path: "tests/resources/keystore.jks", password: "changeit"}, + cert: {path: "tests/resources/keystore.jks", password: "changeit"}, + mode: EXPLICIT, + dataChannelProtection: PRIVATE // Use supported protection + } + } +}; + +// 2. Config for "Unsupported Protection" test +ClientConfiguration ftpsUnsupportedProtectionConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: {path: "tests/resources/keystore.jks", password: "changeit"}, + cert: {path: "tests/resources/keystore.jks", password: "changeit"}, + mode: EXPLICIT, + dataChannelProtection: SAFE // Server will reject this + } + } +}; + +@test:Config { groups: ["ftpsConfig"] } +function testFtpsWithTimeoutsAndAscii() returns error? { + Client clientEp = check new (ftpsDetailedConfig); + string content = "Simple ASCII Content"; + string path = "/ftps-client/ascii_timeout_test.txt"; + + check clientEp->putText(path, content); + + string readContent = check clientEp->getText(path); + test:assertEquals(readContent, content); + + check clientEp->delete(path); +} + +// Negative Test: Verify client attempts to set 'S', even if server/VFS rejects it. +@test:Config { groups: ["ftpsConfig"] } +function testFtpsWithSafeProtection() { + Client|error clientEp = new (ftpsUnsupportedProtectionConfig); + if clientEp is error { + // FIX: Check for the actual VFS wrapper error message + // This confirms the Java code attempted to map 'SAFE' -> 'S' + test:assertTrue(clientEp.message().includes("Failed to setup secure data channel level \"S\""), + "Expected failure setting data channel level S, got: " + clientEp.message()); + } else { + test:assertFail("Client initialized successfully with unsupported 'SAFE' protection (Unexpected)"); + } +} \ No newline at end of file diff --git a/ballerina/tests/secure_ftps_listener_endpoint_test.bal b/ballerina/tests/secure_ftps_listener_endpoint_test.bal new file mode 100644 index 000000000..c81a373d8 --- /dev/null +++ b/ballerina/tests/secure_ftps_listener_endpoint_test.bal @@ -0,0 +1,467 @@ +// Copyright (c) 2025 WSO2 Inc. (http://www.wso2.org) All Rights Reserved. +// +// WSO2 Inc. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/lang.runtime as runtime; +import ballerina/log; +import ballerina/test; + +// --- Global State for Event Capture (Managed per test) --- +isolated boolean ftpsEventReceived = false; +isolated int ftpsFileCount = 0; + +function resetFtpsState() { + lock { + ftpsEventReceived = false; + } + lock { + ftpsFileCount = 0; + } +} + +// --- Reusable Configs --- + +// 1. Trigger Client Config (To create files that the listener watches) +ClientConfiguration triggerClientConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + credentials: { + username: "wso2", + password: "wso2123" + }, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT, + dataChannelProtection: PRIVATE + } + } +}; + +// 2. Trigger Client Config for Implicit Mode +ClientConfiguration triggerImplicitClientConfig = { + protocol: FTPS, + host: "127.0.0.1", + port: 990, + auth: { + credentials: { + username: "wso2", + password: "wso2123" + }, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: IMPLICIT, + dataChannelProtection: PRIVATE + } + } +}; + +// --- Positive Tests --- + +@test:Config {} +function testFtpsExplicitListener() returns error? { + // 1. Setup specific path for isolation + string watchPath = "/ftps-listener"; + string targetFile = watchPath + "/explicit_trigger.txt"; + + // 2. Initialize Helper Client + Client triggerClient = check new(triggerClientConfig); + check removeIfExists(triggerClient, targetFile); + resetFtpsState(); + + // 3. Define Service + Service ftpsService = service object { + remote function onFileChange(WatchEvent & readonly event) { + if event.addedFiles.length() == 0 { return; } + lock { + ftpsEventReceived = true; + } + lock { + ftpsFileCount = event.addedFiles.length(); + } + log:printInfo("Explicit Event: " + event.addedFiles.toString()); + } + }; + + // 4. Start Listener (Self-Contained) + Listener ftpsListener = check new ({ + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: triggerClientConfig.auth, + path: watchPath, // Watch ONLY this folder + pollingInterval: 2, + fileNamePattern: "explicit_trigger.txt" + }); + + check ftpsListener.attach(ftpsService); + check ftpsListener.'start(); + runtime:registerListener(ftpsListener); + + // 5. Trigger Event + check triggerClient->put(targetFile, "data"); + + // 6. Wait for Event + int waitCount = 0; + while waitCount < 20 { + boolean seen; + lock { seen = ftpsEventReceived; } + if seen { break; } + runtime:sleep(1); + waitCount += 1; + } + + // 7. Stop Listener + check ftpsListener.gracefulStop(); + runtime:deregisterListener(ftpsListener); + + // 8. Assertions + boolean eventSeen; + lock { eventSeen = ftpsEventReceived; } + test:assertTrue(eventSeen, "FTPS Explicit Listener failed to detect file."); + + // 9. Cleanup + check removeIfExists(triggerClient, targetFile); +} + +@test:Config {} +function testFtpsImplicitListener() returns error? { + string watchPath = "/ftps-listener"; + string targetFile = watchPath + "/implicit_trigger.txt"; + + Client triggerClient = check new(triggerImplicitClientConfig); + check removeIfExists(triggerClient, targetFile); + resetFtpsState(); + + Service ftpsService = service object { + remote function onFileChange(WatchEvent & readonly event) { + if event.addedFiles.length() == 0 { return; } + lock { + ftpsEventReceived = true; + } + log:printInfo("Implicit Event: " + event.addedFiles.toString()); + } + }; + + Listener ftpsListener = check new ({ + protocol: FTPS, + host: "127.0.0.1", + port: 990, + auth: triggerImplicitClientConfig.auth, + path: watchPath, + pollingInterval: 2, + fileNamePattern: "implicit_trigger.txt" + }); + + check ftpsListener.attach(ftpsService); + check ftpsListener.'start(); + runtime:registerListener(ftpsListener); + + check triggerClient->put(targetFile, "data"); + + int waitCount = 0; + while waitCount < 20 { + boolean seen; + lock { seen = ftpsEventReceived; } + if seen { break; } + runtime:sleep(1); + waitCount += 1; + } + + check ftpsListener.gracefulStop(); + runtime:deregisterListener(ftpsListener); + + boolean eventSeen; + lock { eventSeen = ftpsEventReceived; } + test:assertTrue(eventSeen, "FTPS Implicit Listener failed to detect file."); + + check removeIfExists(triggerClient, targetFile); +} + +// --- Negative Tests --- + +@test:Config {} +public function testFtpsConnectWithInvalidKeystore() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTPS, + host: "localhost", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/invalid.keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector.") || + ftpsServer.message().includes("Failed to load FTPS Server Keystore"), + msg = "Expected error for invalid keystore. Got: " + ftpsServer.message()); + } else { + test:assertFail("Non-error result when invalid keystore is used."); + } +} + +@test:Config {} +public function testFtpsConnectWithInvalidTruststore() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTPS, + host: "localhost", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/invalid.truststore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector.") || + ftpsServer.message().includes("Failed to load FTPS Server Truststore"), + msg = "Expected error for invalid truststore. Got: " + ftpsServer.message()); + } else { + test:assertFail("Non-error result when invalid truststore is used."); + } +} + +@test:Config {} +public function testFtpsConnectToFTPServerWithFTPProtocol() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTP, + host: "localhost", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"}, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector.") || + ftpsServer.message().includes("secureSocket can only be used with FTPS protocol"), + msg = "Expected error for wrong protocol"); + } else { + test:assertFail("Non-error result when connecting to FTPS server via FTP."); + } +} + +@test:Config {} +public function testFtpsListenerConnectWithEmptySecureSocket() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTPS, + host: "localhost", + port: 21214, + auth: { + credentials: {username: "wso2", password: "wso2123"} + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector."), + msg = "Expected error for missing secureSocket"); + } else { + test:assertFail("Non-error result when no secureSocket config is provided."); + } +} + +@test:Config {} +public function testFtpsConnectWithEmptyCredentials() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTPS, + host: "localhost", + port: 21214, + auth: { + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector."), + msg = "Expected error for missing credentials"); + } else { + test:assertFail("Non-error result when no credentials were provided."); + } +} + +@test:Config {} +public function testFtpsConnectWithEmptyKeystorePath() returns error? { + Listener|Error result = new ({ + protocol: FTPS, + host: "localhost", + port: 21214, + auth: { + credentials: { + username: "wso2", + password: "wso2123" + }, + secureSocket: { + key: { + path: "", + password: "" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if result is Error { + test:assertTrue(result.message().includes("Failed to load") || + result.message().startsWith("Failed to initialize File server connector."), + msg = "Expected error for empty keystore path"); + } else { + test:assertFail("Non-error result when empty keystore path is provided."); + } +} + +@test:Config {} +public function testFtpsServerConnectWithInvalidHostWithDetails() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTPS, + host: "nonexistent.invalid.host", + port: 21214, + auth: { + credentials: { + username: "wso2", + password: "wso2123" + }, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector.")); + test:assertTrue(ftpsServer.message().length() > "Failed to initialize File server connector.".length(), + msg = "Error message should contain detailed root cause information"); + } else { + test:assertFail("Non-error result when invalid host is used."); + } +} + +@test:Config {} +public function testFtpsServerConnectWithInvalidPortWithDetails() returns error? { + Listener|Error ftpsServer = new ({ + protocol: FTPS, + host: "127.0.0.1", + port: 21299, + auth: { + credentials: { + username: "wso2", + password: "wso2123" + }, + secureSocket: { + key: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + cert: { + path: "tests/resources/keystore.jks", + password: "changeit" + }, + mode: EXPLICIT + } + }, + path: "/ftps-listener", + pollingInterval: 2 + }); + + if ftpsServer is Error { + test:assertTrue(ftpsServer.message().startsWith("Failed to initialize File server connector.")); + test:assertTrue(ftpsServer.message().length() > "Failed to initialize File server connector.".length(), + msg = "Error message should contain detailed root cause information"); + } else { + test:assertFail("Non-error result when invalid port is used."); + } +} diff --git a/ballerina/tests/secure_ftps_validation_test.bal b/ballerina/tests/secure_ftps_validation_test.bal new file mode 100644 index 000000000..83423409a --- /dev/null +++ b/ballerina/tests/secure_ftps_validation_test.bal @@ -0,0 +1,64 @@ +import ballerina/test; + +@test:Config { groups: ["ftpsValidation"] } +function testListenerFtpsWithPrivateKey() { + // Hits validateServerAuthProtocolCombination: FTPS + PrivateKey + Listener|error resultListener = new ({ + protocol: FTPS, + host: "127.0.0.1", + port: 21214, + auth: { + privateKey: { path: "tests/resources/id_rsa" } + }, + path: "/ftps-listener" + }); + + if resultListener is error { + test:assertTrue(resultListener.message().includes("privateKey can only be used with SFTP"), + "Failed to catch invalid auth combination in Listener"); + } else { + test:assertFail("Listener init should have failed with FTPS + PrivateKey"); + } +} + +@test:Config { groups: ["ftpsValidation"] } +function testClientFtpsWithPrivateKey() { + // Hits FtpClient.validateAuthProtocolCombination + ClientConfiguration invalidConfig = { + protocol: FTPS, + host: "127.0.0.1", + auth: { + privateKey: { path: "tests/resources/id_rsa" } + } + }; + + Client|error clientEp = new (invalidConfig); + if clientEp is error { + test:assertTrue(clientEp.message().includes("privateKey can only be used with SFTP"), + "Failed to catch invalid auth combination in Client"); + } else { + test:assertFail("Client init should have failed with FTPS + PrivateKey"); + } +} + +@test:Config { groups: ["ftpsValidation"] } +function testClientSftpWithSecureSocket() { + // Hits FtpClient.validateAuthProtocolCombination reverse case + ClientConfiguration invalidConfig = { + protocol: SFTP, + host: "127.0.0.1", + auth: { + secureSocket: { + key: { path: "tests/resources/keystore.jks", password: "changeit" } + } + } + }; + + Client|error clientEp = new (invalidConfig); + if clientEp is error { + test:assertTrue(clientEp.message().includes("secureSocket can only be used with FTPS"), + "Failed to catch invalid auth combination (SFTP + SecureSocket)"); + } else { + test:assertFail("Client init should have failed with SFTP + SecureSocket"); + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index fa6774ad2..ce85cb4b7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -37,11 +37,11 @@ stdlibTimeVersion=2.7.0 # Level 2 stdlibCryptoVersion=2.9.2 -stdlibLogVersion=2.12.0 +stdlibLogVersion=2.13.0 # Level 3 -stdlibTaskVersion=2.11.0 -stdlibDataJsonDataVersion=1.1.2 +stdlibTaskVersion=2.10.0 +stdlibDataJsonDataVersion=1.1.3 stdlibDataXmlDataVersion=1.5.2 # Level 4 diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/client/FtpClient.java b/native/src/main/java/io/ballerina/stdlib/ftp/client/FtpClient.java index 3be24560f..f794e85de 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/client/FtpClient.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/client/FtpClient.java @@ -54,6 +54,7 @@ import java.io.InputStream; import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.util.Enumeration; import java.util.HashMap; import java.util.Iterator; @@ -92,10 +93,43 @@ private FtpClient() { } public static Object initClientEndpoint(BObject clientEndpoint, BMap config) { - String protocol = (config.getStringValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_PROTOCOL))) - .getValue(); + String protocol = extractProtocol(config); + configureClientEndpointBasic(clientEndpoint, config, protocol); + + Map ftpConfig = new HashMap<>(20); + Object authError = configureAuthentication(config, protocol, ftpConfig); + if (authError != null) { + return authError; + } + + applyDefaultFtpConfig(config, ftpConfig); + Object vfsError = extractVfsConfigurations(config, ftpConfig); + if (vfsError != null) { + return vfsError; + } + + return createAndStoreConnector(clientEndpoint, ftpConfig); + } + + /** + * Extracts the protocol from the configuration. + * + * @param config The client configuration map + * @return The protocol string + */ + private static String extractProtocol(BMap config) { + return (config.getStringValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_PROTOCOL))).getValue(); + } - // Keep databinding config for later + /** + * Configures basic client endpoint settings (host, port, auth, etc.). + * + * @param clientEndpoint The client endpoint object + * @param config The client configuration map + * @param protocol The protocol being used + */ + private static void configureClientEndpointBasic(BObject clientEndpoint, BMap config, + String protocol) { clientEndpoint.addNativeData(FtpConstants.ENDPOINT_CONFIG_LAX_DATABINDING, config.getBooleanValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_LAX_DATABINDING))); @@ -110,57 +144,351 @@ public static Object initClientEndpoint(BObject clientEndpoint, BMap ftpConfig = new HashMap<>(20); + } + + /** + * Configures authentication settings including validation and protocol-specific setup. + * + * @param config The client configuration map + * @param protocol The protocol being used + * @param ftpConfig The FTP configuration map to populate + * @return Error object if configuration fails, null otherwise + */ + private static Object configureAuthentication(BMap config, String protocol, + Map ftpConfig) { BMap auth = config.getMapValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_AUTH)); - if (auth != null) { - final BMap privateKey = auth.getMapValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_PRIVATE_KEY)); - if (privateKey != null) { - final BString privateKeyPath = privateKey.getStringValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_KEY_PATH)); - ftpConfig.put(FtpConstants.IDENTITY, privateKeyPath.getValue()); - final BString privateKeyPassword = privateKey.getStringValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_PASS_KEY)); - if (privateKeyPassword != null && !privateKeyPassword.getValue().isEmpty()) { - ftpConfig.put(FtpConstants.IDENTITY_PASS_PHRASE, privateKeyPassword.getValue()); - } + if (auth == null) { + return null; + } + + Object validationError = validateAuthProtocolCombination(auth, protocol); + if (validationError != null) { + return validationError; + } + + configurePrivateKey(auth, ftpConfig); + + if (auth.getMapValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)) != null + && protocol.equals(FtpConstants.SCHEME_FTPS)) { + Object ftpsError = configureFtpsSecureSocket( + auth.getMapValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)), + ftpConfig); + if (ftpsError != null) { + return ftpsError; } - ftpConfig.put(ENDPOINT_CONFIG_PREFERRED_METHODS, FtpUtil.getPreferredMethodsFromAuthConfig(auth)); } + + if (protocol.equals(FtpConstants.SCHEME_SFTP)) { + ftpConfig.put(ENDPOINT_CONFIG_PREFERRED_METHODS, + FtpUtil.getPreferredMethodsFromAuthConfig(auth)); + } + + return null; + } + + /** + * Validates that protocol and authentication method combinations are correct. + * + * @param auth The authentication configuration map + * @param protocol The protocol being used (SFTP or FTPS) + * @return Error object if validation fails, null otherwise + */ + private static Object validateAuthProtocolCombination(BMap auth, String protocol) { + final BMap privateKey = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PRIVATE_KEY)); + final BMap secureSocket = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)); + + if (privateKey != null && protocol.equals(FtpConstants.SCHEME_FTPS)) { + return FtpUtil.createError("privateKey can only be used with SFTP protocol. " + + "For FTPS, use secureSocket configuration.", Error.errorType()); + } + + if (secureSocket != null && protocol.equals(FtpConstants.SCHEME_SFTP)) { + return FtpUtil.createError("secureSocket can only be used with FTPS protocol. " + + "For SFTP, use privateKey configuration.", Error.errorType()); + } + + if (secureSocket != null && protocol.equals(FtpConstants.SCHEME_FTP)) { + return FtpUtil.createError("secureSocket can only be used with FTPS protocol. " + + "For FTP, do not use secureSocket configuration.", Error.errorType()); + } + + return null; + } + + /** + * Configures private key authentication for SFTP. + * + * @param auth The authentication configuration map + * @param ftpConfig The FTP configuration map to populate + */ + private static void configurePrivateKey(BMap auth, Map ftpConfig) { + final BMap privateKey = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PRIVATE_KEY)); + + if (privateKey == null) { + return; + } + + final BString privateKeyPath = privateKey.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_KEY_PATH)); + ftpConfig.put(FtpConstants.IDENTITY, privateKeyPath.getValue()); + + final BString privateKeyPassword = privateKey.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PASS_KEY)); + if (privateKeyPassword != null && !privateKeyPassword.getValue().isEmpty()) { + ftpConfig.put(FtpConstants.IDENTITY_PASS_PHRASE, privateKeyPassword.getValue()); + } + } + + /** + * Applies default FTP configuration values. + * + * @param config The client configuration map + * @param ftpConfig The FTP configuration map to populate + */ + private static void applyDefaultFtpConfig(BMap config, Map ftpConfig) { ftpConfig.put(FtpConstants.PASSIVE_MODE, String.valueOf(true)); boolean userDirIsRoot = config.getBooleanValue(FtpConstants.USER_DIR_IS_ROOT_FIELD); ftpConfig.put(FtpConstants.USER_DIR_IS_ROOT, String.valueOf(userDirIsRoot)); ftpConfig.put(FtpConstants.AVOID_PERMISSION_CHECK, String.valueOf(true)); + } - // Extract new VFS configurations + /** + * Extracts VFS-related configurations (timeout, file transfer, compression, known hosts, proxy). + * + * @param config The client configuration map + * @param ftpConfig The FTP configuration map to populate + * @return Error object if extraction fails, null otherwise + */ + private static Object extractVfsConfigurations(BMap config, Map ftpConfig) { try { extractTimeoutConfigurations(config, ftpConfig); extractFileTransferConfiguration(config, ftpConfig); extractCompressionConfiguration(config, ftpConfig); extractKnownHostsConfiguration(config, ftpConfig); extractProxyConfiguration(config, ftpConfig); + return null; } catch (BallerinaFtpException e) { return FtpUtil.createError(e.getMessage(), Error.errorType()); } + } + /** + * Creates the FTP URL, stores configuration, and creates the VFS client connector. + * + * @param clientEndpoint The client endpoint object + * @param ftpConfig The FTP configuration map + * @return Error object if creation fails, null otherwise + */ + private static Object createAndStoreConnector(BObject clientEndpoint, Map ftpConfig) { + // Fix: Default port to 990 for IMPLICIT FTPS if no port is specified + String protocol = (String) clientEndpoint.getNativeData(FtpConstants.ENDPOINT_CONFIG_PROTOCOL); + if (FtpConstants.SCHEME_FTPS.equals(protocol)) { + Object ftpsModeObj = ftpConfig.get(FtpConstants.ENDPOINT_CONFIG_FTPS_MODE); + if (ftpsModeObj != null && FtpConstants.FTPS_MODE_IMPLICIT.equals(ftpsModeObj.toString())) { + Integer currentPort = (Integer) clientEndpoint.getNativeData(FtpConstants.ENDPOINT_CONFIG_PORT); + if (currentPort == null || currentPort == -1 || currentPort == 21) { + // Default to port 990 for IMPLICIT FTPS when port is not specified + clientEndpoint.addNativeData(FtpConstants.ENDPOINT_CONFIG_PORT, 990); + } + } + } + String url; try { url = FtpUtil.createUrl(clientEndpoint, ""); } catch (BallerinaFtpException e) { return FtpUtil.createError(e.getMessage(), Error.errorType()); } + ftpConfig.put(FtpConstants.URI, url); clientEndpoint.addNativeData(FtpConstants.PROPERTY_MAP, ftpConfig); + RemoteFileSystemConnectorFactory fileSystemConnectorFactory = new RemoteFileSystemConnectorFactoryImpl(); try { VfsClientConnector connector = fileSystemConnectorFactory.createVfsClientConnector(ftpConfig); clientEndpoint.addNativeData(VFS_CLIENT_CONNECTOR, connector); + return null; } catch (RemoteFileSystemConnectorException e) { return FtpUtil.createError(e.getMessage(), findRootCause(e), Error.errorType()); } + } + + /** + * Configures secure socket settings for FTPS protocol. + * + * @param secureSocket The secure socket configuration map + * @param ftpConfig The FTP configuration map to populate + * @return Error object if configuration fails, null otherwise + */ + private static Object configureFtpsSecureSocket(BMap secureSocket, Map ftpConfig) { + configureFtpsMode(secureSocket, ftpConfig); + configureFtpsDataChannelProtection(secureSocket, ftpConfig); + + // Return error if KeyStore loading fails + Object keyError = extractAndConfigureStore(secureSocket, FtpConstants.SECURE_SOCKET_KEY, + FtpConstants.ENDPOINT_CONFIG_KEYSTORE_PATH, + FtpConstants.ENDPOINT_CONFIG_KEYSTORE_PASSWORD, + ftpConfig); + if (keyError != null) { + return keyError; + } + + Object trustError = extractAndConfigureStore(secureSocket, FtpConstants.SECURE_SOCKET_TRUSTSTORE, + FtpConstants.ENDPOINT_CONFIG_TRUSTSTORE_PATH, + FtpConstants.ENDPOINT_CONFIG_TRUSTSTORE_PASSWORD, + ftpConfig); + if (trustError != null) { + return trustError; + } + return null; } + /** + * Configures FTPS mode (IMPLICIT or EXPLICIT). + * + * @param secureSocket The secure socket configuration map + * @param ftpConfig The FTP configuration map to populate + */ + private static void configureFtpsMode(BMap secureSocket, Map ftpConfig) { + final BString mode = secureSocket.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_FTPS_MODE)); + + if (mode != null && !mode.getValue().isEmpty()) { + ftpConfig.put(FtpConstants.ENDPOINT_CONFIG_FTPS_MODE, mode.getValue()); + } else { + // Default to EXPLICIT if not specified + ftpConfig.put(FtpConstants.ENDPOINT_CONFIG_FTPS_MODE, FtpConstants.FTPS_MODE_EXPLICIT); + } + } + + /** + * Configures FTPS data channel protection level. + * + * @param secureSocket The secure socket configuration map + * @param ftpConfig The FTP configuration map to populate + */ + private static void configureFtpsDataChannelProtection(BMap secureSocket, Map ftpConfig) { + final BString dataChannelProtection = secureSocket.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION)); + + if (dataChannelProtection != null && !dataChannelProtection.getValue().isEmpty()) { + ftpConfig.put(FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION, + dataChannelProtection.getValue()); + } else { + // Default to PRIVATE (secure) if not specified + ftpConfig.put(FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION, + FtpConstants.FTPS_DATA_CHANNEL_PROTECTION_PRIVATE); + } + } + + /** + * Extracts a store (KeyStore or TrustStore) from secureSocket configuration and adds it to ftpConfig. + * Handles both BMap and BObject representations. Extracts path/password strings and loads the Java KeyStore. + * + * @param secureSocket The secure socket configuration map + * @param storeKey The key name ("key" for KeyStore, "cert" for TrustStore) + * @param pathConfigKey The configuration key for the store path (deprecated, kept for backward compatibility) + * @param passwordConfigKey The configuration key for the store password + * @param ftpConfig The FTP configuration map to populate + * @return Error object if KeyStore loading fails, null otherwise + */ + private static Object extractAndConfigureStore(BMap secureSocket, String storeKey, + String pathConfigKey, String passwordConfigKey, + Map ftpConfig) { + Object storeObj = getStoreObject(secureSocket, storeKey); + if (storeObj == null) { + return null; + } + + String path = null; + String password = null; + + // Extract Strings from Ballerina Record (BMap) + if (storeObj instanceof BMap) { + BMap storeRecord = (BMap) storeObj; + BString pathBStr = storeRecord.getStringValue( + StringUtils.fromString(FtpConstants.KEYSTORE_PATH_KEY)); + BString passBStr = storeRecord.getStringValue( + StringUtils.fromString(FtpConstants.KEYSTORE_PASSWORD_KEY)); + + if (pathBStr != null) { + path = pathBStr.getValue(); + } + if (passBStr != null) { + password = passBStr.getValue(); + } + } else if (storeObj instanceof BObject) { + // Fallback if it's a BObject (unlikely for crypto:KeyStore but safe to keep) + try { + BObject storeObject = (BObject) storeObj; + BString pathBStr = storeObject.getStringValue( + StringUtils.fromString(FtpConstants.KEYSTORE_PATH_KEY)); + BString passBStr = storeObject.getStringValue( + StringUtils.fromString(FtpConstants.KEYSTORE_PASSWORD_KEY)); + if (pathBStr != null) { + path = pathBStr.getValue(); + } + if (passBStr != null) { + password = passBStr.getValue(); + } + } catch (Exception e) { + log.debug("Could not extract path/password from BObject: {}", e.getMessage()); + } + } + + // BRIDGE: Load the Java Object and put it in the Map + if (path != null) { + try { + KeyStore javaKeyStore = FtpUtil.loadKeyStore(path, password); + + if (javaKeyStore != null) { + if (storeKey.equals(FtpConstants.SECURE_SOCKET_KEY)) { + ftpConfig.put(FtpConstants.KEYSTORE_INSTANCE, javaKeyStore); + } else { + ftpConfig.put(FtpConstants.TRUSTSTORE_INSTANCE, javaKeyStore); + } + } + } catch (BallerinaFtpException e) { + log.error("Failed to load FTPS Keystore from path {}: {}", path, e.getMessage()); + return FtpUtil.createError(e.getMessage(), findRootCause(e), Error.errorType()); + } + } + + // Backward compatibility: store password string if needed + if (password != null) { + ftpConfig.put(passwordConfigKey, password); + } + + return null; + } + + /** + * Attempts to retrieve a store object from secureSocket using multiple methods. + * Handles both BMap and BObject representations. + * + * @param secureSocket The secure socket configuration map + * @param storeKey The key name ("key" or "cert") + * @return The store object, or null if not found + */ + private static Object getStoreObject(BMap secureSocket, String storeKey) { + BString keyString = StringUtils.fromString(storeKey); + Object storeObj = secureSocket.get(keyString); + if (storeObj != null) { + return storeObj; + } + + storeObj = secureSocket.getMapValue(keyString); + if (storeObj != null) { + return storeObj; + } + + return secureSocket.getObjectValue(keyString); + } + /** * @deprecated : use typed getters like getBytes/getText/getJson/getXml/getCsv or their streaming variants. */ diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/server/FtpListenerHelper.java b/native/src/main/java/io/ballerina/stdlib/ftp/server/FtpListenerHelper.java index 2c2c91a4d..dd20228af 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/server/FtpListenerHelper.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/server/FtpListenerHelper.java @@ -39,6 +39,7 @@ import io.ballerina.stdlib.ftp.util.FtpUtil; import io.ballerina.stdlib.ftp.util.ModuleUtils; +import java.security.KeyStore; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -75,7 +76,7 @@ private FtpListenerHelper() { */ public static Object init(Environment env, BObject ftpListener, BMap serviceEndpointConfig) { try { - Map paramMap = getServerConnectorParamMap(serviceEndpointConfig); + Map paramMap = getServerConnectorParamMap(serviceEndpointConfig); RemoteFileSystemConnectorFactory fileSystemConnectorFactory = new RemoteFileSystemConnectorFactoryImpl(); final FtpListener listener = new FtpListener(env.getRuntime()); @@ -162,54 +163,320 @@ public static Object register(BObject ftpListener, BObject service) { return null; } - private static Map getServerConnectorParamMap(BMap serviceEndpointConfig) + private static Map getServerConnectorParamMap(BMap serviceEndpointConfig) throws BallerinaFtpException { - Map params = new HashMap<>(25); + Map params = new HashMap<>(25); + + String protocol = extractProtocol(serviceEndpointConfig); + configureBasicServerParams(serviceEndpointConfig, params); + + configureServerAuthentication(serviceEndpointConfig, protocol, params); + applyDefaultServerParams(serviceEndpointConfig, params); + addFileAgeFilterParams(serviceEndpointConfig, params); + extractServerVfsConfigurations(serviceEndpointConfig, params); + + return params; + } - BMap auth = serviceEndpointConfig.getMapValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_AUTH)); + /** + * Extracts the protocol from the service endpoint configuration. + * + * @param serviceEndpointConfig The service endpoint configuration map + * @return The protocol string + */ + private static String extractProtocol(BMap serviceEndpointConfig) { + return (serviceEndpointConfig.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PROTOCOL))).getValue(); + } + + /** + * Configures basic server parameters (URL, file pattern). + * + * @param serviceEndpointConfig The service endpoint configuration map + * @param params The parameters map to populate + * @throws BallerinaFtpException If URL creation fails + */ + private static void configureBasicServerParams(BMap serviceEndpointConfig, Map params) + throws BallerinaFtpException { String url = FtpUtil.createUrl(serviceEndpointConfig); params.put(FtpConstants.URI, url); addStringProperty(serviceEndpointConfig, params); - if (auth != null) { - final BMap privateKey = auth.getMapValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_PRIVATE_KEY)); - if (privateKey != null) { - final String privateKeyPath = (privateKey.getStringValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_KEY_PATH))).getValue(); - if (privateKeyPath.isEmpty()) { - throw FtpUtil.createError("Private key path cannot be empty", null, Error.errorType()); - } - params.put(FtpConstants.IDENTITY, privateKeyPath); - String privateKeyPassword = null; - if (privateKey.containsKey(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_PASS_KEY))) { - privateKeyPassword = (privateKey.getStringValue(StringUtils.fromString( - FtpConstants.ENDPOINT_CONFIG_PASS_KEY))).getValue(); - } - if (privateKeyPassword != null && !privateKeyPassword.isEmpty()) { - params.put(FtpConstants.IDENTITY_PASS_PHRASE, privateKeyPassword); + } + + /** + * Configures server authentication settings including validation and protocol-specific setup. + * + * @param serviceEndpointConfig The service endpoint configuration map + * @param protocol The protocol being used + * @param params The parameters map to populate + * @throws BallerinaFtpException If authentication configuration fails + */ + private static void configureServerAuthentication(BMap serviceEndpointConfig, String protocol, + Map params) throws BallerinaFtpException { + BMap auth = serviceEndpointConfig.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_AUTH)); + if (auth == null) { + return; + } + + validateServerAuthProtocolCombination(auth, protocol); + configureServerPrivateKey(auth, params); + + BMap secureSocket = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)); + if (secureSocket != null && protocol.equals(FtpConstants.SCHEME_FTPS)) { + configureServerFtpsSecureSocket(secureSocket, params); + } + + if (protocol.equals(FtpConstants.SCHEME_SFTP)) { + params.put(ENDPOINT_CONFIG_PREFERRED_METHODS, + FtpUtil.getPreferredMethodsFromAuthConfig(auth)); + } + } + + /** + * Validates that protocol and authentication method combinations are correct for server. + * + * @param auth The authentication configuration map + * @param protocol The protocol being used (SFTP or FTPS) + * @throws BallerinaFtpException If validation fails + */ + private static void validateServerAuthProtocolCombination(BMap auth, String protocol) + throws BallerinaFtpException { + final BMap privateKey = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PRIVATE_KEY)); + final BMap secureSocket = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)); + + if (privateKey != null && protocol.equals(FtpConstants.SCHEME_FTPS)) { + throw FtpUtil.createError("privateKey can only be used with SFTP protocol. " + + "For FTPS, use secureSocket configuration.", Error.errorType()); + } + + if (secureSocket != null && protocol.equals(FtpConstants.SCHEME_SFTP)) { + throw FtpUtil.createError("secureSocket can only be used with FTPS protocol. " + + "For SFTP, use privateKey configuration.", Error.errorType()); + } + + if (secureSocket != null && protocol.equals(FtpConstants.SCHEME_FTP)) { + throw FtpUtil.createError("secureSocket can only be used with FTPS protocol. " + + "For FTP, do not use secureSocket configuration.", Error.errorType()); + } + } + + /** + * Configures private key authentication for SFTP server. + * + * @param auth The authentication configuration map + * @param params The parameters map to populate + * @throws BallerinaFtpException If private key configuration is invalid + */ + private static void configureServerPrivateKey(BMap auth, Map params) + throws BallerinaFtpException { + final BMap privateKey = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PRIVATE_KEY)); + + if (privateKey == null) { + return; + } + + final String privateKeyPath = (privateKey.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_KEY_PATH))).getValue(); + if (privateKeyPath.isEmpty()) { + throw FtpUtil.createError("Private key path cannot be empty", null, Error.errorType()); + } + + params.put(FtpConstants.IDENTITY, privateKeyPath); + + String privateKeyPassword = null; + if (privateKey.containsKey(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_PASS_KEY))) { + privateKeyPassword = (privateKey.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_PASS_KEY))).getValue(); + } + + if (privateKeyPassword != null && !privateKeyPassword.isEmpty()) { + params.put(FtpConstants.IDENTITY_PASS_PHRASE, privateKeyPassword); + } + } + + /** + * Configures secure socket settings for FTPS protocol on server. + * + * @param secureSocket The secure socket configuration map + * @param params The parameters map to populate + * @throws BallerinaFtpException If keystore loading fails + */ + private static void configureServerFtpsSecureSocket(BMap secureSocket, Map params) + throws BallerinaFtpException { + configureServerFtpsMode(secureSocket, params); + configureServerFtpsDataChannelProtection(secureSocket, params); + // For Keystore + extractAndConfigureServerStore(secureSocket, FtpConstants.SECURE_SOCKET_KEY, + FtpConstants.ENDPOINT_CONFIG_KEYSTORE_PATH, + FtpConstants.ENDPOINT_CONFIG_KEYSTORE_PASSWORD, + params, "Keystore"); + + // For Truststore + extractAndConfigureServerStore(secureSocket, FtpConstants.SECURE_SOCKET_TRUSTSTORE, + FtpConstants.ENDPOINT_CONFIG_TRUSTSTORE_PATH, + FtpConstants.ENDPOINT_CONFIG_TRUSTSTORE_PASSWORD, + params, "Truststore"); + } + + /** + * Configures FTPS mode (IMPLICIT or EXPLICIT) for server. + * + * @param secureSocket The secure socket configuration map + * @param params The parameters map to populate + */ + private static void configureServerFtpsMode(BMap secureSocket, Map params) { + final BString mode = secureSocket.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_FTPS_MODE)); + + if (mode != null && !mode.getValue().isEmpty()) { + params.put(FtpConstants.ENDPOINT_CONFIG_FTPS_MODE, mode.getValue()); + } else { + params.put(FtpConstants.ENDPOINT_CONFIG_FTPS_MODE, FtpConstants.FTPS_MODE_EXPLICIT); + } + } + + /** + * Configures FTPS data channel protection level for server. + * + * @param secureSocket The secure socket configuration map + * @param params The parameters map to populate + */ + private static void configureServerFtpsDataChannelProtection(BMap secureSocket, Map params) { + final BString dataChannelProtection = secureSocket.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION)); + + if (dataChannelProtection != null && !dataChannelProtection.getValue().isEmpty()) { + params.put(FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION, + dataChannelProtection.getValue()); + } else { + params.put(FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION, + FtpConstants.FTPS_DATA_CHANNEL_PROTECTION_PRIVATE); + } + } + + /** + * Extracts a store (KeyStore or TrustStore) from secureSocket configuration and adds it to params. + * Handles both BMap and BObject representations. Extracts path/password strings and loads the Java KeyStore. + * + * @param secureSocket The secure socket configuration map + * @param storeKey The key name (SECURE_SOCKET_KEY for KeyStore, SECURE_SOCKET_TRUSTSTORE for TrustStore) + * @param pathConfigKey The configuration key for the store path + * @param passwordConfigKey The configuration key for the store password + * @param params The parameters map to populate + * @param storeType The type of store ("Keystore" or "Truststore") for error messaging + * @throws BallerinaFtpException + */ + private static void extractAndConfigureServerStore(BMap secureSocket, String storeKey, + String pathConfigKey, String passwordConfigKey, + Map params, String storeType) + throws BallerinaFtpException { + Object storeObj = getServerStoreObject(secureSocket, storeKey); + if (storeObj == null) { + return; + } + + String path = null; + String password = null; + + if (storeObj instanceof BMap) { + BMap storeRecord = (BMap) storeObj; + BString pathBStr = storeRecord.getStringValue(StringUtils.fromString(FtpConstants.KEYSTORE_PATH_KEY)); + BString passBStr = storeRecord.getStringValue(StringUtils.fromString(FtpConstants.KEYSTORE_PASSWORD_KEY)); + + if (pathBStr != null) { + path = pathBStr.getValue(); + } + if (passBStr != null) { + password = passBStr.getValue(); + } + } + + // Validate empty path for keystore (Mandatory for Key, Optional for Trust) + if (storeKey.equals(FtpConstants.SECURE_SOCKET_KEY) && (path == null || path.isEmpty())) { + throw new BallerinaFtpException("Failed to load FTPS Server " + storeType + ": Path cannot be empty"); + } + + // Load the Java KeyStore Object + if (path != null && !path.isEmpty()) { + try { + KeyStore javaKeyStore = FtpUtil.loadKeyStore(path, password); + + if (javaKeyStore != null) { + if (storeKey.equals(FtpConstants.SECURE_SOCKET_KEY)) { + params.put(FtpConstants.KEYSTORE_INSTANCE, javaKeyStore); + } else { + params.put(FtpConstants.TRUSTSTORE_INSTANCE, javaKeyStore); + } } + } catch (BallerinaFtpException e) { + // Uses the storeType ("Keystore" or "Truststore") in the error message + throw new BallerinaFtpException("Failed to load FTPS Server " + storeType + ": " + e.getMessage(), e); } - params.put(ENDPOINT_CONFIG_PREFERRED_METHODS, FtpUtil.getPreferredMethodsFromAuthConfig(auth)); } + + if (password != null) { + params.put(passwordConfigKey, password); + } + } + + /** + * Attempts to retrieve a store object from secureSocket using multiple methods. + * Handles both BMap and BObject representations. + * + * @param secureSocket The secure socket configuration map + * @param storeKey The key name (SECURE_SOCKET_KEY or SECURE_SOCKET_TRUSTSTORE) + * @return The store object, or null if not found + */ + private static Object getServerStoreObject(BMap secureSocket, String storeKey) { + BString keyString = StringUtils.fromString(storeKey); + Object storeObj = secureSocket.get(keyString); + if (storeObj != null) { + return storeObj; + } + + storeObj = secureSocket.getMapValue(keyString); + if (storeObj != null) { + return storeObj; + } + + return secureSocket.getObjectValue(keyString); + } + + /** + * Applies default server configuration values. + * + * @param serviceEndpointConfig The service endpoint configuration map + * @param params The parameters map to populate + */ + private static void applyDefaultServerParams(BMap serviceEndpointConfig, Map params) { boolean userDirIsRoot = serviceEndpointConfig.getBooleanValue(FtpConstants.USER_DIR_IS_ROOT_FIELD); params.put(FtpConstants.USER_DIR_IS_ROOT, String.valueOf(userDirIsRoot)); params.put(FtpConstants.AVOID_PERMISSION_CHECK, String.valueOf(true)); params.put(FtpConstants.PASSIVE_MODE, String.valueOf(true)); + } - // Add file age filter parameters - addFileAgeFilterParams(serviceEndpointConfig, params); - + /** + * Extracts VFS-related configurations for server (timeout, file transfer, compression, known hosts, proxy). + * + * @param serviceEndpointConfig The service endpoint configuration map + * @param params The parameters map to populate + * @throws BallerinaFtpException If extraction fails + */ + private static void extractServerVfsConfigurations(BMap serviceEndpointConfig, Map params) + throws BallerinaFtpException { extractTimeoutConfigurations(serviceEndpointConfig, params); extractFileTransferConfiguration(serviceEndpointConfig, params); extractCompressionConfiguration(serviceEndpointConfig, params); extractKnownHostsConfiguration(serviceEndpointConfig, params); extractProxyConfiguration(serviceEndpointConfig, params); - - return params; } - private static void addFileAgeFilterParams(BMap serviceEndpointConfig, Map params) { + private static void addFileAgeFilterParams(BMap serviceEndpointConfig, Map params) { BMap fileAgeFilter = serviceEndpointConfig.getMapValue( StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_FILE_AGE_FILTER)); if (fileAgeFilter != null) { @@ -275,7 +542,7 @@ private static List parseFileDependencyConditions( return conditions; } - private static void addStringProperty(BMap config, Map params) { + private static void addStringProperty(BMap config, Map params) { BString namePatternString = config.getStringValue(StringUtils.fromString( FtpConstants.ENDPOINT_CONFIG_FILE_PATTERN)); String fileNamePattern = (namePatternString != null && !namePatternString.getValue().isEmpty()) ? diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/transport/RemoteFileSystemConnectorFactory.java b/native/src/main/java/io/ballerina/stdlib/ftp/transport/RemoteFileSystemConnectorFactory.java index c75705166..5e874155f 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/transport/RemoteFileSystemConnectorFactory.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/transport/RemoteFileSystemConnectorFactory.java @@ -38,7 +38,7 @@ public interface RemoteFileSystemConnectorFactory { * @return RemoteFileSystemServerConnector RemoteFileSystemServerConnector instance. * @throws RemoteFileSystemConnectorException if any error occurred when creating the server connector. */ - RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, + RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, RemoteFileSystemListener remoteFileSystemListener) throws RemoteFileSystemConnectorException; @@ -48,7 +48,7 @@ RemoteFileSystemServerConnector createServerConnector(Map connec * @return VFSClientConnector instance * @throws RemoteFileSystemConnectorException if any error occurred when initializing the server connector. */ - RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, + RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, List dependencyConditions, RemoteFileSystemListener remoteFileSystemListener) throws RemoteFileSystemConnectorException; @@ -58,6 +58,6 @@ RemoteFileSystemServerConnector createServerConnector(Map connec * @return VFSClientConnector instance * @throws RemoteFileSystemConnectorException if any error occurred when initializing the server connector. */ - VfsClientConnector createVfsClientConnector(Map connectorConfig) + VfsClientConnector createVfsClientConnector(Map connectorConfig) throws RemoteFileSystemConnectorException; } diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/transport/client/connector/contractimpl/VfsClientConnectorImpl.java b/native/src/main/java/io/ballerina/stdlib/ftp/transport/client/connector/contractimpl/VfsClientConnectorImpl.java index 3c12d581f..c5263b50c 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/transport/client/connector/contractimpl/VfsClientConnectorImpl.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/transport/client/connector/contractimpl/VfsClientConnectorImpl.java @@ -54,20 +54,21 @@ public class VfsClientConnectorImpl implements VfsClientConnector { private static final Logger logger = LoggerFactory.getLogger( VfsClientConnectorImpl.class); - private Map connectorConfig; + private Map connectorConfig; private RemoteFileSystemListener remoteFileSystemListener; private FileSystemOptions opts; private FileObject path; private FileSystemManager fsManager; - public VfsClientConnectorImpl(Map config) + public VfsClientConnectorImpl(Map config) throws RemoteFileSystemConnectorException { this.connectorConfig = config; opts = FileTransportUtils.attachFileSystemOptions(config); String fileURI = null; try { fsManager = VFS.getManager(); - fileURI = connectorConfig.get(FtpConstants.URI); + Object uriObj = connectorConfig.get(FtpConstants.URI); + fileURI = (uriObj != null) ? uriObj.toString() : null; path = fsManager.resolveFile(fileURI, opts); } catch (FileSystemException e) { String safeUri = maskUrlPassword(fileURI); diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/transport/impl/RemoteFileSystemConnectorFactoryImpl.java b/native/src/main/java/io/ballerina/stdlib/ftp/transport/impl/RemoteFileSystemConnectorFactoryImpl.java index 4097b2399..72ad60d55 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/transport/impl/RemoteFileSystemConnectorFactoryImpl.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/transport/impl/RemoteFileSystemConnectorFactoryImpl.java @@ -36,14 +36,14 @@ public class RemoteFileSystemConnectorFactoryImpl implements RemoteFileSystemConnectorFactory { @Override - public RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, + public RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, RemoteFileSystemListener remoteFileSystemListener) throws RemoteFileSystemConnectorException { return new RemoteFileSystemServerConnectorImpl(connectorConfig, remoteFileSystemListener); } @Override - public RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, + public RemoteFileSystemServerConnector createServerConnector(Map connectorConfig, List dependencyConditions, RemoteFileSystemListener remoteFileSystemListener) throws RemoteFileSystemConnectorException { @@ -51,7 +51,7 @@ public RemoteFileSystemServerConnector createServerConnector(Map } @Override - public VfsClientConnector createVfsClientConnector(Map connectorConfig) + public VfsClientConnector createVfsClientConnector(Map connectorConfig) throws RemoteFileSystemConnectorException { return new VfsClientConnectorImpl(connectorConfig); } diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/RemoteFileSystemConsumer.java b/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/RemoteFileSystemConsumer.java index 5252f111f..6fcbcbebd 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/RemoteFileSystemConsumer.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/RemoteFileSystemConsumer.java @@ -75,10 +75,11 @@ public class RemoteFileSystemConsumer { * @param listener RemoteFileSystemListener instance to send callback * @throws RemoteFileSystemConnectorException if unable to start the connect to the remote server */ - public RemoteFileSystemConsumer(Map fileProperties, RemoteFileSystemListener listener) + public RemoteFileSystemConsumer(Map fileProperties, RemoteFileSystemListener listener) throws RemoteFileSystemConnectorException { this.remoteFileSystemListener = listener; - listeningDirURI = fileProperties.get(FtpConstants.URI); + Object uriObj = fileProperties.get(FtpConstants.URI); + listeningDirURI = (uriObj != null) ? uriObj.toString() : null; try { this.fileSystemManager = VFS.getManager(); this.fileSystemOptions = FileTransportUtils.attachFileSystemOptions(fileProperties); @@ -98,32 +99,42 @@ public RemoteFileSystemConsumer(Map fileProperties, RemoteFileSy throw new RemoteFileSystemConnectorException( "Unable to initialize the connection with the server. " + rootCauseMessage, e); } - if (fileProperties.get(FtpConstants.FILE_NAME_PATTERN) != null) { - fileNamePattern = fileProperties.get(FtpConstants.FILE_NAME_PATTERN); + Object fileNamePatternObj = fileProperties.get(FtpConstants.FILE_NAME_PATTERN); + if (fileNamePatternObj != null) { + fileNamePattern = fileNamePatternObj.toString(); } // Parse file age filter configuration if (fileProperties.containsKey(FtpConstants.FILE_AGE_FILTER_MIN_AGE)) { - String minAgeStr = fileProperties.get(FtpConstants.FILE_AGE_FILTER_MIN_AGE); - if (minAgeStr != null && !minAgeStr.isEmpty()) { - this.minAge = Double.parseDouble(minAgeStr); + Object minAgeObj = fileProperties.get(FtpConstants.FILE_AGE_FILTER_MIN_AGE); + if (minAgeObj != null) { + String minAgeStr = minAgeObj.toString(); + if (!minAgeStr.isEmpty()) { + this.minAge = Double.parseDouble(minAgeStr); + } } } if (fileProperties.containsKey(FtpConstants.FILE_AGE_FILTER_MAX_AGE)) { - String maxAgeStr = fileProperties.get(FtpConstants.FILE_AGE_FILTER_MAX_AGE); - if (maxAgeStr != null && !maxAgeStr.isEmpty()) { - this.maxAge = Double.parseDouble(maxAgeStr); + Object maxAgeObj = fileProperties.get(FtpConstants.FILE_AGE_FILTER_MAX_AGE); + if (maxAgeObj != null) { + String maxAgeStr = maxAgeObj.toString(); + if (!maxAgeStr.isEmpty()) { + this.maxAge = Double.parseDouble(maxAgeStr); + } } } if (fileProperties.containsKey(FtpConstants.FILE_AGE_FILTER_AGE_CALCULATION_MODE)) { - String mode = fileProperties.get(FtpConstants.FILE_AGE_FILTER_AGE_CALCULATION_MODE); - if (mode != null && !mode.isEmpty()) { - this.ageCalculationMode = mode; + Object modeObj = fileProperties.get(FtpConstants.FILE_AGE_FILTER_AGE_CALCULATION_MODE); + if (modeObj != null) { + String mode = modeObj.toString(); + if (!mode.isEmpty()) { + this.ageCalculationMode = mode; + } } } } - public RemoteFileSystemConsumer(Map fileProperties, + public RemoteFileSystemConsumer(Map fileProperties, List conditions, RemoteFileSystemListener listener) throws RemoteFileSystemConnectorException { diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/connector/contractimpl/RemoteFileSystemServerConnectorImpl.java b/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/connector/contractimpl/RemoteFileSystemServerConnectorImpl.java index f94d70a5b..2c7aac5b0 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/connector/contractimpl/RemoteFileSystemServerConnectorImpl.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/connector/contractimpl/RemoteFileSystemServerConnectorImpl.java @@ -44,7 +44,7 @@ public class RemoteFileSystemServerConnectorImpl implements RemoteFileSystemServ private RemoteFileSystemConsumer consumer; private AtomicBoolean isPollOperationOccupied = new AtomicBoolean(false); - public RemoteFileSystemServerConnectorImpl(Map properties, + public RemoteFileSystemServerConnectorImpl(Map properties, RemoteFileSystemListener remoteFileSystemListener) throws RemoteFileSystemConnectorException { try { @@ -57,7 +57,7 @@ public RemoteFileSystemServerConnectorImpl(Map properties, } } - public RemoteFileSystemServerConnectorImpl(Map properties, + public RemoteFileSystemServerConnectorImpl(Map properties, List conditions, RemoteFileSystemListener remoteFileSystemListener) throws RemoteFileSystemConnectorException { diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/util/FileTransportUtils.java b/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/util/FileTransportUtils.java index 0a2691667..c43a89f07 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/util/FileTransportUtils.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/transport/server/util/FileTransportUtils.java @@ -25,20 +25,30 @@ import org.apache.commons.vfs2.FileSystemOptions; import org.apache.commons.vfs2.provider.ftp.FtpFileSystemConfigBuilder; import org.apache.commons.vfs2.provider.ftp.FtpFileType; +import org.apache.commons.vfs2.provider.ftps.FtpsDataChannelProtectionLevel; +import org.apache.commons.vfs2.provider.ftps.FtpsFileSystemConfigBuilder; +import org.apache.commons.vfs2.provider.ftps.FtpsMode; import org.apache.commons.vfs2.provider.sftp.IdentityInfo; import org.apache.commons.vfs2.provider.sftp.SftpFileSystemConfigBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.security.KeyStore; import java.time.Duration; import java.util.Locale; import java.util.Map; import java.util.regex.Pattern; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + import static io.ballerina.stdlib.ftp.util.FtpConstants.ENDPOINT_CONFIG_PREFERRED_METHODS; import static io.ballerina.stdlib.ftp.util.FtpConstants.IDENTITY_PASS_PHRASE; import static io.ballerina.stdlib.ftp.util.FtpConstants.SCHEME_FTP; +import static io.ballerina.stdlib.ftp.util.FtpConstants.SCHEME_FTPS; import static io.ballerina.stdlib.ftp.util.FtpConstants.SCHEME_SFTP; /** @@ -59,55 +69,65 @@ private FileTransportUtils() {} * @param options Options to be used with the file system manager * @return A FileSystemOptions instance */ - public static FileSystemOptions attachFileSystemOptions(Map options) + public static FileSystemOptions attachFileSystemOptions(Map options) throws RemoteFileSystemConnectorException { if (options == null) { return null; } FileSystemOptions opts = new FileSystemOptions(); - String listeningDirURI = options.get(FtpConstants.URI); - if (listeningDirURI.toLowerCase(Locale.getDefault()).startsWith(SCHEME_FTP)) { + String listeningDirURI = (String) options.get(FtpConstants.URI); + String lowerCaseUri = listeningDirURI.toLowerCase(Locale.getDefault()); + if (lowerCaseUri.startsWith(SCHEME_FTPS)) { // + setFtpsOptions(options, opts); + } else if (lowerCaseUri.startsWith(SCHEME_FTP)) { setFtpOptions(options, opts); - } else if (listeningDirURI.toLowerCase(Locale.getDefault()).startsWith(SCHEME_SFTP)) { + } else if (lowerCaseUri.startsWith(SCHEME_SFTP)) { setSftpOptions(options, opts); } return opts; } - private static void setFtpOptions(Map options, FileSystemOptions opts) + private static void setFtpOptions(Map options, FileSystemOptions opts) throws RemoteFileSystemConnectorException { final FtpFileSystemConfigBuilder configBuilder = FtpFileSystemConfigBuilder.getInstance(); - if (options.get(FtpConstants.PASSIVE_MODE) != null) { - configBuilder.setPassiveMode(opts, Boolean.parseBoolean(options.get(FtpConstants.PASSIVE_MODE))); + Object passiveModeObj = options.get(FtpConstants.PASSIVE_MODE); + if (passiveModeObj != null) { + configBuilder.setPassiveMode(opts, Boolean.parseBoolean(passiveModeObj.toString())); } - if (options.get(FtpConstants.USER_DIR_IS_ROOT) != null) { - configBuilder.setUserDirIsRoot(opts, Boolean.parseBoolean(options.get(FtpConstants.USER_DIR_IS_ROOT))); + Object userDirIsRootObj = options.get(FtpConstants.USER_DIR_IS_ROOT); + if (userDirIsRootObj != null) { + configBuilder.setUserDirIsRoot(opts, Boolean.parseBoolean(userDirIsRootObj.toString())); } - if (options.get(FtpConstants.CONNECT_TIMEOUT) != null) { - double connectTimeoutSeconds = Double.parseDouble(options.get(FtpConstants.CONNECT_TIMEOUT)); + + Object connectTimeoutObj = options.get(FtpConstants.CONNECT_TIMEOUT); + if (connectTimeoutObj != null) { + double connectTimeoutSeconds = Double.parseDouble(connectTimeoutObj.toString()); Duration connectTimeout = Duration.ofMillis((long) (connectTimeoutSeconds * 1000)); configBuilder.setConnectTimeout(opts, connectTimeout); log.debug("FTP connectTimeout set to {} seconds", connectTimeoutSeconds); } - if (options.get(FtpConstants.FTP_DATA_TIMEOUT) != null) { - double dataTimeoutSeconds = Double.parseDouble(options.get(FtpConstants.FTP_DATA_TIMEOUT)); + Object dataTimeoutObj = options.get(FtpConstants.FTP_DATA_TIMEOUT); + if (dataTimeoutObj != null) { + double dataTimeoutSeconds = Double.parseDouble(dataTimeoutObj.toString()); Duration dataTimeout = Duration.ofMillis((long) (dataTimeoutSeconds * 1000)); configBuilder.setDataTimeout(opts, dataTimeout); log.debug("FTP dataTimeout set to {} seconds", dataTimeoutSeconds); } - if (options.get(FtpConstants.FTP_SOCKET_TIMEOUT) != null) { - double socketTimeoutSeconds = Double.parseDouble(options.get(FtpConstants.FTP_SOCKET_TIMEOUT)); + Object socketTimeoutObj = options.get(FtpConstants.FTP_SOCKET_TIMEOUT); + if (socketTimeoutObj != null) { + double socketTimeoutSeconds = Double.parseDouble(socketTimeoutObj.toString()); Duration socketTimeout = Duration.ofMillis((long) (socketTimeoutSeconds * 1000)); configBuilder.setSoTimeout(opts, socketTimeout); log.debug("FTP socketTimeout set to {} seconds", socketTimeoutSeconds); } - if (options.get(FtpConstants.FTP_FILE_TYPE) != null) { - String fileTypeStr = options.get(FtpConstants.FTP_FILE_TYPE); + Object fileTypeObj = options.get(FtpConstants.FTP_FILE_TYPE); + if (fileTypeObj != null) { + String fileTypeStr = fileTypeObj.toString(); if (FtpConstants.FILE_TYPE_ASCII.equalsIgnoreCase(fileTypeStr)) { configBuilder.setFileType(opts, FtpFileType.ASCII); log.debug("FTP file type set to ASCII"); @@ -121,24 +141,212 @@ private static void setFtpOptions(Map options, FileSystemOptions } } - private static void setSftpOptions(Map options, FileSystemOptions opts) + private static void setFtpsOptions(Map options, FileSystemOptions opts) + throws RemoteFileSystemConnectorException { + // Use FTPS-specific config builder for proper FTPS configuration + final FtpsFileSystemConfigBuilder ftpsConfigBuilder = FtpsFileSystemConfigBuilder.getInstance(); + + // Set common FTP options (passive mode, user dir as root) using FTPS builder + // These methods are inherited from FtpFileSystemConfigBuilder + // but must be called on the FTPS builder to ensure they are applied to the ftps. namespace + Object passiveModeObj = options.get(FtpConstants.PASSIVE_MODE); + if (passiveModeObj != null) { + ftpsConfigBuilder.setPassiveMode(opts, Boolean.parseBoolean(passiveModeObj.toString())); + } + Object userDirIsRootObj = options.get(FtpConstants.USER_DIR_IS_ROOT); + if (userDirIsRootObj != null) { + ftpsConfigBuilder.setUserDirIsRoot(opts, Boolean.parseBoolean(userDirIsRootObj.toString())); + } + + Object connectTimeoutObj = options.get(FtpConstants.CONNECT_TIMEOUT); + if (connectTimeoutObj != null) { + double connectTimeoutSeconds = Double.parseDouble(connectTimeoutObj.toString()); + Duration connectTimeout = Duration.ofMillis((long) (connectTimeoutSeconds * 1000)); + ftpsConfigBuilder.setConnectTimeout(opts, connectTimeout); + log.debug("FTPS connectTimeout set to {} seconds", connectTimeoutSeconds); + } + + Object dataTimeoutObj = options.get(FtpConstants.FTP_DATA_TIMEOUT); + if (dataTimeoutObj != null) { + double dataTimeoutSeconds = Double.parseDouble(dataTimeoutObj.toString()); + Duration dataTimeout = Duration.ofMillis((long) (dataTimeoutSeconds * 1000)); + ftpsConfigBuilder.setDataTimeout(opts, dataTimeout); + log.debug("FTPS dataTimeout set to {} seconds", dataTimeoutSeconds); + } + + Object socketTimeoutObj = options.get(FtpConstants.FTP_SOCKET_TIMEOUT); + if (socketTimeoutObj != null) { + double socketTimeoutSeconds = Double.parseDouble(socketTimeoutObj.toString()); + Duration socketTimeout = Duration.ofMillis((long) (socketTimeoutSeconds * 1000)); + ftpsConfigBuilder.setSoTimeout(opts, socketTimeout); + log.debug("FTPS socketTimeout set to {} seconds", socketTimeoutSeconds); + } + + Object fileTypeObj = options.get(FtpConstants.FTP_FILE_TYPE); + if (fileTypeObj != null) { + String fileTypeStr = fileTypeObj.toString(); + if (FtpConstants.FILE_TYPE_ASCII.equalsIgnoreCase(fileTypeStr)) { + ftpsConfigBuilder.setFileType(opts, FtpFileType.ASCII); + log.debug("FTPS file type set to ASCII"); + } else if (FtpConstants.FILE_TYPE_BINARY.equalsIgnoreCase(fileTypeStr)) { + ftpsConfigBuilder.setFileType(opts, FtpFileType.BINARY); + log.debug("FTPS file type set to BINARY"); + } else { + log.warn("Unknown FTPS file type: {}, defaulting to BINARY", fileTypeStr); + ftpsConfigBuilder.setFileType(opts, FtpFileType.BINARY); + } + } else { + // Default to BINARY when file type is not specified + // This is required for VFS to determine file type, especially with CLEAR data channel protection + ftpsConfigBuilder.setFileType(opts, FtpFileType.BINARY); + log.debug("FTPS file type defaulting to BINARY"); + } + + // Handle implicit vs explicit FTPS mode using the recommended VFS2 API + Object ftpsModeObj = options.get(FtpConstants.ENDPOINT_CONFIG_FTPS_MODE); + if (ftpsModeObj != null && FtpConstants.FTPS_MODE_IMPLICIT.equalsIgnoreCase(ftpsModeObj.toString())) { + // For implicit FTPS, set implicit SSL mode + ftpsConfigBuilder.setFtpsMode(opts, FtpsMode.IMPLICIT); + } else { + // For explicit FTPS (default), set explicit mode + ftpsConfigBuilder.setFtpsMode(opts, FtpsMode.EXPLICIT); + } + + // Configure data channel protection + configureFtpsSecurityOptions(ftpsConfigBuilder, opts, options); + + // Configure SSL/TLS certificates (KeyStore/TrustStore) for FTPS + configureFtpsSslCertificates(ftpsConfigBuilder, opts, options); + } + + /** + * Configures SSL/TLS certificates for FTPS by using pre-loaded KeyStore and TrustStore objects + * and setting KeyManager and TrustManager in VFS2. + * + * @param ftpsConfigBuilder The FTPS config builder + * @param opts The file system options + * @param options The configuration options map + * @throws RemoteFileSystemConnectorException If configuration fails + */ + private static void configureFtpsSslCertificates(FtpsFileSystemConfigBuilder ftpsConfigBuilder, + FileSystemOptions opts, + Map options) + throws RemoteFileSystemConnectorException { // + try { + // 1. Configure KeyStore (Client Certificate) + Object keystoreObj = options.get(FtpConstants.KEYSTORE_INSTANCE); + if (keystoreObj instanceof KeyStore) { + KeyStore keyStore = (KeyStore) keystoreObj; + Object passwordObj = options.get(FtpConstants.ENDPOINT_CONFIG_KEYSTORE_PASSWORD); + String password = (passwordObj != null) ? passwordObj.toString() : null; + + // Init KeyManagerFactory with the pre-loaded KeyStore + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + char[] passChars = (password != null) ? password.toCharArray() : null; + + kmf.init(keyStore, passChars); + KeyManager[] keyManagers = kmf.getKeyManagers(); + + if (keyManagers != null && keyManagers.length > 0) { + ftpsConfigBuilder.setKeyManager(opts, keyManagers[0]); + } else { + log.warn("FTPS configured with Keystore but no KeyManagers were found."); + } + } + + // 2. Configure TrustStore (Server Validation) + Object truststoreObj = options.get(FtpConstants.TRUSTSTORE_INSTANCE); + if (truststoreObj instanceof KeyStore) { + KeyStore trustStore = (KeyStore) truststoreObj; + + // Init TrustManagerFactory with the pre-loaded TrustStore + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + TrustManager[] trustManagers = tmf.getTrustManagers(); + + if (trustManagers != null && trustManagers.length > 0) { + ftpsConfigBuilder.setTrustManager(opts, trustManagers[0]); + } else { + log.warn("FTPS configured with TrustStore but no TrustManagers were found."); + } + } + } catch (Exception e) { + throw new RemoteFileSystemConnectorException( + "Failed to configure SSL/TLS certificates for FTPS: " + e.getMessage(), e); + } + } + + /** + * Configures FTPS security options including data channel protection. + * + * @param ftpsConfigBuilder The FTPS config builder + * @param opts The file system options + * @param options The configuration options map + */ + private static void configureFtpsSecurityOptions(FtpsFileSystemConfigBuilder ftpsConfigBuilder, + FileSystemOptions opts, + Map options) { + // Configure data channel protection level + Object protectionLevelObj = options.get(FtpConstants.ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION); + if (protectionLevelObj != null) { + String protectionLevel = protectionLevelObj.toString(); + FtpsDataChannelProtectionLevel level = mapToVfs2ProtectionLevel(protectionLevel); + ftpsConfigBuilder.setDataChannelProtectionLevel(opts, level); + log.debug("FTPS data channel protection set to: {}", protectionLevel); + } else { + // Default to PRIVATE (secure) + ftpsConfigBuilder.setDataChannelProtectionLevel(opts, FtpsDataChannelProtectionLevel.P); + log.debug("FTPS data channel protection defaulting to PRIVATE"); + } + } + + /** + * Maps Ballerina data channel protection level string to VFS2 enum value. + * + * @param level The protection level string (CLEAR, PRIVATE, SAFE, or CONFIDENTIAL) + * @return The corresponding VFS2 FtpsDataChannelProtectionLevel enum value + */ + private static FtpsDataChannelProtectionLevel mapToVfs2ProtectionLevel(String level) { + switch (level.toUpperCase()) { + case "CLEAR": + return FtpsDataChannelProtectionLevel.C; + case "PRIVATE": + return FtpsDataChannelProtectionLevel.P; + case "SAFE": + return FtpsDataChannelProtectionLevel.S; + case "CONFIDENTIAL": + return FtpsDataChannelProtectionLevel.E; + default: + log.warn("Unknown data channel protection level: {}, defaulting to PRIVATE", level); + return FtpsDataChannelProtectionLevel.P; + } + } + + + private static void setSftpOptions(Map options, FileSystemOptions opts) throws RemoteFileSystemConnectorException { final SftpFileSystemConfigBuilder configBuilder = SftpFileSystemConfigBuilder.getInstance(); - String value = options.get(ENDPOINT_CONFIG_PREFERRED_METHODS); - configBuilder.setPreferredAuthentications(opts, value); - boolean userDirIsRoot = Boolean.parseBoolean(options.get(FtpConstants.USER_DIR_IS_ROOT)); + Object preferredMethodsObj = options.get(ENDPOINT_CONFIG_PREFERRED_METHODS); + if (preferredMethodsObj != null) { + configBuilder.setPreferredAuthentications(opts, preferredMethodsObj.toString()); + } + Object userDirIsRootObj = options.get(FtpConstants.USER_DIR_IS_ROOT); + boolean userDirIsRoot = userDirIsRootObj != null && Boolean.parseBoolean(userDirIsRootObj.toString()); configBuilder.setUserDirIsRoot(opts, userDirIsRoot); - if (options.get(FtpConstants.IDENTITY) != null) { + Object identityObj = options.get(FtpConstants.IDENTITY); + if (identityObj != null) { IdentityInfo identityInfo; - if (options.containsKey(IDENTITY_PASS_PHRASE)) { - identityInfo = new IdentityInfo(new File(options.get(FtpConstants.IDENTITY)), - options.get(IDENTITY_PASS_PHRASE).getBytes()); + Object passPhraseObj = options.get(IDENTITY_PASS_PHRASE); + if (passPhraseObj != null) { + identityInfo = new IdentityInfo(new File(identityObj.toString()), + passPhraseObj.toString().getBytes()); } else { - identityInfo = new IdentityInfo(new File(options.get(FtpConstants.IDENTITY))); + identityInfo = new IdentityInfo(new File(identityObj.toString())); } configBuilder.setIdentityInfo(opts, identityInfo); } - if (options.get(FtpConstants.AVOID_PERMISSION_CHECK) != null) { + Object avoidPermissionCheckObj = options.get(FtpConstants.AVOID_PERMISSION_CHECK); + if (avoidPermissionCheckObj != null) { try { configBuilder.setStrictHostKeyChecking(opts, "no"); } catch (FileSystemException e) { @@ -146,30 +354,34 @@ private static void setSftpOptions(Map options, FileSystemOption } } - if (options.get(FtpConstants.CONNECT_TIMEOUT) != null) { - double connectTimeoutSeconds = Double.parseDouble(options.get(FtpConstants.CONNECT_TIMEOUT)); + Object connectTimeoutObj = options.get(FtpConstants.CONNECT_TIMEOUT); + if (connectTimeoutObj != null) { + double connectTimeoutSeconds = Double.parseDouble(connectTimeoutObj.toString()); Duration connectTimeout = Duration.ofMillis((long) (connectTimeoutSeconds * 1000)); configBuilder.setConnectTimeout(opts, connectTimeout); log.debug("SFTP connectTimeout set to {} seconds", connectTimeoutSeconds); } - if (options.get(FtpConstants.SFTP_SESSION_TIMEOUT) != null) { - double sessionTimeoutSeconds = Double.parseDouble(options.get(FtpConstants.SFTP_SESSION_TIMEOUT)); + Object sessionTimeoutObj = options.get(FtpConstants.SFTP_SESSION_TIMEOUT); + if (sessionTimeoutObj != null) { + double sessionTimeoutSeconds = Double.parseDouble(sessionTimeoutObj.toString()); Duration sessionTimeoutMillis = Duration.ofMillis((long) (sessionTimeoutSeconds * 1000)); configBuilder.setSessionTimeout(opts, sessionTimeoutMillis); log.debug("SFTP sessionTimeout set to {} seconds", sessionTimeoutSeconds); } // Compression configuration - if (options.get(FtpConstants.SFTP_COMPRESSION) != null) { - String compression = options.get(FtpConstants.SFTP_COMPRESSION); + Object compressionObj = options.get(FtpConstants.SFTP_COMPRESSION); + if (compressionObj != null) { + String compression = compressionObj.toString(); configBuilder.setCompression(opts, compression); log.debug("SFTP compression set to: {}", compression); } // Known hosts configuration - if (options.get(FtpConstants.SFTP_KNOWN_HOSTS) != null) { - String knownHostsPath = options.get(FtpConstants.SFTP_KNOWN_HOSTS); + Object knownHostsObj = options.get(FtpConstants.SFTP_KNOWN_HOSTS); + if (knownHostsObj != null) { + String knownHostsPath = knownHostsObj.toString(); String expandedPath = expandTildePath(knownHostsPath); File knownHostsFile = new File(expandedPath); if (knownHostsFile.exists()) { @@ -186,10 +398,13 @@ private static void setSftpOptions(Map options, FileSystemOption } // Proxy configuration - if (options.get(FtpConstants.PROXY_HOST) != null) { - String proxyHost = options.get(FtpConstants.PROXY_HOST); - String proxyPortStr = options.get(FtpConstants.PROXY_PORT); - String proxyType = options.getOrDefault(FtpConstants.PROXY_TYPE, FtpConstants.PROXY_TYPE_HTTP); + Object proxyHostObj = options.get(FtpConstants.PROXY_HOST); + if (proxyHostObj != null) { + String proxyHost = proxyHostObj.toString(); + Object proxyPortObj = options.get(FtpConstants.PROXY_PORT); + String proxyPortStr = proxyPortObj != null ? proxyPortObj.toString() : null; + Object proxyTypeObj = options.get(FtpConstants.PROXY_TYPE); + String proxyType = proxyTypeObj != null ? proxyTypeObj.toString() : FtpConstants.PROXY_TYPE_HTTP; if (!proxyHost.isEmpty()) { int proxyPort = proxyPortStr != null ? Integer.parseInt(proxyPortStr) : 8080; @@ -200,12 +415,13 @@ private static void setSftpOptions(Map options, FileSystemOption configBuilder.setProxyType(opts, SftpFileSystemConfigBuilder.PROXY_HTTP); // Set proxy authentication if provided - String proxyUsername = options.get(FtpConstants.PROXY_USERNAME); - String proxyPassword = options.get(FtpConstants.PROXY_PASSWORD); - if (proxyUsername != null && !proxyUsername.isEmpty()) { + Object proxyUsernameObj = options.get(FtpConstants.PROXY_USERNAME); + Object proxyPasswordObj = options.get(FtpConstants.PROXY_PASSWORD); + if (proxyUsernameObj != null && !proxyUsernameObj.toString().isEmpty()) { + String proxyUsername = proxyUsernameObj.toString(); configBuilder.setProxyUser(opts, proxyUsername); - if (proxyPassword != null) { - configBuilder.setProxyPassword(opts, proxyPassword); + if (proxyPasswordObj != null) { + configBuilder.setProxyPassword(opts, proxyPasswordObj.toString()); } log.debug("SFTP proxy authentication configured for user: {}", proxyUsername); } diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpConstants.java b/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpConstants.java index 0d312f2a6..6b9c95a3c 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpConstants.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpConstants.java @@ -35,6 +35,7 @@ private FtpConstants() { public static final String FILE_NAME_PATTERN = "fileNamePattern"; public static final String SCHEME_SFTP = "sftp"; public static final String SCHEME_FTP = "ftp"; + public static final String SCHEME_FTPS = "ftps"; public static final String URI = "uri"; public static final String PASSIVE_MODE = "PASSIVE_MODE"; public static final String USER_DIR_IS_ROOT = "USER_DIR_IS_ROOT"; @@ -78,8 +79,36 @@ private FtpConstants() { public static final String ENDPOINT_CONFIG_AUTH = "auth"; public static final String ENDPOINT_CONFIG_CREDENTIALS = "credentials"; public static final String ENDPOINT_CONFIG_PRIVATE_KEY = "privateKey"; + public static final String ENDPOINT_CONFIG_SECURE_SOCKET = "secureSocket"; + public static final String ENDPOINT_CONFIG_FTPS_MODE = "mode"; + public static final String ENDPOINT_CONFIG_KEYSTORE_PATH = "keystorePath"; + public static final String ENDPOINT_CONFIG_KEYSTORE_PASSWORD = "keystorePassword"; + public static final String ENDPOINT_CONFIG_TRUSTSTORE_PATH = "truststorePath"; + public static final String ENDPOINT_CONFIG_TRUSTSTORE_PASSWORD = "truststorePassword"; + + // Keys for extracting data from Ballerina Records + public static final String SECURE_SOCKET_KEY = "key"; + public static final String SECURE_SOCKET_TRUSTSTORE = "cert"; + public static final String KEYSTORE_PATH_KEY = "path"; + public static final String KEYSTORE_PASSWORD_KEY = "password"; + + // Keys for storing the Java Objects in the Config Map (Transport Layer) + public static final String KEYSTORE_INSTANCE = "KEYSTORE_INSTANCE"; + public static final String TRUSTSTORE_INSTANCE = "TRUSTSTORE_INSTANCE"; + public static final String ENDPOINT_CONFIG_PREFERRED_METHODS = "preferredMethods"; public static final String ENDPOINT_CONFIG_LAX_DATABINDING = "laxDataBinding"; + + // FTPS mode constants + public static final String FTPS_MODE_IMPLICIT = "IMPLICIT"; + public static final String FTPS_MODE_EXPLICIT = "EXPLICIT"; + + // FTPS data channel protection constants + public static final String ENDPOINT_CONFIG_FTPS_DATA_CHANNEL_PROTECTION = "dataChannelProtection"; + public static final String FTPS_DATA_CHANNEL_PROTECTION_CLEAR = "CLEAR"; + public static final String FTPS_DATA_CHANNEL_PROTECTION_PRIVATE = "PRIVATE"; + public static final String FTPS_DATA_CHANNEL_PROTECTION_SAFE = "SAFE"; + public static final String FTPS_DATA_CHANNEL_PROTECTION_CONFIDENTIAL = "CONFIDENTIAL"; // Advanced file selection configuration constants public static final String ENDPOINT_CONFIG_FILE_AGE_FILTER = "fileAgeFilter"; diff --git a/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpUtil.java b/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpUtil.java index 452652914..65e748545 100644 --- a/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpUtil.java +++ b/native/src/main/java/io/ballerina/stdlib/ftp/util/FtpUtil.java @@ -39,10 +39,12 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; +import java.security.KeyStore; import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -74,7 +76,7 @@ private FtpUtil() { // private constructor } - public static void extractTimeoutConfigurations(BMap config, Map ftpProperties) + public static void extractTimeoutConfigurations(BMap config, Map ftpProperties) throws BallerinaFtpException { // Extract connectTimeout Object connectTimeoutObj = config.get(StringUtils.fromString(FtpConstants.CONNECT_TIMEOUT)); @@ -117,14 +119,14 @@ private static void validateTimeout(double timeout, String fieldName) throws Bal } public static void extractFileTransferConfiguration(BMap config, - Map ftpProperties) { + Map ftpProperties) { BString ftpFileTransfer = config.getStringValue(StringUtils.fromString(FtpConstants.FTP_FILE_TYPE)); if (ftpFileTransfer != null && !ftpFileTransfer.getValue().isEmpty()) { ftpProperties.put(FtpConstants.FTP_FILE_TYPE, ftpFileTransfer.getValue()); } } - public static void extractCompressionConfiguration(BMap config, Map ftpProperties) { + public static void extractCompressionConfiguration(BMap config, Map ftpProperties) { BArray sftpCompression = config.getArrayValue(StringUtils.fromString(FtpConstants.SFTP_COMPRESSION)); if (sftpCompression != null && !sftpCompression.isEmpty()) { StringBuilder compressionValues = new StringBuilder(); @@ -141,14 +143,14 @@ public static void extractCompressionConfiguration(BMap config, } } - public static void extractKnownHostsConfiguration(BMap config, Map ftpProperties) { + public static void extractKnownHostsConfiguration(BMap config, Map ftpProperties) { BString knownHosts = config.getStringValue(StringUtils.fromString(FtpConstants.SFTP_KNOWN_HOSTS)); if (knownHosts != null && !knownHosts.getValue().isEmpty()) { ftpProperties.put(FtpConstants.SFTP_KNOWN_HOSTS, knownHosts.getValue()); } } - public static void extractProxyConfiguration(BMap config, Map ftpProperties) + public static void extractProxyConfiguration(BMap config, Map ftpProperties) throws BallerinaFtpException { BMap proxyConfig = config.getMapValue(StringUtils.fromString(FtpConstants.PROXY)); if (proxyConfig == null) { @@ -222,8 +224,23 @@ public static String createUrl(BMap config) throws BallerinaFtpException { int port = extractPortValue(config.getIntValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_PORT))); final BMap auth = config.getMapValue(StringUtils.fromString( FtpConstants.ENDPOINT_CONFIG_AUTH)); + if (protocol.equals(FtpConstants.SCHEME_FTPS) && (port <= 0 || port == 21)) { + if (auth != null) { + final BMap secureSocket = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)); + if (secureSocket != null) { + final BString mode = secureSocket.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_FTPS_MODE)); + // Check if mode is IMPLICIT + if (mode != null && FtpConstants.FTPS_MODE_IMPLICIT.equalsIgnoreCase(mode.getValue())) { + port = 990; + } + } + } + } String username = FTP_ANONYMOUS_USERNAME; - String password = protocol.equals(FtpConstants.SCHEME_FTP) ? FTP_ANONYMOUS_PASSWORD : null; + String password = (protocol.equals(FtpConstants.SCHEME_FTP) || protocol.equals(FtpConstants.SCHEME_FTPS)) // + ? FTP_ANONYMOUS_PASSWORD : null; if (auth != null) { final BMap credentials = auth.getMapValue(StringUtils.fromString( FtpConstants.ENDPOINT_CONFIG_CREDENTIALS)); @@ -240,6 +257,23 @@ public static String createUrl(BMap config) throws BallerinaFtpException { } } } + + // Fix: Default port to 990 for IMPLICIT FTPS if no port is specified + if (FtpConstants.SCHEME_FTPS.equals(protocol) && (port == -1 || port == 0)) { + if (auth != null) { + final BMap secureSocket = auth.getMapValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_SECURE_SOCKET)); + if (secureSocket != null) { + final BString mode = secureSocket.getStringValue(StringUtils.fromString( + FtpConstants.ENDPOINT_CONFIG_FTPS_MODE)); + if (mode != null && FtpConstants.FTPS_MODE_IMPLICIT.equals(mode.getValue())) { + // Default to port 990 for IMPLICIT FTPS when port is not specified + port = 990; + } + } + } + } + return createUrl(protocol, host, port, username, password, filePath); } @@ -277,7 +311,8 @@ public static Map getAuthMap(BMap config, String protocol) { final BMap auth = config.getMapValue(StringUtils.fromString( FtpConstants.ENDPOINT_CONFIG_AUTH)); String username = FTP_ANONYMOUS_USERNAME; - String password = protocol.equals(FtpConstants.SCHEME_FTP) ? FTP_ANONYMOUS_PASSWORD : null; + String password = (protocol.equals(FtpConstants.SCHEME_FTP) || protocol.equals(FtpConstants.SCHEME_FTPS)) // + ? FTP_ANONYMOUS_PASSWORD : null; if (auth != null) { final BMap credentials = auth.getMapValue(StringUtils.fromString( FtpConstants.ENDPOINT_CONFIG_CREDENTIALS)); @@ -412,7 +447,7 @@ public static Optional getContentHandlerMethod(BObject service) { } /** - * Gets all content handler methods from a service. +s * Gets all content handler methods from a service. * * @param service The BObject service * @return Array of MethodType objects representing all content handler methods @@ -516,4 +551,82 @@ public String errorType() { public static Module getFtpPackage() { return getModule(); } + + /** + * Loads a Java KeyStore from a file path and password. + * + * @param path The file path to the KeyStore + * @param password The password for the KeyStore + * @return The loaded java.security.KeyStore object + * @throws BallerinaFtpException If loading fails + */ + public static KeyStore loadKeyStore(String path, String password) throws BallerinaFtpException { + if (path == null || path.isEmpty()) { + return null; + } + try { + // Auto-detect type based on extension + String type = KeyStore.getDefaultType(); + if (path.toLowerCase().endsWith(".jks")) { + type = "JKS"; + } else if (path.toLowerCase().endsWith(".p12") || path.toLowerCase().endsWith(".pfx")) { + type = "PKCS12"; + } + + KeyStore keyStore = KeyStore.getInstance(type); + char[] passChars = (password != null) ? password.toCharArray() : null; + + try (FileInputStream fis = new FileInputStream(new File(path))) { + keyStore.load(fis, passChars); + } + return keyStore; + } catch (Exception e) { + throw new BallerinaFtpException("Failed to load KeyStore from path: " + path + ". " + e.getMessage(), e); + } + } + + /** + * Extracts path and password from a crypto:KeyStore or crypto:TrustStore BObject/BMap. + * Handles both BMap (record) and BObject (typed object) cases. + * + * @deprecated This method is deprecated. Use extractJavaKeyStore instead to get the KeyStore object directly. + * @param keyStoreObj The KeyStore/TrustStore object (can be BMap or BObject) + * @return Map with "path" and "password" keys, or empty map if extraction fails + */ + @Deprecated + public static Map extractKeyStoreInfo(Object keyStoreObj) { // + if (keyStoreObj == null) { + return new HashMap<>(); + } + + Map result = new HashMap<>(2); + + try { + BString path = null; + BString password = null; + + // Try as BMap first (record type) + if (keyStoreObj instanceof BMap) { + BMap keyStoreMap = (BMap) keyStoreObj; + path = keyStoreMap.getStringValue(StringUtils.fromString(FtpConstants.KEYSTORE_PATH_KEY)); + password = keyStoreMap.getStringValue(StringUtils.fromString(FtpConstants.KEYSTORE_PASSWORD_KEY)); + } else if (keyStoreObj instanceof BObject) { // Try as BObject (typed object from crypto module) + BObject keyStoreObject = (BObject) keyStoreObj; + path = keyStoreObject.getStringValue(StringUtils.fromString(FtpConstants.KEYSTORE_PATH_KEY)); + password = keyStoreObject.getStringValue(StringUtils.fromString(FtpConstants.KEYSTORE_PASSWORD_KEY)); + } + + if (path != null && !path.getValue().isEmpty()) { + result.put(FtpConstants.KEYSTORE_PATH_KEY, path.getValue()); + } + if (password != null && !password.getValue().isEmpty()) { + result.put(FtpConstants.KEYSTORE_PASSWORD_KEY, password.getValue()); + } + + return result; + } catch (Exception e) { + log.warn("Failed to extract KeyStore/TrustStore information: {}", e.getMessage()); + return new HashMap<>(); + } + } } diff --git a/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/MockFtpServer.java b/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/MockFtpServer.java index 9eca3922f..9d358130a 100644 --- a/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/MockFtpServer.java +++ b/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/MockFtpServer.java @@ -39,9 +39,13 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; /** * Creates a Mock FTP Servers @@ -59,6 +63,8 @@ private MockFtpServer() {} private static FakeFtpServer ftpServer; private static SshServer sftpServer; private static FtpServer ftpsServer; + private static FtpServer ftpsServerExplicit; + private static FtpServer ftpsServerImplicit; private static SftpAuthStatusHolder sftpAuthStatusHolder = new SftpAuthStatusHolder(); public static Object initAnonymousFtpServer() throws Exception { @@ -157,22 +163,42 @@ public static Object initFtpServer() throws Exception { } public static void startFtpsServer(String resources) throws Exception { + startFtpsServer(resources, true, 21214); + } + + public static void startFtpsServer(String resources, boolean implicitMode, int port) throws Exception { final FtpServerFactory serverFactory = new FtpServerFactory(); - int port = 21214; final ListenerFactory factory = new ListenerFactory(); SslConfigurationFactory ssl = new SslConfigurationFactory(); ssl.setKeystoreFile(new File(resources + "/keystore.jks")); ssl.setKeystorePassword("changeit"); factory.setSslConfiguration(ssl.createSslConfiguration()); - factory.setImplicitSsl(true); + factory.setImplicitSsl(implicitMode); final PropertiesUserManagerFactory userManagerFactory = new PropertiesUserManagerFactory(); final UserManager userManager = userManagerFactory.createUserManager(); BaseUser user = new BaseUser(); user.setName(username); user.setPassword(password); + + // --- START ROBUST SETUP --- File dataDirectory = new File(resources + "/datafiles"); + if (!dataDirectory.exists()) { + dataDirectory.mkdirs(); + } + + // Clean and create isolated directories for FTPS + // This ensures no zombie files from previous runs interfere + File ftpsClientDir = new File(dataDirectory, "ftps-client"); + cleanDirectory(ftpsClientDir); + ftpsClientDir.mkdirs(); + + File ftpsListenerDir = new File(dataDirectory, "ftps-listener"); + cleanDirectory(ftpsListenerDir); + ftpsListenerDir.mkdirs(); + user.setHomeDirectory(dataDirectory.getAbsolutePath()); + List authorities = new ArrayList<>(); authorities.add(new WritePermission()); user.setAuthorities(authorities); @@ -180,11 +206,11 @@ public static void startFtpsServer(String resources) throws Exception { serverFactory.setUserManager(userManager); factory.setPort(port); serverFactory.addListener("default", factory.createListener()); - ftpsServer = serverFactory.createServer(); - ftpsServer.start(); + FtpServer server = serverFactory.createServer(); + server.start(); int i = 0; - while ((ftpsServer.isStopped() || ftpsServer.isSuspended()) && i < 10) { + while ((server.isStopped() || server.isSuspended()) && i < 10) { try { TimeUnit.MILLISECONDS.sleep(500); i++; @@ -194,14 +220,40 @@ public static void startFtpsServer(String resources) throws Exception { } } if (i < 10) { - logger.info("Started Apache FTPS server"); + if (implicitMode) { + ftpsServerImplicit = server; + logger.info("Started Apache FTPS server in IMPLICIT mode on port {}", port); + } else { + ftpsServerExplicit = server; + logger.info("Started Apache FTPS server in EXPLICIT mode on port {}", port); + } + // Keep backward compatibility + ftpsServer = server; } else { - logger.info("Could not start Apache FTPS server"); + logger.error("Could not start Apache FTPS server"); + throw new Exception("Could not start Apache FTPS server"); } } + public static void startFtpsServerExplicit(String resources) throws Exception { + startFtpsServer(resources, false, 21214); + } + + public static void startFtpsServerImplicit(String resources) throws Exception { + startFtpsServer(resources, true, 990); + } + public static Object initSftpServer(String resources) throws Exception { final int port = 21213; + + // --- CLEANUP FOR SFTP TESTS --- + // Ensure the directories used by SFTP tests are clean + File dataDirectory = new File(resources + "/datafiles"); + if (dataDirectory.exists()) { + cleanDirectory(new File(dataDirectory, "in")); + cleanDirectory(new File(dataDirectory, "out")); + } + sftpServer = SshServer.setUpDefaultServer(); SftpServerUtil.setupBasicServerConfig(sftpServer, resources, port); try { @@ -238,10 +290,47 @@ public static void stopSftpServer() throws IOException { logger.info("Stopped Mock SFTP server"); } - public static void stopFtpsServer() throws IOException { - if (!ftpsServer.isSuspended() && !ftpsServer.isStopped()) { + public static void stopFtpsServer() { + if (ftpsServer != null) { ftpsServer.stop(); + ftpsServer = null; + } + if (ftpsServerExplicit != null) { + ftpsServerExplicit.stop(); + ftpsServerExplicit = null; + } + if (ftpsServerImplicit != null) { + ftpsServerImplicit.stop(); + ftpsServerImplicit = null; } logger.info("Stopped FTPS server"); } + + public static void stopFtpsServerExplicit() throws IOException { + if (ftpsServerExplicit != null && !ftpsServerExplicit.isSuspended() && !ftpsServerExplicit.isStopped()) { + ftpsServerExplicit.stop(); + } + logger.info("Stopped FTPS server (EXPLICIT mode)"); + } + + public static void stopFtpsServerImplicit() throws IOException { + if (ftpsServerImplicit != null && !ftpsServerImplicit.isSuspended() && !ftpsServerImplicit.isStopped()) { + ftpsServerImplicit.stop(); + } + logger.info("Stopped FTPS server (IMPLICIT mode)"); + } + + // Helper method to recursively clean a directory + private static void cleanDirectory(File dir) { + if (dir != null && dir.exists()) { + try (Stream walk = Files.walk(dir.toPath())) { + walk.sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(dir.toPath())) // Don't delete the root dir itself, just contents + .map(Path::toFile) + .forEach(File::delete); + } catch (IOException e) { + logger.warn("Failed to clean directory: " + dir.getAbsolutePath() + " - " + e.getMessage()); + } + } + } } diff --git a/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/StartServer.java b/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/StartServer.java index 93cd83a83..ea9cbef72 100644 --- a/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/StartServer.java +++ b/test-utils/src/main/java/io/ballerina/stdlib/ftp/testutils/mockServerUtils/StartServer.java @@ -35,6 +35,8 @@ public static void main(String[] args) { MockFtpServer.initAnonymousFtpServer(); MockFtpServer.initFtpServer(); MockFtpServer.initSftpServer(args[0]); + MockFtpServer.startFtpsServerExplicit(args[0]); // Port 21214 + MockFtpServer.startFtpsServerImplicit(args[0]); // Port 990 } catch (Exception ex) { logger.error(ex.getMessage()); System.exit(1);