support "rest:" repository URLs, fixes #9593

That is borgstore's REST http over stdio (over ssh, if a host is given).
This commit is contained in:
Thomas Waldmann 2026-05-30 01:57:01 +02:00
parent 4d9369f897
commit 39ac734b9c
No known key found for this signature in database
GPG key ID: 243ACFA951F78E01
4 changed files with 95 additions and 3 deletions

View file

@ -12,7 +12,13 @@ expanded by your shell).
Note: You may also prepend ``file://`` to a filesystem path to use URL style.
**Remote repositories** accessed via SSH user@host:
**Remote repositories** accessed via SSH user@host (REST http over stdio):
``rest://user@host:port//abs/path/to/repo`` — absolute path
``rest://user@host:port/rel/path/to/repo`` path relative to the current directory
**Remote repositories** accessed via SSH user@host (legacy borg RPC protocol):
``ssh://user@host:port//abs/path/to/repo`` — absolute path

View file

@ -42,7 +42,7 @@ def get_repository(location, *, create, exclusive, lock_wait, lock, args, v1_leg
)
elif (
location.proto in ("sftp", "file", "http", "https", "rclone", "s3", "b2") and not v1_legacy
location.proto in ("rest", "sftp", "file", "http", "https", "rclone", "s3", "b2") and not v1_legacy
): # stuff directly supported by borgstore
repository = Repository(location, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock)

View file

@ -552,6 +552,19 @@ class Location:
re.VERBOSE,
)
# REST http via stdio (via ssh, if host given):
rest_re = re.compile(
r"(?P<proto>(rest))://"
+ r"("
+ optional_user_re
+ host_re
+ optional_port_re
+ r")?"
+ r"/" # this is the separator, not part of the path!
+ abs_or_rel_path_re,
re.VERBOSE,
)
# BorgStore REST server
# (http|https)://user:pass@host:port/
http_re = re.compile(
@ -624,6 +637,14 @@ class Location:
def _parse(self, text):
m = self.ssh_or_sftp_re.match(text)
if m:
self.proto = m.group("proto")
self.user = m.group("user")
self._host = m.group("host")
self.port = m.group("port") and int(m.group("port")) or None
self.path = os.path.normpath(m.group("path"))
return True
m = self.rest_re.match(text)
if m:
self.proto = m.group("proto")
self.user = m.group("user")
@ -692,7 +713,7 @@ class Location:
return self.path
if self.proto == "rclone":
return f"{self.proto}:{self.path}"
if self.proto in ("sftp", "ssh", "s3", "b2", "http", "https"):
if self.proto in ("rest", "sftp", "ssh", "s3", "b2", "http", "https"):
return (
f"{self.proto}://"
f"{(self.user + '@') if self.user else ''}"

View file

@ -133,6 +133,71 @@ class TestLocationWithoutEnv:
"host='2a02:0001:0002:0003:0004:0005:0006:0007', port=1234, path='relative/path')"
)
def test_rest(self, monkeypatch):
monkeypatch.delenv("BORG_REPO", raising=False)
assert (
repr(Location("rest://user@host:1234//absolute/path"))
== "Location(proto='rest', user='user', pass=None, host='host', port=1234, path='/absolute/path')"
)
assert (
repr(Location("rest://user@host:1234/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='host', port=1234, path='relative/path')"
)
assert (
repr(Location("rest://user@host/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='host', port=None, path='relative/path')"
)
assert (
repr(Location("rest://user@[::]:1234/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='::', port=1234, path='relative/path')"
)
assert (
repr(Location("rest://user@[::]/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='::', port=None, path='relative/path')"
)
assert (
repr(Location("rest://user@[2001:db8::]:1234/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='2001:db8::', port=1234, path='relative/path')"
)
assert (
repr(Location("rest://user@[2001:db8::]/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='2001:db8::', port=None, path='relative/path')"
)
assert (
repr(Location("rest://user@[2001:db8::c0:ffee]:1234/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='2001:db8::c0:ffee', port=1234, path='relative/path')" # noqa: E501
)
assert (
repr(Location("rest://user@[2001:db8::c0:ffee]/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='2001:db8::c0:ffee', port=None, path='relative/path')" # noqa: E501
)
assert (
repr(Location("rest://user@[2001:db8::192.0.2.1]:1234/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='2001:db8::192.0.2.1', port=1234, path='relative/path')" # noqa: E501
)
assert (
repr(Location("rest://user@[2001:db8::192.0.2.1]/relative/path"))
== "Location(proto='rest', user='user', pass=None, host='2001:db8::192.0.2.1', port=None, path='relative/path')" # noqa: E501
)
assert (
repr(Location("rest://user@[2a02:0001:0002:0003:0004:0005:0006:0007]/relative/path"))
== "Location(proto='rest', user='user', pass=None, "
"host='2a02:0001:0002:0003:0004:0005:0006:0007', port=None, path='relative/path')"
)
assert (
repr(Location("rest://user@[2a02:0001:0002:0003:0004:0005:0006:0007]:1234/relative/path"))
== "Location(proto='rest', user='user', pass=None, "
"host='2a02:0001:0002:0003:0004:0005:0006:0007', port=1234, path='relative/path')"
)
assert (
repr(Location("rest:///relative/path"))
== "Location(proto='rest', user=None, pass=None, host=None, port=None, path='relative/path')"
)
assert (
repr(Location("rest:////absolute/path"))
== "Location(proto='rest', user=None, pass=None, host=None, port=None, path='/absolute/path')"
)
def test_s3(self, monkeypatch):
monkeypatch.delenv("BORG_REPO", raising=False)
assert (