[GH-ISSUE #3095] [Linux] Double URL encoding when opening exported file in a path containing Cyrillic characters #1435

Open
opened 2026-10-13 20:42:28 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @Spiceman161 on GitHub (Aug 29, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3095

Describe the bug

On Linux, exporting a configuration to a directory whose path contains
Cyrillic characters succeeds, but immediately afterwards an error dialog
appears:

"Could not open URI"
"No such file or directory"

The exported file itself is created successfully.

The URI passed to the system is percent-encoded twice.

For example, instead of:

file:///home/spiceman/%D0%94%D0%BE...

AmneziaVPN tries to open:

file:///home/spiceman/%25D0%2594%25D0%25BE...

So % in the already percent-encoded UTF-8 path is encoded again as %25.

Steps to reproduce

  1. Run AmneziaVPN on Linux.
  2. Export an AWG configuration.
  3. Select a directory containing Cyrillic characters, for example:
    ~/Документы/AWG_configs
  4. Save the configuration.
  5. The file is successfully created.
  6. An "Unable to open URI" dialog appears.

Expected behavior

The exported file/location should be opened without an error.

Actual behavior

The file is saved correctly, but opening its location fails because the
file:// URI is percent-encoded twice.

Environment

OS: Linux Mint
AmneziaVPN: 5.0.1.5
Architecture: x86_64

Image
Originally created by @Spiceman161 on GitHub (Aug 29, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3095 Describe the bug On Linux, exporting a configuration to a directory whose path contains Cyrillic characters succeeds, but immediately afterwards an error dialog appears: "Could not open URI" "No such file or directory" The exported file itself is created successfully. The URI passed to the system is percent-encoded twice. For example, instead of: file:///home/spiceman/%D0%94%D0%BE... AmneziaVPN tries to open: file:///home/spiceman/%25D0%2594%25D0%25BE... So `%` in the already percent-encoded UTF-8 path is encoded again as `%25`. Steps to reproduce 1. Run AmneziaVPN on Linux. 2. Export an AWG configuration. 3. Select a directory containing Cyrillic characters, for example: ~/Документы/AWG_configs 4. Save the configuration. 5. The file is successfully created. 6. An "Unable to open URI" dialog appears. Expected behavior The exported file/location should be opened without an error. Actual behavior The file is saved correctly, but opening its location fails because the file:// URI is percent-encoded twice. Environment OS: Linux Mint AmneziaVPN: 5.0.1.5 Architecture: x86_64 <img width="769" height="237" alt="Image" src="https://github.com/user-attachments/assets/37d6017d-3e8e-46df-a1a2-4fbfa393cbb5" />
Author
Owner

@HeisLuka commented on GitHub (Sep 9, 2026):

I checked this against both 5.0.1.5 and current dev.

This is still present, and the source shows a fairly direct local-path / URL boundary bug.

The file itself is written correctly

SystemController::saveFile() first uses the supplied value as a normal filesystem path:

QFile file(fileName);

if (!file.open(QIODevice::WriteOnly)) {
    ...
}

file.write(data);

So a path such as:

/home/user/Документы/AWG_configs/profile.conf

is handled as a filesystem path.

This matches the report:

file is successfully created

The failure happens only in the post-save "open location" step.

Linux then passes a filesystem path to a URL API

After saving, current code does:

QFileInfo fi(fileName);

#ifdef Q_OS_MAC
    const auto url =
        "file://" + fi.absoluteDir().absolutePath();
#else
    const auto url =
        fi.absoluteDir().absolutePath();
#endif

QDesktopServices::openUrl(url);

On Linux this means approximately:

QString:
/home/user/Документы/AWG_configs

is passed directly to:

QDesktopServices::openUrl()

But openUrl() takes a QUrl, not a filesystem path.

The implicit QString -> QUrl conversion therefore crosses the local-file / URL boundary without explicitly declaring that this is a local file.

Qt already provides the intended operation for exactly this case:

QUrl::fromLocalFile(path)

So the canonical construction should conceptually be:

const QUrl url =
    QUrl::fromLocalFile(
        fi.absoluteDir().absolutePath());

QDesktopServices::openUrl(url);

rather than relying on an implicit conversion.

Why this is important for non-ASCII paths

A local filesystem name such as:

Документы

and its URI representation:

%D0%94%D0%BE...

are two representations of the same path, but they belong on opposite sides of the filesystem-path / URI boundary.

The current code does not establish that boundary explicitly.

That allows the platform URL-opening path to reinterpret/encode the value again, which is consistent with the reported:

expected:
%D0%94%D0%BE...

actual:
%25D0%2594%25D0%25BE...

where % from an already encoded representation becomes %25.

I don't think it is necessary to depend on the exact Linux desktop/portal implementation which performs the second encoding.

The Amnezia-side invariant itself is already wrong:

filesystem path
    ->
implicit QString-to-QUrl conversion

instead of:

filesystem path
    ->
QUrl::fromLocalFile()
    ->
URL API

This is still identical in current dev

The relevant SystemController::saveFile() implementation in 5.0.1.5 and current dev is unchanged.

So this issue is not fixed yet.

It is broader than AWG config export

SystemController::saveFile() is a shared helper.

Current code uses it for multiple export paths, including:

VPN/config export
log export
allowed-DNS export
IP split-tunneling export
subscription/key export

Therefore the root cluster is more general:

Linux desktop export
+
non-ASCII / URL-sensitive destination path

rather than specifically:

AWG config export

Any feature using this helper can hit the same post-save location-opening bug.

There is a second, smaller error-handling issue

The return value from:

QDesktopServices::openUrl(url)

is currently ignored.

saveFile() then unconditionally returns:

true

after the file was written.

So the function internally combines two different operations:

A. save file
B. ask desktop to open its directory

but only operation A has meaningful error handling.

That is probably fine if opening the directory is intentionally best-effort, but then the distinction should be explicit.

Conceptually:

FILE_SAVE_SUCCESS
!=
OPEN_LOCATION_SUCCESS

The export should remain successful if operation B fails, because the user's file has already been created.

At minimum an immediate openUrl() rejection can be logged separately.

I would also avoid manually constructing the macOS URL

The macOS branch currently does:

"file://" + fi.absoluteDir().absolutePath()

Even though this issue is reported on Linux, there is no reason to maintain two hand-built representations here.

Using:

QUrl::fromLocalFile(fi.absoluteDir().absolutePath())

for desktop platforms provides one semantic path and avoids similar problems with characters such as:

#
%
?
spaces
non-ASCII characters

Suggested invariant

The useful invariant is:

filesystem strings are never passed directly
to APIs expecting QUrl

Instead:

local path
    ->
QUrl::fromLocalFile()
    ->
QDesktopServices::openUrl()

Regression matrix

  1. ASCII path:
/tmp/Amnezia/export.conf

Expected:
file saved and directory opens.

  1. Cyrillic:
~/Документы/AWG_configs/export.conf

Expected:
exact directory opens; no %25D0....

  1. Spaces:
~/VPN Configs/export.conf
  1. Literal %:
~/VPN 100%/export.conf
  1. #:
~/VPN#1/export.conf

It must remain part of the filesystem name and must not become a URL fragment.

  1. ?:
~/VPN?/export.conf

It must not become a URL query delimiter.

  1. Mixed Unicode:
~/Документы/日本語/é/export.conf
  1. Repeat the matrix for at least:
config export
log export
split-tunnel export

because they share SystemController::saveFile().

So I would classify #3095 as:

confirmed on current dev, with a narrow source-level fix.

The exported file is not corrupted. The bug is in the post-save location-opening path: a local filesystem path is implicitly converted to QUrl instead of being explicitly constructed with QUrl::fromLocalFile().

<!-- gh-comment-id:5608830315 --> @HeisLuka commented on GitHub (Sep 9, 2026): I checked this against both `5.0.1.5` and current `dev`. This is still present, and the source shows a fairly direct local-path / URL boundary bug. ### The file itself is written correctly `SystemController::saveFile()` first uses the supplied value as a normal filesystem path: ```cpp QFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { ... } file.write(data); ``` So a path such as: ```text /home/user/Документы/AWG_configs/profile.conf ``` is handled as a filesystem path. This matches the report: ```text file is successfully created ``` The failure happens only in the post-save "open location" step. ### Linux then passes a filesystem path to a URL API After saving, current code does: ```cpp QFileInfo fi(fileName); #ifdef Q_OS_MAC const auto url = "file://" + fi.absoluteDir().absolutePath(); #else const auto url = fi.absoluteDir().absolutePath(); #endif QDesktopServices::openUrl(url); ``` On Linux this means approximately: ```text QString: /home/user/Документы/AWG_configs ``` is passed directly to: ```cpp QDesktopServices::openUrl() ``` But `openUrl()` takes a `QUrl`, not a filesystem path. The implicit `QString -> QUrl` conversion therefore crosses the local-file / URL boundary without explicitly declaring that this is a local file. Qt already provides the intended operation for exactly this case: ```cpp QUrl::fromLocalFile(path) ``` So the canonical construction should conceptually be: ```cpp const QUrl url = QUrl::fromLocalFile( fi.absoluteDir().absolutePath()); QDesktopServices::openUrl(url); ``` rather than relying on an implicit conversion. ### Why this is important for non-ASCII paths A local filesystem name such as: ```text Документы ``` and its URI representation: ```text %D0%94%D0%BE... ``` are two representations of the same path, but they belong on opposite sides of the filesystem-path / URI boundary. The current code does not establish that boundary explicitly. That allows the platform URL-opening path to reinterpret/encode the value again, which is consistent with the reported: ```text expected: %D0%94%D0%BE... actual: %25D0%2594%25D0%25BE... ``` where `%` from an already encoded representation becomes `%25`. I don't think it is necessary to depend on the exact Linux desktop/portal implementation which performs the second encoding. The Amnezia-side invariant itself is already wrong: ```text filesystem path -> implicit QString-to-QUrl conversion ``` instead of: ```text filesystem path -> QUrl::fromLocalFile() -> URL API ``` ### This is still identical in current dev The relevant `SystemController::saveFile()` implementation in `5.0.1.5` and current `dev` is unchanged. So this issue is not fixed yet. ### It is broader than AWG config export `SystemController::saveFile()` is a shared helper. Current code uses it for multiple export paths, including: ```text VPN/config export log export allowed-DNS export IP split-tunneling export subscription/key export ``` Therefore the root cluster is more general: ```text Linux desktop export + non-ASCII / URL-sensitive destination path ``` rather than specifically: ```text AWG config export ``` Any feature using this helper can hit the same post-save location-opening bug. ### There is a second, smaller error-handling issue The return value from: ```cpp QDesktopServices::openUrl(url) ``` is currently ignored. `saveFile()` then unconditionally returns: ```cpp true ``` after the file was written. So the function internally combines two different operations: ```text A. save file B. ask desktop to open its directory ``` but only operation A has meaningful error handling. That is probably fine if opening the directory is intentionally best-effort, but then the distinction should be explicit. Conceptually: ```text FILE_SAVE_SUCCESS != OPEN_LOCATION_SUCCESS ``` The export should remain successful if operation B fails, because the user's file has already been created. At minimum an immediate `openUrl()` rejection can be logged separately. ### I would also avoid manually constructing the macOS URL The macOS branch currently does: ```cpp "file://" + fi.absoluteDir().absolutePath() ``` Even though this issue is reported on Linux, there is no reason to maintain two hand-built representations here. Using: ```cpp QUrl::fromLocalFile(fi.absoluteDir().absolutePath()) ``` for desktop platforms provides one semantic path and avoids similar problems with characters such as: ```text # % ? spaces non-ASCII characters ``` ### Suggested invariant The useful invariant is: ```text filesystem strings are never passed directly to APIs expecting QUrl ``` Instead: ```text local path -> QUrl::fromLocalFile() -> QDesktopServices::openUrl() ``` ### Regression matrix 1. ASCII path: ```text /tmp/Amnezia/export.conf ``` Expected: file saved and directory opens. 2. Cyrillic: ```text ~/Документы/AWG_configs/export.conf ``` Expected: exact directory opens; no `%25D0...`. 3. Spaces: ```text ~/VPN Configs/export.conf ``` 4. Literal `%`: ```text ~/VPN 100%/export.conf ``` 5. `#`: ```text ~/VPN#1/export.conf ``` It must remain part of the filesystem name and must not become a URL fragment. 6. `?`: ```text ~/VPN?/export.conf ``` It must not become a URL query delimiter. 7. Mixed Unicode: ```text ~/Документы/日本語/é/export.conf ``` 8. Repeat the matrix for at least: ```text config export log export split-tunnel export ``` because they share `SystemController::saveFile()`. So I would classify #3095 as: **confirmed on current dev, with a narrow source-level fix.** The exported file is not corrupted. The bug is in the post-save location-opening path: **a local filesystem path is implicitly converted to `QUrl` instead of being explicitly constructed with `QUrl::fromLocalFile()`.**
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amnezia-client#1435
No description provided.