Skip to content
Merged
5 changes: 5 additions & 0 deletions ballerina/client_endpoint.bal
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,16 @@ public enum Compression {
# + host - Target service URL
# + port - Port number of the remote service
# + auth - Authentication options
# + userDirIsRoot - If set to `true`, treats the login home directory as the root (`/`) and
# prevents the underlying VFS from attempting to change to the actual server root.
# If `false`, treats the actual server root as `/`, which may cause a `CWD /` command
# that can fail on servers restricting root access (e.g., chrooted environments).
public type ClientConfiguration record {|
Protocol protocol = FTP;
string host = "127.0.0.1";
int port = 21;
AuthConfiguration auth?;
boolean userDirIsRoot = false;
|};

isolated function getInputContent(string path, stream<byte[] & readonly, io:Error?>|string|xml|json content,
Expand Down
5 changes: 5 additions & 0 deletions ballerina/listener_endpoint.bal
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ class Job {
# + path - Remote FTP directory location
# + fileNamePattern - File name pattern that event need to trigger
# + pollingInterval - Periodic time interval to check new update
# + userDirIsRoot - If set to `true`, treats the login home directory as the root (`/`) and
# prevents the underlying VFS from attempting to change to the actual server root.
# If `false`, treats the actual server root as `/`, which may cause a `CWD /` command
# that can fail on servers restricting root access (e.g., chrooted environments).
public type ListenerConfiguration record {|
Protocol protocol = FTP;
string host = "127.0.0.1";
Expand All @@ -167,6 +171,7 @@ public type ListenerConfiguration record {|
string path = "/";
string fileNamePattern?;
decimal pollingInterval = 60;
boolean userDirIsRoot = false;
|};

# Represents a FTP service.
Expand Down
29 changes: 28 additions & 1 deletion ballerina/tests/client_endpoint_test.bal
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ ClientConfiguration config = {
protocol: FTP,
host: "127.0.0.1",
port: 21212,
auth: {credentials: {username: "wso2", password: "wso2123"}}
auth: {credentials: {username: "wso2", password: "wso2123"}},
userDirIsRoot: false
};

ClientConfiguration ftpUserHomeRootConfig = {
protocol: FTP,
host: "127.0.0.1",
port: 21212,
auth: { credentials: { username: "wso2", password: "wso2123" } },
userDirIsRoot: true
};

// Create the config to access mock SFTP server
Expand All @@ -59,6 +68,7 @@ ClientConfiguration sftpConfig = {
Client? anonClientEp = ();
Client? clientEp = ();
Client? sftpClientEp = ();
Client? ftpUserHomeRootClientEp = ();

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

callerListener = check new (callerListenerConfig);
check (<Listener>callerListener).attach(callerService);
Expand Down Expand Up @@ -210,6 +221,22 @@ public function testPutCompressedFileContent() returns error? {
}
}

@test:Config {}
function testFtpUserDirIsRootTrue() returns error? {
stream<byte[] & readonly, io:Error?>|Error res = (<Client>ftpUserHomeRootClientEp)->get("test1.txt");
if res is Error {
test:assertFail("FTP get failed with userDirIsRoot=true: " + res.message());
}

stream<byte[] & readonly, io:Error?> str = res;
test:assertTrue(check matchStreamContent(str, "File content"), "Expected file content not found when userDirIsRoot=true");

io:Error? closeErr = str.close();
if closeErr is io:Error {
test:assertFail("Error closing stream: " + closeErr.message());
}
}

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

### Fixed
- [Fix the issue where the FTP listener stops working if the file name is the same](https://github.com/ballerina-platform/ballerina-library/issues/8035)
- [Added support for `userDirIsRoot` configuration in FTP/SFTP clients and listeners to handle jailed home directory environments](https://github.com/ballerina-platform/ballerina-library/issues/8153)

## [2.13.1] - 2025-04-23

Expand Down
24 changes: 19 additions & 5 deletions docs/spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
_Owners_: @shafreenAnfar @dilanSachi @Bhashinee
_Reviewers_: @shafreenAnfar @Bhashinee
_Created_: 2020/10/28
_Updated_: 2022/12/08
_Updated_: 2025/08/13
_Edition_: Swan Lake

## Introduction
Expand Down Expand Up @@ -144,6 +144,11 @@ public type ClientConfiguration record {|
int port = 21;
# Authentication options
AuthConfiguration auth?;
# If set to `true`, treats the login home directory as the root (`/`) and
# prevents the underlying VFS from attempting to change to the actual server root.
# If `false`, treats the actual server root as `/`, which may cause a `CWD /` command
# that can fail on servers restricting root access (e.g., chrooted environments).
boolean userDirIsRoot = false;
|};
```
* InputContent record represents the configurations for the input given for `put` and `append` operations.
Expand Down Expand Up @@ -194,7 +199,8 @@ ftp:ClientConfiguration ftpConfig = {
username: "<The FTP username>",
password: "<The FTP passowrd>"
}
}
},
userDirIsRoot: true
};
```
### 3.3. Functions
Expand Down Expand Up @@ -345,6 +351,11 @@ public type ListenerConfiguration record {|
string fileNamePattern?;
# Periodic time interval to check new update
decimal pollingInterval = 60;
# If set to `true`, treats the login home directory as the root (`/`) and
# prevents the underlying VFS from attempting to change to the actual server root.
# If `false`, treats the actual server root as `/`, which may cause a `CWD /` command
# that can fail on servers restricting root access (e.g., chrooted environments).
boolean userDirIsRoot = false;
|};
```
* `WatchEvent` record represents the latest status change of the server from the last status change.
Expand Down Expand Up @@ -384,7 +395,8 @@ ftp:ListenerConfiguration ftpConfig = {
path: "<The private key file path>",
password: "<The private key file password>"
}
}
},
userDirIsRoot: true
};
```
#### 4.3. Usage
Expand Down Expand Up @@ -646,7 +658,8 @@ ftp:ClientConfiguration sftpClientConfig = {
protocol: ftp:SFTP,
host: "ftp.example.com",
port: 21,
auth: authConfig
auth: authConfig,
userDirIsRoot: true
};

public function main() returns error? {
Expand Down Expand Up @@ -681,7 +694,8 @@ listener ftp:Listener remoteServer = check new({
port: 21,
path: "/home/in",
pollingInterval: 2,
fileNamePattern: "(.*).txt"
fileNamePattern: "(.*).txt",
userDirIsRoot: true
});

service on remoteServer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public static Object initClientEndpoint(BObject clientEndpoint, BMap<Object, Obj
FtpUtil.extractPortValue(config.getIntValue(StringUtils.fromString(
FtpConstants.ENDPOINT_CONFIG_PORT))));
clientEndpoint.addNativeData(FtpConstants.ENDPOINT_CONFIG_PROTOCOL, protocol);
Map<String, String> ftpConfig = new HashMap<>(5);
Map<String, String> ftpConfig = new HashMap<>(6);
BMap auth = config.getMapValue(StringUtils.fromString(FtpConstants.ENDPOINT_CONFIG_AUTH));
if (auth != null) {
final BMap privateKey = auth.getMapValue(StringUtils.fromString(
Expand All @@ -96,7 +96,8 @@ public static Object initClientEndpoint(BObject clientEndpoint, BMap<Object, Obj
ftpConfig.put(ENDPOINT_CONFIG_PREFERRED_METHODS, FtpUtil.getPreferredMethodsFromAuthConfig(auth));
}
ftpConfig.put(FtpConstants.PASSIVE_MODE, String.valueOf(true));
ftpConfig.put(FtpConstants.USER_DIR_IS_ROOT, String.valueOf(false));
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));
String url;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ private static Map<String, String> getServerConnectorParamMap(BMap serviceEndpoi
}
params.put(ENDPOINT_CONFIG_PREFERRED_METHODS, FtpUtil.getPreferredMethodsFromAuthConfig(auth));
}
params.put(FtpConstants.USER_DIR_IS_ROOT, String.valueOf(false));
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));
return params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ private static void setFtpOptions(Map<String, String> options, FileSystemOptions
configBuilder.setPassiveMode(opts, Boolean.parseBoolean(options.get(FtpConstants.PASSIVE_MODE)));
}
if (options.get(FtpConstants.USER_DIR_IS_ROOT) != null) {
configBuilder.setUserDirIsRoot(opts, Boolean.parseBoolean(FtpConstants.USER_DIR_IS_ROOT));
configBuilder.setUserDirIsRoot(opts, Boolean.parseBoolean(options.get(FtpConstants.USER_DIR_IS_ROOT)));
}
}

Expand All @@ -85,9 +85,8 @@ private static void setSftpOptions(Map<String, String> options, FileSystemOption
final SftpFileSystemConfigBuilder configBuilder = SftpFileSystemConfigBuilder.getInstance();
String value = options.get(ENDPOINT_CONFIG_PREFERRED_METHODS);
configBuilder.setPreferredAuthentications(opts, value);
if (options.get(FtpConstants.USER_DIR_IS_ROOT) != null) {
configBuilder.setUserDirIsRoot(opts, false);
}
boolean userDirIsRoot = Boolean.parseBoolean(options.get(FtpConstants.USER_DIR_IS_ROOT));
configBuilder.setUserDirIsRoot(opts, userDirIsRoot);
if (options.get(FtpConstants.IDENTITY) != null) {
IdentityInfo identityInfo;
if (options.containsKey(IDENTITY_PASS_PHRASE)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ private FtpConstants() {
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";
public static final BString USER_DIR_IS_ROOT_FIELD = StringUtils.fromString("userDirIsRoot");
public static final String DESTINATION = "destination";
public static final String IDENTITY = "IDENTITY";
public static final String IDENTITY_PASS_PHRASE = "IDENTITY_PASS_PHRASE";
Expand Down
Loading