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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ballerina/Ballerina.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
org = "ballerina"
name = "ftp"
version = "2.14.0"
version = "2.14.1"
authors = ["Ballerina"]
keywords = ["FTP", "SFTP", "remote file", "file transfer", "client", "service"]
repository = "https://github.com/ballerina-platform/module-ballerina-ftp"
Expand Down Expand Up @@ -45,5 +45,5 @@ path = "./lib/commons-lang3-3.18.0.jar"
[[platform.java21.dependency]]
groupId = "io.ballerina.stdlib"
artifactId = "ftp-native"
version = "2.14.0"
path = "../native/build/libs/ftp-native-2.14.0.jar"
version = "2.14.1"
path = "../native/build/libs/ftp-native-2.14.1-SNAPSHOT.jar"
2 changes: 1 addition & 1 deletion ballerina/CompilerPlugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ id = "ftp-compiler-plugin"
class = "io.ballerina.stdlib.ftp.plugin.FtpCompilerPlugin"

[[dependency]]
path = "../compiler-plugin/build/libs/ftp-compiler-plugin-2.14.0.jar"
path = "../compiler-plugin/build/libs/ftp-compiler-plugin-2.14.1-SNAPSHOT.jar"
2 changes: 1 addition & 1 deletion ballerina/Dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ distribution-version = "2201.12.0"
[[package]]
org = "ballerina"
name = "ftp"
version = "2.14.0"
version = "2.14.1"
dependencies = [
{org = "ballerina", name = "io"},
{org = "ballerina", name = "jballerina.java"},
Expand Down
100 changes: 100 additions & 0 deletions ballerina/tests/client_endpoint_test.bal
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ string nonFittingFilePath = "/home/in/test4.txt";
string newFilePath = "/home/in/test2.txt";
string appendFilePath = "tests/resources/datafiles/file1.txt";
string putFilePath = "tests/resources/datafiles/file2.txt";
string relativePath = "rel-put.txt";
string relativePathWithSlash = "/rel-path-slash-put.txt";
string absPath = "//home/in/double-abs.txt";

// Create the config to access anonymous mock FTP server
ClientConfiguration anonConfig = {
Expand Down Expand Up @@ -65,9 +68,26 @@ ClientConfiguration sftpConfig = {
}
};

// Create the config to access mock SFTP server with jailed home
ClientConfiguration sftpConfigUserDirRoot = {
protocol: SFTP,
host: "127.0.0.1",
port: 21213,
auth: {
credentials: {username: "wso2", password: "wso2123"},
privateKey: {
path: "tests/resources/sftp.private.key",
password: "changeit"
},
preferredMethods: [GSSAPI_WITH_MIC, PUBLICKEY, KEYBOARD_INTERACTIVE, PASSWORD]
},
userDirIsRoot: true
};

Client? anonClientEp = ();
Client? clientEp = ();
Client? sftpClientEp = ();
Client? sftpClientUserDirRootEp = ();
Client? ftpUserHomeRootClientEp = ();

Listener? callerListener = ();
Expand All @@ -81,6 +101,7 @@ function initTestEnvironment() returns error? {
anonClientEp = check new (anonConfig);
clientEp = check new (config);
sftpClientEp = check new (sftpConfig);
sftpClientUserDirRootEp = check new (sftpConfigUserDirRoot);
ftpUserHomeRootClientEp = check new (ftpUserHomeRootConfig);

callerListener = check new (callerListenerConfig);
Expand Down Expand Up @@ -237,6 +258,85 @@ function testFtpUserDirIsRootTrue() returns error? {
}
}

@test:Config {
dependsOn: [testListFiles]
}
public function testPutRelativePath_userDirIsRootTrue() returns error? {
Error? putRes = (<Client>ftpUserHomeRootClientEp)->put(relativePath, "hello-jailed-rel");
if putRes is Error {
test:assertFail("PUT(relative, no slash) failed on userDirIsRoot=true: " + putRes.message());
}

stream<byte[] & readonly, io:Error?>|Error getRes = (<Client>ftpUserHomeRootClientEp)->get(relativePath);
if getRes is stream<byte[] & readonly, io:Error?> {
test:assertTrue(check matchStreamContent(getRes, "hello-jailed-rel"),
msg = "Unexpected content from GET(relative) after PUT on userDirIsRoot=true");
io:Error? closeErr = getRes.close();
if closeErr is io:Error {
test:assertFail("Error closing relative GET stream: " + closeErr.message());
}
} else {
test:assertFail("GET(relative) failed on userDirIsRoot=true: " + getRes.message());
}

Error? delRes = (<Client>ftpUserHomeRootClientEp)->delete(relativePath);
if delRes is Error {
log:printWarn("Cleanup delete failed for " + relativePath + ": " + delRes.message());
}
}

@test:Config {
dependsOn: [testListFiles]
}
public function testPutRelativePathWithSlash_userDirIsRootTrue() returns error? {
Error? putRes = (<Client>ftpUserHomeRootClientEp)->put(relativePathWithSlash, "hello-jailed-rel-with-slash");
if putRes is Error {
test:assertFail("PUT(relative, with slash) failed on userDirIsRoot=true: " + putRes.message());
}

stream<byte[] & readonly, io:Error?>|Error getRes = (<Client>ftpUserHomeRootClientEp)->get(relativePathWithSlash);
if getRes is stream<byte[] & readonly, io:Error?> {
test:assertTrue(check matchStreamContent(getRes, "hello-jailed-rel-with-slash"),
msg = "Unexpected content from GET(relative) after PUT on userDirIsRoot=true");
io:Error? closeErr = getRes.close();
if closeErr is io:Error {
test:assertFail("Error closing relative GET stream: " + closeErr.message());
}
} else {
test:assertFail("GET(relative) failed on userDirIsRoot=true: " + getRes.message());
}

Error? delRes = (<Client>ftpUserHomeRootClientEp)->delete(relativePathWithSlash);
if delRes is Error {
log:printWarn("Cleanup delete failed for " + relativePathWithSlash + ": " + delRes.message());
}
}

@test:Config { dependsOn: [testListFiles] }
public function testPutAbsoluteDoubleSlash_userDirIsRootFalse() returns error? {
Error? putRes = (<Client>clientEp)->put(absPath, "hello-abs-double-slash");
if putRes is Error { test:assertFail("PUT(//absolute) failed: " + putRes.message()); }
stream<byte[] & readonly, io:Error?>|Error getRes = (<Client>clientEp)->get(absPath);
if getRes is stream<byte[] & readonly, io:Error?> {
test:assertTrue(check matchStreamContent(getRes, "hello-abs-double-slash"));
check getRes.close();
} else { test:assertFail("GET(//absolute) failed: " + getRes.message()); }
check (<Client>clientEp)->delete(absPath);
}

@test:Config { dependsOn: [testListFiles] }
public function testSftpUserDirIsRootTrue_RelativePutGet() returns error? {
stream<io:Block, io:Error?> bStream = ["hello-sftp-rel".toBytes().cloneReadOnly()].toStream();
Error? putRes = (<Client>sftpClientUserDirRootEp)->put("sftp-rel.txt", bStream);
if putRes is Error { test:assertFail("SFTP relative PUT failed: " + putRes.message()); }
stream<byte[] & readonly, io:Error?>|Error getRes = (<Client>sftpClientUserDirRootEp)->get("sftp-rel.txt");
if getRes is stream<byte[] & readonly, io:Error?> {
test:assertTrue(check matchStreamContent(getRes, "hello-sftp-rel"));
check getRes.close();
} else { test:assertFail("SFTP relative GET failed: " + getRes.message()); }
check (<Client>sftpClientUserDirRootEp)->delete("sftp-rel.txt");
}

@test:Config {
dependsOn: [testPutCompressedFileContent]
}
Expand Down
3 changes: 3 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- [Fix the issue where the FTP URL is improperly formatted for the jail-break scnenarios](https://github.com/ballerina-platform/ballerina-library/issues/8267)


## [2.14.0] - 2025-08-21

### Added
Expand Down
16 changes: 13 additions & 3 deletions native/src/main/java/io/ballerina/stdlib/ftp/util/FtpUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,24 @@ public static String createUrl(BMap config) throws BallerinaFtpException {
private static String createUrl(String protocol, String host, int port, String username, String password,
String filePath) throws BallerinaFtpException {
String userInfo = username + ":" + password;
URI uri;
final String normalizedPath = normalizeFtpPath(filePath);
try {
uri = new URI(protocol, userInfo, host, port, filePath, null, null);
URI uri = new URI(protocol, userInfo, host, port, normalizedPath, null, null);
return uri.toString();
} catch (URISyntaxException e) {
throw new BallerinaFtpException("Error occurred while constructing a URI from host: " + host +
", port: " + port + ", username: " + username + " and basePath: " + filePath + e.getMessage(), e);
}
return uri.toString();
}

private static String normalizeFtpPath(String rawPath) {
if (rawPath == null || rawPath.isEmpty()) {
return "/";
}
if (rawPath.startsWith("/")) {
return rawPath;
}
return "/" + rawPath;
}

public static Map<String, String> getAuthMap(BMap config) {
Expand Down
Loading