From a1ae17c8cbc9f9784d19e25d36904c9cae77ebd3 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Mon, 31 Aug 2026 12:03:57 +0200 Subject: [PATCH 1/2] lib.Uri: Fix __assemble(): No '://' for relative paths Assembling a scheme-bearing form always writes '://' after the scheme, even if the input has no authority part. For a schemeless relative path such as '../../local/path', .full then becomes 'file://../../local/path', which re-parses with '..' as the host and '/../local/path' as the path, both entirely broken. Fix __assemble() to write a bare ':' instead of '://' when the input has no host and a relative path, so the assembled form re-parses back to the same relative path: full of '../../local/path' is now 'file:../../local/path'. Absolute paths and forms with an authority are unchanged. The assembled 'file:../../local/path' form is a valid URI under the RFC 3986 generic grammar (scheme with a rootless path), while RFC 8089's file URI ABNF admits only an empty or an absolute path. Uri thus trades RFC 8089 conformance for RFC 3986 compatibility: a relative path survives the full-and-reparse cycle unchanged. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/Uri.py | 7 ++++++- test/unit/python/jw/pkg/lib/Uri/test.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/Uri.py b/src/python/jw/pkg/lib/Uri.py index cada582e..422ef1d8 100644 --- a/src/python/jw/pkg/lib/Uri.py +++ b/src/python/jw/pkg/lib/Uri.py @@ -18,7 +18,12 @@ class Uri: if self.__p.path.startswith('~'): return self.path if scheme: - ret += f'{self.protocol}://' + if self.hostname is None and not self.path.startswith('/'): + # -- No authority and a relative path: '://' would + # re-parse the first path segment as a host + ret += f'{self.protocol}:' + else: + ret += f'{self.protocol}://' if credentials and self.username: ret += self.username if self.password: diff --git a/test/unit/python/jw/pkg/lib/Uri/test.py b/test/unit/python/jw/pkg/lib/Uri/test.py index a435b8b2..dc68b84f 100644 --- a/test/unit/python/jw/pkg/lib/Uri/test.py +++ b/test/unit/python/jw/pkg/lib/Uri/test.py @@ -21,6 +21,23 @@ assert '' in u.safe_full_with_username # safe version hides password u = Uri('/local/path') assert u.scheme == 'file' assert u.protocol == 'file' +assert u.full == 'file:///local/path' + +# Relative paths default to file, too +u = Uri('../../local/path') +assert u.scheme == 'file' +assert u.protocol == 'file' +assert u.path == '../../local/path' + +# full keeps relative paths relative: '://' would re-parse the +# first segment as a host +assert u.full == 'file:../../local/path' +assert Uri(u.full).path == '../../local/path' + +# An explicit file: scheme with a relative path round-trips, too +u = Uri('file:../../local/path') +assert u.path == '../../local/path' +assert u.full == 'file:../../local/path' # Pimp returns existing Uri unchanged u1 = Uri('ssh://host/path') -- 2.55.0 From e8ade91f215b086620913e9245b1bfea6ae5a689 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 06:56:06 +0200 Subject: [PATCH 2/2] lib.Uri: Fix path join for empty authority __new_with_path() joins base and path with exactly one '/', assuming a trailing '/' in base is a path separator. That assumption breaks for URIs with empty authority, where scheme_plus_authority ends in '://' (e.g. 'file:///tmp/x'): new_replace_path('/etc/hosts') drops the leading slash of the path and produces 'file://etc/hosts', which parses with hostname 'etc' and path '/hosts' instead of path '/etc/hosts'. Treat a base ending in '://' separately and keep a leading slash in the path, adding one if it is missing. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/Uri.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/python/jw/pkg/lib/Uri.py b/src/python/jw/pkg/lib/Uri.py index 422ef1d8..51534410 100644 --- a/src/python/jw/pkg/lib/Uri.py +++ b/src/python/jw/pkg/lib/Uri.py @@ -161,6 +161,12 @@ class Uri: ret.__password = None if not path: return ret + if ret.__string.endswith('://'): + # -- Empty authority: the trailing '/' is part of the '://' + # separator, so a leading slash in path must be kept to stay + # an absolute path + ret.__string += path if path.startswith('/') else '/' + path + return ret if ret.__string[-1] == '/': if path[0] == '/': ret.__string += path[1:] -- 2.55.0