From 39ac734b9c89ef330fc2ffe98c543cbc5b631a87 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sat, 30 May 2026 01:57:01 +0200 Subject: [PATCH] support "rest:" repository URLs, fixes #9593 That is borgstore's REST http over stdio (over ssh, if a host is given). --- docs/usage/general/repository-urls.rst.inc | 8 ++- src/borg/archiver/_common.py | 2 +- src/borg/helpers/parseformat.py | 23 ++++++- .../testsuite/helpers/parseformat_test.py | 65 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/usage/general/repository-urls.rst.inc b/docs/usage/general/repository-urls.rst.inc index 238598bd5..a53bb4de9 100644 --- a/docs/usage/general/repository-urls.rst.inc +++ b/docs/usage/general/repository-urls.rst.inc @@ -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 diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py index 7aba725e2..c2642155c 100644 --- a/src/borg/archiver/_common.py +++ b/src/borg/archiver/_common.py @@ -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) diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index d70eb5781..c8b6c417b 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -552,6 +552,19 @@ class Location: re.VERBOSE, ) + # REST http via stdio (via ssh, if host given): + rest_re = re.compile( + r"(?P(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 ''}" diff --git a/src/borg/testsuite/helpers/parseformat_test.py b/src/borg/testsuite/helpers/parseformat_test.py index 0c9281f08..0d6ab162c 100644 --- a/src/borg/testsuite/helpers/parseformat_test.py +++ b/src/borg/testsuite/helpers/parseformat_test.py @@ -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 (