git-remote-rns 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathaniel "Eeems" van Diepen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-remote-rns
3
+ Version: 0.0.1
4
+ Summary: Git remote helper for syncing repositories over Reticulum
5
+ Author: Eeems
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: rns>=1.1.4
11
+ Requires-Dist: lxmf>=0.9.4
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: prospector[with_everything]; extra == "dev"
15
+ Requires-Dist: basedpyright; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ git-remote-rns
19
+ ==============
20
+
21
+ Reticulum remote transport for git
@@ -0,0 +1,4 @@
1
+ git-remote-rns
2
+ ==============
3
+
4
+ Reticulum remote transport for git
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-remote-rns
3
+ Version: 0.0.1
4
+ Summary: Git remote helper for syncing repositories over Reticulum
5
+ Author: Eeems
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: rns>=1.1.4
11
+ Requires-Dist: lxmf>=0.9.4
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: prospector[with_everything]; extra == "dev"
15
+ Requires-Dist: basedpyright; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ git-remote-rns
19
+ ==============
20
+
21
+ Reticulum remote transport for git
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ git_remote_rns.egg-info/PKG-INFO
5
+ git_remote_rns.egg-info/SOURCES.txt
6
+ git_remote_rns.egg-info/dependency_links.txt
7
+ git_remote_rns.egg-info/entry_points.txt
8
+ git_remote_rns.egg-info/requires.txt
9
+ git_remote_rns.egg-info/top_level.txt
10
+ rngit/__init__.py
11
+ rngit/__main__.py
12
+ rngit/client.py
13
+ rngit/server.py
14
+ rngit/shared.py
15
+ tests/test_integration.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ git-remote-rns = rngit.__main__:client
3
+ rngit = rngit.__main__:server
@@ -0,0 +1,7 @@
1
+ rns>=1.1.4
2
+ lxmf>=0.9.4
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ prospector[with_everything]
7
+ basedpyright
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "git-remote-rns"
3
+ version = "0.0.1"
4
+ description = "Git remote helper for syncing repositories over Reticulum"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{name = "Eeems"}]
9
+ dependencies = ["rns>=1.1.4", "lxmf>=0.9.4"]
10
+
11
+ [project.optional-dependencies]
12
+ dev = ["pytest>=7.0", "prospector[with_everything]", 'basedpyright']
13
+
14
+ [project.scripts]
15
+ git-remote-rns = "rngit.__main__:client"
16
+ rngit = "rngit.__main__:server"
17
+
18
+ [build-system]
19
+ requires = ["setuptools>=70.1", "nuitka>=4.0.6"]
20
+ build-backend = "nuitka.distutils.Build"
21
+
22
+ [tool.setuptools]
23
+ packages = ["rngit"]
24
+
25
+ [tool.ruff]
26
+ exclude = [".venv", "typings", "build"]
27
+
28
+ [tool.pyright]
29
+ exclude = [".venv/**", "typings/**", "build/**"]
30
+ reportMissingTypeStubs = false
31
+
32
+ [tool.pylint."messages control"]
33
+ disable = [
34
+ "line-too-long",
35
+ "invalid-name",
36
+ "global-statement",
37
+ "broad-exception-caught",
38
+ "too-many-statements",
39
+ "too-many-return-statements",
40
+ "too-many-branches",
41
+ "too-many-locals"
42
+ ]
43
+
44
+ [tool.bandit]
45
+ exclude_dirs = [".venv", "typings", "build"]
@@ -0,0 +1,10 @@
1
+ from importlib.metadata import (
2
+ PackageNotFoundError,
3
+ version,
4
+ )
5
+
6
+ try:
7
+ __version__ = version("git-remote-rns")
8
+
9
+ except PackageNotFoundError:
10
+ __version__ = "0.1.0"
@@ -0,0 +1,15 @@
1
+ import sys
2
+
3
+ from .client import main as _client
4
+ from .server import main as _server
5
+
6
+
7
+ def client():
8
+ sys.exit(_client())
9
+
10
+
11
+ def server():
12
+ sys.exit(_server())
13
+
14
+
15
+ __all__ = ["client", "server"]
@@ -0,0 +1,322 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ import signal
5
+ import subprocess # noqa: B404
6
+ import sys
7
+ import threading
8
+ import traceback
9
+ from collections.abc import Sequence
10
+ from tempfile import TemporaryDirectory
11
+ from typing import cast
12
+
13
+ import RNS
14
+
15
+ from . import __version__
16
+ from .shared import (
17
+ APP_NAME,
18
+ configure_logging,
19
+ is_valid_hexhash,
20
+ packets,
21
+ )
22
+
23
+ __all__ = [
24
+ "main",
25
+ ]
26
+
27
+ log: logging.Logger = logging.getLogger(__name__)
28
+
29
+ _linkEvent: threading.Event = threading.Event()
30
+ _identity: RNS.Identity | None = None
31
+ _repo_path: str | None = None
32
+
33
+
34
+ def on_link_established(link: RNS.Link):
35
+ global _identity # pylint: disable=W0602 # noqa: F999
36
+ assert _identity is not None # nosec B101
37
+ log.debug("ESTABLISHED: %s", link)
38
+ link.set_packet_callback(on_packet) # pyright: ignore[reportUnknownMemberType]
39
+ _ = link.identify(_identity) # pyright: ignore[reportUnknownMemberType]
40
+
41
+
42
+ def on_link_closed(link: RNS.Link):
43
+ global _linkEvent # pylint: disable=W0602 # noqa: F999
44
+ log.debug("CLOSED: %s", link)
45
+ _linkEvent.clear()
46
+
47
+
48
+ def on_packet(message: bytes, _packet: RNS.Packet):
49
+ global _linkEvent # pylint: disable=W0602 # noqa: F999
50
+ log.debug("PACKET: %s", message)
51
+ match message:
52
+ case packets.PACKET_IDENTIFIED.value:
53
+ _linkEvent.set()
54
+
55
+ case _:
56
+ log.error("Invalid packet: %d", message)
57
+
58
+
59
+ def request(
60
+ link: RNS.Link, path: str, data: bytes = b""
61
+ ) -> tuple[str | None, bytes | None]:
62
+ global _repo_path # pylint: disable=W0602 # noqa: F999
63
+ assert _repo_path is not None # nosec B101
64
+ event = threading.Event()
65
+ log.debug("REQUEST %s", path)
66
+ receipt = link.request( # pyright: ignore[reportUnknownMemberType]
67
+ path,
68
+ _repo_path.encode() + b"\n" + data,
69
+ response_callback=lambda _, e=event: e.set(), # pyright: ignore[reportUnknownLambdaType]
70
+ failed_callback=lambda _, e=event: e.set(), # pyright: ignore[reportUnknownLambdaType]
71
+ )
72
+ if not receipt:
73
+ return "Failed to send request", None
74
+
75
+ _ = event.wait()
76
+ match receipt.get_status():
77
+ case RNS.RequestReceipt.FAILED:
78
+ return "Failed to send request", None
79
+
80
+ case RNS.RequestReceipt.READY:
81
+ data = receipt.get_response() # pyright: ignore[reportUnknownVariableType, reportAssignmentType]
82
+ assert isinstance(data, bytes) # nosec B101
83
+ returncode = int.from_bytes(data[0:1], "big")
84
+ if returncode:
85
+ return "Remote error: " + data[1:].decode(), None
86
+
87
+ return None, data[1:]
88
+
89
+ case _:
90
+ return f"Invalid status: {receipt.get_status()}", None
91
+
92
+
93
+ def main(argv: Sequence[str] | None = None) -> int: # noqa: MC0001
94
+ parser = argparse.ArgumentParser(prog="git-remote-rns")
95
+ _ = parser.add_argument("remote", help="Remote name (ignored)")
96
+ _ = parser.add_argument("url", help="Remote URL (<hash>[/path])")
97
+ _ = parser.add_argument(
98
+ "--version", action="version", version=f"git-remote-rns {__version__}"
99
+ )
100
+ _ = parser.add_argument(
101
+ "-i", "--identity", help="Path identity file", dest="identity"
102
+ )
103
+ _ = parser.add_argument(
104
+ "-v",
105
+ "--verbose",
106
+ action="store_true",
107
+ help="Enable verbose logging",
108
+ dest="verbose",
109
+ )
110
+ args = parser.parse_args(argv)
111
+
112
+ assert isinstance(args.identity, str | None) # pyright: ignore[reportAny] # nosec B101
113
+ identity_path = args.identity
114
+
115
+ assert isinstance(args.verbose, bool) # pyright: ignore[reportAny] # nosec B101
116
+ verbose = args.verbose or bool(os.environ.get("VERBOSE", 0))
117
+ configure_logging(logging.DEBUG if verbose else logging.WARNING)
118
+
119
+ assert isinstance(args.url, str) # pyright: ignore[reportAny] # nosec B101
120
+ url = args.url
121
+ parts = url.split("/", 1)
122
+ destination_hexhash = parts[0]
123
+ if not is_valid_hexhash(destination_hexhash):
124
+ log.error("error: Invalid URL. Hexhash invalid: %s", destination_hexhash)
125
+ return 1
126
+
127
+ destination = bytes.fromhex(destination_hexhash)
128
+
129
+ global _repo_path
130
+ _repo_path = parts[1] if len(parts) > 1 else "."
131
+
132
+ config_path = os.environ.get("RNS_CONFIG_PATH", None)
133
+ _ = RNS.Reticulum(config_path, RNS.LOG_VERBOSE if verbose else RNS.LOG_WARNING)
134
+
135
+ assert RNS.Reticulum.configdir is not None # pyright: ignore[reportUnknownMemberType] # nosec B101
136
+ if identity_path is None:
137
+ identity_path = os.path.join(RNS.Reticulum.configdir, "identity") # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
138
+
139
+ assert identity_path is not None # nosec B101
140
+ log.info("Identity: %s", identity_path)
141
+ log.info("Destination: %s", destination_hexhash)
142
+ identity: RNS.Identity | None = None
143
+ if os.path.exists(identity_path):
144
+ identity = RNS.Identity.from_file(identity_path) # pyright: ignore[reportUnknownMemberType]
145
+
146
+ if identity is None:
147
+ identity = RNS.Identity(True)
148
+ _ = identity.to_file(identity_path) # pyright: ignore[reportUnknownMemberType]
149
+
150
+ global _identity
151
+ _identity = identity
152
+
153
+ if not RNS.Transport.has_path(destination): # pyright: ignore[reportUnknownMemberType]
154
+ RNS.Transport.request_path(destination) # pyright: ignore[reportUnknownMemberType]
155
+ if not RNS.Transport.await_path(destination, 30): # pyright: ignore[reportUnknownMemberType]
156
+ log.error("Timed out waiting for path")
157
+ return 1
158
+
159
+ server_identity = RNS.Identity.recall(destination) # pyright: ignore[reportUnknownMemberType]
160
+ if server_identity is None:
161
+ log.error("Failed to get server identity")
162
+ return 1
163
+
164
+ server_destination = RNS.Destination(
165
+ server_identity,
166
+ RNS.Destination.OUT,
167
+ RNS.Destination.SINGLE,
168
+ APP_NAME,
169
+ )
170
+ link = RNS.Link(server_destination, on_link_established, on_link_closed)
171
+ push_queue: list[tuple[str, str]] = []
172
+ global _linkEvent # pylint: disable=W0602 # noqa: F999
173
+ try: # pylint: disable=too-many-nested-blocks
174
+ for line in sys.stdin:
175
+ _ = _linkEvent.wait()
176
+ if not line:
177
+ continue
178
+
179
+ log.debug("STDIN '%s'", line.rstrip())
180
+
181
+ parts = cast(list[str], line.split(maxsplit=1))
182
+ assert isinstance(parts, list) # nosec B101
183
+ if not parts:
184
+ log.debug("\\n")
185
+ while push_queue:
186
+ local_ref, remote_ref = push_queue.pop(0)
187
+ if local_ref.startswith("+"):
188
+ local_ref = local_ref[1:]
189
+
190
+ if not local_ref:
191
+ err, data = request(
192
+ link,
193
+ "delete",
194
+ remote_ref.encode(),
195
+ )
196
+ if err is not None:
197
+ _ = sys.stderr.write(err)
198
+ _ = sys.stderr.write("\n")
199
+ _ = sys.stderr.flush()
200
+ return 1
201
+
202
+ if data:
203
+ _ = sys.stderr.buffer.write(data)
204
+ _ = sys.stderr.buffer.write(b"\n")
205
+ _ = sys.stderr.flush()
206
+
207
+ else:
208
+ with TemporaryDirectory() as tmpdir:
209
+ bundle = os.path.join(tmpdir, "bundle")
210
+ _ = subprocess.check_call( # nosec B607 B603
211
+ [
212
+ "git",
213
+ "bundle",
214
+ "create",
215
+ "--progress",
216
+ bundle,
217
+ local_ref,
218
+ ]
219
+ )
220
+ with open(bundle, "rb") as f:
221
+ data = f.read()
222
+
223
+ err, data = request(
224
+ link,
225
+ "push",
226
+ f"{local_ref}:{remote_ref}\n".encode() + data,
227
+ )
228
+ if err is not None:
229
+ _ = sys.stderr.write(err)
230
+ _ = sys.stderr.write("\n")
231
+ _ = sys.stderr.flush()
232
+ return 1
233
+
234
+ if data:
235
+ _ = sys.stderr.buffer.write(data)
236
+ _ = sys.stderr.buffer.write(b"\n")
237
+ _ = sys.stderr.flush()
238
+
239
+ _ = sys.stdout.write("\n")
240
+ try:
241
+ _ = sys.stdout.flush()
242
+
243
+ except BrokenPipeError:
244
+ # Ignoring as git likes to close stdout early
245
+ pass
246
+
247
+ continue
248
+
249
+ match parts[0]:
250
+ case "capabilities":
251
+ log.debug("CAPABILITIES")
252
+ _ = sys.stdout.write("list\n")
253
+ _ = sys.stdout.write("fetch\n")
254
+ _ = sys.stdout.write("push\n")
255
+ _ = sys.stdout.write("\n")
256
+ _ = sys.stdout.flush()
257
+
258
+ case "fetch":
259
+ sha, ref = parts[1].rstrip().split(" ", maxsplit=1)
260
+ log.debug("FETCH %s %s", sha, ref)
261
+ err, data = request(link, "fetch", f"{sha} {ref}".encode())
262
+ if err is not None:
263
+ _ = sys.stderr.write(err)
264
+ _ = sys.stderr.write("\n")
265
+ _ = sys.stderr.flush()
266
+ return 1
267
+
268
+ assert data is not None # nosec B101
269
+ with TemporaryDirectory() as tmpdir:
270
+ bundle = os.path.join(tmpdir, f"{sha}.bundle")
271
+ with open(bundle, "wb") as f:
272
+ _ = f.write(data)
273
+
274
+ _ = subprocess.check_call( # nosec B607 B603
275
+ ["git", "bundle", "verify", bundle],
276
+ stdout=subprocess.DEVNULL,
277
+ )
278
+ _ = subprocess.check_call( # nosec B607 B603
279
+ ["git", "bundle", "unbundle", "--progress", bundle, ref],
280
+ stdout=subprocess.DEVNULL,
281
+ )
282
+
283
+ case "push":
284
+ local_ref, remote_ref = parts[1].rstrip().split(":", maxsplit=1)
285
+ log.debug("PUSH %s %s", local_ref, remote_ref)
286
+ push_queue.append((local_ref, remote_ref))
287
+
288
+ case "list":
289
+ log.debug("LIST")
290
+ path = "list"
291
+ if len(parts) > 1 and "for-push" in parts[1]:
292
+ path = "list-for-push"
293
+
294
+ err, data = request(link, path)
295
+ if err is not None:
296
+ _ = sys.stderr.write(err)
297
+ _ = sys.stderr.write("\n")
298
+ _ = sys.stderr.flush()
299
+ return 1
300
+
301
+ assert data is not None # nosec B101
302
+ _ = sys.stdout.buffer.write(data)
303
+ _ = sys.stdout.write("\n")
304
+ _ = sys.stdout.flush()
305
+
306
+ case _:
307
+ _ = sys.stderr.write(f"Unknown command: {parts[1]}\n")
308
+ _ = sys.stderr.flush()
309
+ return 1
310
+
311
+ log.debug("End of stdin")
312
+ _ = signal.signal(signal.SIGPIPE, signal.SIG_DFL)
313
+
314
+ except Exception:
315
+ log.error(traceback.format_exc())
316
+ return 1
317
+
318
+ finally:
319
+ log.debug("Closing link")
320
+ link.teardown()
321
+
322
+ return 0
@@ -0,0 +1,513 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ import subprocess # noqa: B404
5
+ import time
6
+ import traceback
7
+ from collections.abc import Sequence
8
+ from tempfile import TemporaryDirectory
9
+ from typing import cast
10
+
11
+ import RNS
12
+
13
+ from . import __version__
14
+ from .shared import (
15
+ APP_NAME,
16
+ configure_logging,
17
+ is_valid_hexhash,
18
+ packets,
19
+ )
20
+
21
+ __all__ = [
22
+ "main",
23
+ ]
24
+
25
+ log: logging.Logger = logging.getLogger(__name__)
26
+ _repo_path: str | None = None
27
+ _write_list: set[str] = set()
28
+ _read_list: set[str] | None = set()
29
+
30
+
31
+ def on_link_closed(link: RNS.Link):
32
+ log.debug("CLOSED: %s %s", link, link.get_remote_identity()) # pyright: ignore[reportUnknownArgumentType]
33
+
34
+
35
+ def on_link_established(link: RNS.Link):
36
+ try:
37
+ log.debug("ESTABLISHED: %s", link)
38
+ link.set_link_closed_callback(on_link_closed) # pyright: ignore[reportUnknownMemberType]
39
+ link.set_remote_identified_callback(on_identified) # pyright: ignore[reportUnknownMemberType]
40
+
41
+ except Exception:
42
+ traceback.print_exc()
43
+ raise
44
+
45
+
46
+ def on_identified(link: RNS.Link, identity: RNS.Identity):
47
+ try:
48
+ assert link.get_remote_identity() == identity # nosec B101
49
+ _ = RNS.Packet(link, packets.PACKET_IDENTIFIED.value).send()
50
+ log.debug("IDENTIFIED: %s %s", link, identity)
51
+
52
+ except Exception:
53
+ traceback.print_exc()
54
+ raise
55
+
56
+
57
+ def identity_allowed_error(
58
+ identity: RNS.Identity | None, allow_list: set[str]
59
+ ) -> str | None:
60
+ if identity is None:
61
+ return "Not identified"
62
+
63
+ if identity.hexhash not in allow_list:
64
+ return "Not allowed"
65
+
66
+ return None
67
+
68
+
69
+ def read_allowed_error(identity: RNS.Identity | None) -> str | None:
70
+ global _read_list # pylint: disable=W0602 # noqa: F999
71
+ if _read_list is None:
72
+ return None
73
+
74
+ return identity_allowed_error(identity, _read_list)
75
+
76
+
77
+ def write_allowed_error(identity: RNS.Identity | None) -> str | None:
78
+ global _write_list # pylint: disable=W0602 # noqa: F999
79
+ return identity_allowed_error(identity, _write_list)
80
+
81
+
82
+ def request_repo_path(data: bytes) -> tuple[str | None, tuple[str, bytes] | None]:
83
+ global _repo_path # pylint: disable=W0602 # noqa: F999
84
+ try:
85
+ assert isinstance(data, bytes), "data must be bytes" # nosec B101
86
+ assert _repo_path is not None, "_repo_path not set" # nosec B101
87
+ parts = data.split(b"\n", maxsplit=1)
88
+ path = parts[0].decode()
89
+ if ".." in path:
90
+ return "Invalid path", None
91
+
92
+ base_path = os.path.realpath(_repo_path)
93
+ repo_path = os.path.realpath(os.path.join(base_path, path))
94
+ if not repo_path.startswith(base_path):
95
+ return "Invalid path", None
96
+
97
+ if not os.path.exists(repo_path):
98
+ return "Path not Found", None
99
+
100
+ if not os.path.isdir(repo_path):
101
+ return "Path is not directory", None
102
+
103
+ proc = subprocess.run( # nosec B607 B603# nosec B607 B603
104
+ ["git", "rev-parse", "--git-dir"],
105
+ cwd=repo_path,
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.PIPE,
108
+ check=False,
109
+ )
110
+ if proc.returncode:
111
+ return (
112
+ proc.stderr.rstrip().decode()
113
+ or proc.stdout.rstrip().decode()
114
+ or "Unknown error",
115
+ None,
116
+ )
117
+
118
+ git_dir = proc.stdout.rstrip().decode()
119
+ if git_dir not in (".", ".git"):
120
+ return "Path not a valid repository", None
121
+
122
+ return (None, (repo_path, b"" if len(parts) == 1 else parts[1]))
123
+
124
+ except Exception as e:
125
+ traceback.print_exc()
126
+ return str(e), None
127
+
128
+
129
+ def log_request(path: str, repo_path: str, *args: object):
130
+ global _repo_path # pylint: disable=W0602 # noqa: F999
131
+ repo_path = os.path.relpath(repo_path, _repo_path)
132
+ log.debug("REQUEST %s %s %s", path, repo_path, " ".join(f"{f}" for f in args))
133
+
134
+
135
+ def on_list_request(
136
+ path: str,
137
+ data: bytes,
138
+ _request_id: bytes,
139
+ remote_identity: RNS.Identity | None,
140
+ _request_at: float,
141
+ ) -> bytes | None:
142
+ try:
143
+ err = (
144
+ write_allowed_error(remote_identity)
145
+ if path == "list-for-push"
146
+ else read_allowed_error(remote_identity)
147
+ )
148
+ if err is not None:
149
+ return b"\1" + err.encode()
150
+
151
+ err, res = request_repo_path(data)
152
+ if err is not None:
153
+ return b"\1" + err.encode()
154
+
155
+ assert res is not None # nosec B101
156
+ repo_path, data = res
157
+
158
+ log_request(path, repo_path)
159
+ head_path = os.path.join(repo_path, ".git", "HEAD")
160
+ if not os.path.exists(head_path):
161
+ head_path = os.path.join(repo_path, "HEAD")
162
+
163
+ with open(head_path, "rb") as f:
164
+ ref = f.read()[5:].rstrip().decode()
165
+
166
+ proc = subprocess.run( # nosec B607 B603
167
+ ["git", "refs", "list", "--format", "%(objectname) %(refname)"],
168
+ text=False,
169
+ cwd=repo_path,
170
+ stdout=subprocess.PIPE,
171
+ stderr=subprocess.PIPE,
172
+ check=False,
173
+ )
174
+ log.debug("git refs list code: %d", proc.returncode)
175
+ if proc.returncode:
176
+ return proc.returncode.to_bytes(1, "big") + proc.stderr
177
+
178
+ return b"\0" + proc.stdout + f"@{ref} HEAD\n".encode()
179
+
180
+ except Exception as e:
181
+ traceback.print_exc()
182
+ return b"\1" + str(e).encode()
183
+
184
+
185
+ def on_fetch_request(
186
+ path: str,
187
+ data: bytes,
188
+ _request_id: bytes,
189
+ remote_identity: RNS.Identity | None,
190
+ _request_at: float,
191
+ ) -> bytes | None:
192
+ try:
193
+ err = read_allowed_error(remote_identity)
194
+ if err is not None:
195
+ return b"\1" + err.encode()
196
+
197
+ err, res = request_repo_path(data)
198
+ if err is not None:
199
+ return b"\1" + err.encode()
200
+
201
+ assert res is not None # nosec B101
202
+ repo_path, data = res
203
+
204
+ sha, ref = data.decode().split(" ", maxsplit=1)
205
+ log_request(path, repo_path, sha, ref)
206
+ with TemporaryDirectory() as tmpdir:
207
+ bundle = os.path.join(tmpdir, f"{sha}.bundle")
208
+ proc = subprocess.run( # nosec B607 B603
209
+ ["git", "bundle", "create", "--no-progress", bundle, ref],
210
+ cwd=repo_path,
211
+ stdout=subprocess.PIPE,
212
+ stderr=subprocess.PIPE,
213
+ check=False,
214
+ )
215
+ log.debug("git bundle create return code: %d", proc.returncode)
216
+ if proc.returncode:
217
+ return proc.returncode.to_bytes(1, "big") + proc.stderr
218
+
219
+ with open(bundle, "rb") as f:
220
+ return b"\0" + f.read()
221
+
222
+ except Exception as e:
223
+ traceback.print_exc()
224
+ return b"\1" + str(e).encode()
225
+
226
+
227
+ def on_push_request(
228
+ path: str,
229
+ data: bytes,
230
+ _request_id: bytes,
231
+ remote_identity: RNS.Identity | None,
232
+ _request_at: float,
233
+ ) -> bytes | None:
234
+ try:
235
+ err = write_allowed_error(remote_identity)
236
+ if err is not None:
237
+ return b"\1" + err.encode()
238
+
239
+ err, res = request_repo_path(data)
240
+ if err is not None:
241
+ return b"\1" + err.encode()
242
+
243
+ assert res is not None # nosec B101
244
+ repo_path, data = res
245
+
246
+ info, data = data.split(b"\n", maxsplit=1)
247
+ local_ref, remote_ref = info.decode().split(":", maxsplit=1)
248
+ force = local_ref.startswith("+")
249
+ if force:
250
+ local_ref = local_ref[1:]
251
+
252
+ log_request(path, repo_path, local_ref, remote_ref, "(force) " if force else "")
253
+ with TemporaryDirectory() as tmpdir:
254
+ bundle = os.path.join(tmpdir, "bundle")
255
+ with open(bundle, "wb") as f:
256
+ _ = f.write(data)
257
+
258
+ proc = subprocess.run( # nosec B607 B603
259
+ ["git", "bundle", "verify", bundle],
260
+ cwd=repo_path,
261
+ stdout=subprocess.PIPE,
262
+ stderr=subprocess.PIPE,
263
+ check=False,
264
+ )
265
+ log.debug("git bundle verifyreturn code: %d", proc.returncode)
266
+ if proc.returncode:
267
+ return proc.returncode.to_bytes(1, "big") + proc.stderr
268
+
269
+ proc = subprocess.run( # nosec B607 B603
270
+ [
271
+ "git",
272
+ "fetch",
273
+ bundle,
274
+ f"{local_ref}:{remote_ref}",
275
+ *(["--force"] if force else []),
276
+ ],
277
+ cwd=repo_path,
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.PIPE,
280
+ check=False,
281
+ )
282
+ log.debug("git bundle unbundle return code: %d", proc.returncode)
283
+
284
+ return proc.returncode.to_bytes(1, "big") + proc.stderr
285
+
286
+ except Exception as e:
287
+ traceback.print_exc()
288
+ return b"\1" + str(e).encode()
289
+
290
+
291
+ def on_delete_request(
292
+ path: str,
293
+ data: bytes,
294
+ _request_id: bytes,
295
+ remote_identity: RNS.Identity | None,
296
+ _request_at: float,
297
+ ):
298
+ try:
299
+ err = write_allowed_error(remote_identity)
300
+ if err is not None:
301
+ return b"\1" + err.encode()
302
+
303
+ err, res = request_repo_path(data)
304
+ if err is not None:
305
+ return b"\1" + err.encode()
306
+
307
+ assert res is not None # nosec B101
308
+ repo_path, data = res
309
+
310
+ ref = data
311
+ log_request(path, repo_path, data)
312
+
313
+ proc = subprocess.run( # nosec B607 B603
314
+ ["git", "update-ref", "-d", ref],
315
+ cwd=repo_path,
316
+ stdout=subprocess.PIPE,
317
+ stderr=subprocess.PIPE,
318
+ check=False,
319
+ )
320
+ log.debug("git update-ref return code: %d", proc.returncode)
321
+ return b"\0" + proc.stderr
322
+
323
+ except Exception as e:
324
+ traceback.print_exc()
325
+ return b"\1" + str(e).encode()
326
+
327
+
328
+ def main(argv: Sequence[str] | None = None) -> int: # noqa: MC0001
329
+ parser = argparse.ArgumentParser(description="RNS Git Server", allow_abbrev=False)
330
+ _ = parser.add_argument("repo", help="Path to git repository to serve")
331
+ _ = parser.add_argument(
332
+ "-c", "--config", help="Path to Reticulum config directory", dest="config"
333
+ )
334
+ _ = parser.add_argument(
335
+ "-v",
336
+ "--verbose",
337
+ action="store_true",
338
+ help="Enable verbose logging",
339
+ dest="verbose",
340
+ )
341
+ _ = parser.add_argument(
342
+ "-i", "--identity", help="Path identity file", dest="identity"
343
+ )
344
+ _ = parser.add_argument(
345
+ "--version", action="version", version=f"rngit {__version__}"
346
+ )
347
+ _ = parser.add_argument(
348
+ "-a",
349
+ "--announce-interval",
350
+ type=int,
351
+ default=None,
352
+ help="Interval in seconds between announces (default: announce once)",
353
+ dest="announce_interval",
354
+ )
355
+ _ = parser.add_argument(
356
+ "-w",
357
+ "--allow-write",
358
+ action="append",
359
+ default=[],
360
+ help="Identities allowed to write to the repository. Will automatically be allowed to read.",
361
+ dest="allow_write",
362
+ )
363
+ _ = parser.add_argument(
364
+ "-r",
365
+ "--allow-read",
366
+ action="append",
367
+ default=[],
368
+ help="Identities allowed to read the repository",
369
+ dest="allow_read",
370
+ )
371
+ _ = parser.add_argument(
372
+ "-A",
373
+ "--allow-all-read",
374
+ action="store_true",
375
+ dest="allow_all_read",
376
+ help="Allow any connection to read the repository",
377
+ )
378
+ args = parser.parse_args(argv)
379
+
380
+ assert isinstance(args.repo, str) # pyright: ignore[reportAny] # nosec B101
381
+ repo_path = os.path.realpath(args.repo)
382
+ if not os.path.exists(repo_path):
383
+ raise FileNotFoundError(repo_path)
384
+
385
+ if not os.path.isdir(repo_path):
386
+ raise ValueError(f"Not a directory: {repo_path}")
387
+
388
+ global _repo_path
389
+ _repo_path = repo_path
390
+
391
+ assert isinstance(args.config, str | None) # pyright: ignore[reportAny] # nosec B101
392
+ config_path = args.config
393
+ if config_path is None:
394
+ config_path = os.environ.get("RNS_CONFIG_PATH", None)
395
+
396
+ assert isinstance(args.verbose, bool) # pyright: ignore[reportAny] # nosec B101
397
+ verbose = args.verbose
398
+
399
+ assert isinstance(args.identity, str | None) # pyright: ignore[reportAny] # nosec B101
400
+ identity_path = args.identity
401
+
402
+ assert isinstance(args.announce_interval, int | None) # pyright: ignore[reportAny] # nosec B101
403
+ announce_interval = args.announce_interval
404
+
405
+ assert isinstance(args.allow_all_read, bool) # pyright: ignore[reportAny] # nosec B101
406
+ allow_all_read = args.allow_all_read
407
+
408
+ assert isinstance(args.allow_read, list) # pyright: ignore[reportAny]# nosec B101
409
+ assert all(x for x in args.allow_read if isinstance(x, str)) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]# nosec B101
410
+ read_list = set(cast(list[str], args.allow_read))
411
+
412
+ for allow in read_list:
413
+ if not is_valid_hexhash(allow):
414
+ raise ValueError(f"Invalid read hexhash: {allow}")
415
+
416
+ if allow_all_read and read_list:
417
+ raise ValueError(
418
+ "--allow-read and --allow-all-read cannot be used at the same time"
419
+ )
420
+
421
+ assert isinstance(args.allow_write, list) # pyright: ignore[reportAny] # nosec B101
422
+ assert all(x for x in args.allow_write if isinstance(x, str)) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # nosec B101
423
+ write_list = set(cast(list[str], args.allow_write))
424
+
425
+ global _write_list
426
+ _write_list = write_list
427
+
428
+ for allow in write_list:
429
+ if not is_valid_hexhash(allow):
430
+ raise ValueError(f"Invalid write hexhash: {allow}")
431
+
432
+ read_list |= write_list
433
+
434
+ global _read_list
435
+ _read_list = None if allow_all_read else read_list
436
+
437
+ configure_logging(logging.DEBUG if verbose else logging.WARNING)
438
+
439
+ _ = RNS.Reticulum(config_path, RNS.LOG_VERBOSE if verbose else RNS.LOG_WARNING)
440
+
441
+ assert RNS.Reticulum.configdir is not None # pyright: ignore[reportUnknownMemberType] # nosec B101
442
+ if identity_path is None:
443
+ identity_path = os.path.join(RNS.Reticulum.configdir, "identity") # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
444
+
445
+ assert identity_path is not None # nosec B101
446
+ log.info("Identity: %s", identity_path)
447
+ identity: RNS.Identity | None = None
448
+ if os.path.exists(identity_path):
449
+ identity = RNS.Identity.from_file(identity_path) # pyright: ignore[reportUnknownMemberType]
450
+
451
+ if identity is None:
452
+ identity = RNS.Identity(True)
453
+ _ = identity.to_file(identity_path) # pyright: ignore[reportUnknownMemberType]
454
+
455
+ assert identity is not None # nosec B101
456
+ assert identity.hexhash is not None # nosec B101
457
+
458
+ server_destination = RNS.Destination(
459
+ identity,
460
+ RNS.Destination.IN,
461
+ RNS.Destination.SINGLE,
462
+ APP_NAME,
463
+ )
464
+
465
+ log.info("Destination: %s", RNS.prettyhexrep(server_destination.hash)) # pyright: ignore[reportUnknownMemberType]
466
+ log.info("Read list: %s", "(any)" if allow_all_read else read_list)
467
+ log.info("Write list: %s", write_list)
468
+ allow_list = (bytes.fromhex(x) for x in read_list)
469
+ server_destination.register_request_handler( # pyright: ignore[reportUnknownMemberType]
470
+ "list",
471
+ on_list_request,
472
+ RNS.Destination.ALLOW_ALL,
473
+ allow_list,
474
+ )
475
+ server_destination.register_request_handler( # pyright: ignore[reportUnknownMemberType]
476
+ "list-for-push",
477
+ on_list_request,
478
+ RNS.Destination.ALLOW_ALL,
479
+ allow_list,
480
+ )
481
+ server_destination.register_request_handler( # pyright: ignore[reportUnknownMemberType]
482
+ "fetch",
483
+ on_fetch_request,
484
+ RNS.Destination.ALLOW_ALL,
485
+ allow_list,
486
+ )
487
+ server_destination.register_request_handler( # pyright: ignore[reportUnknownMemberType]
488
+ "push",
489
+ on_push_request,
490
+ RNS.Destination.ALLOW_ALL,
491
+ allow_list,
492
+ )
493
+ server_destination.register_request_handler( # pyright: ignore[reportUnknownMemberType]
494
+ "delete",
495
+ on_delete_request,
496
+ RNS.Destination.ALLOW_ALL,
497
+ allow_list,
498
+ )
499
+ server_destination.set_link_established_callback(on_link_established) # pyright: ignore[reportUnknownMemberType]
500
+
501
+ _ = server_destination.announce() # pyright: ignore[reportUnknownMemberType]
502
+ if announce_interval is None:
503
+ while True:
504
+ time.sleep(10)
505
+
506
+ last_announce = time.time()
507
+ while True:
508
+ current = time.time()
509
+ if last_announce + announce_interval >= current:
510
+ _ = server_destination.announce() # pyright: ignore[reportUnknownMemberType]
511
+ last_announce = current
512
+
513
+ time.sleep(0.1)
@@ -0,0 +1,30 @@
1
+ import logging
2
+ import string
3
+ import sys
4
+ from enum import Enum
5
+
6
+ import RNS
7
+
8
+ APP_NAME = "git"
9
+ EXPECTED_HEXHASH_LENGTH = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
10
+
11
+
12
+ class packets(Enum):
13
+ PACKET_IDENTIFIED = 0x01.to_bytes(1, "big")
14
+
15
+
16
+ def configure_logging(level: int = logging.WARNING):
17
+ while logging.root.handlers:
18
+ logging.root.removeHandler(logging.root.handlers[0])
19
+
20
+ logging.basicConfig(
21
+ level=level,
22
+ format="%(asctime)s [%(levelname)s] %(message)s",
23
+ stream=sys.stderr,
24
+ )
25
+
26
+
27
+ def is_valid_hexhash(hexhash: str) -> bool:
28
+ return len(hexhash) == EXPECTED_HEXHASH_LENGTH and all(
29
+ c in string.hexdigits for c in hexhash
30
+ )
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,238 @@
1
+ import os
2
+ import pathlib
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ RETICULUM_CONFIG = """
13
+ [reticulum]
14
+ share_instance = Yes
15
+
16
+ [interfaces]
17
+ [[AutoInterface]]
18
+ type = AutoInterface
19
+ enabled = no
20
+
21
+ [[Dummy]]
22
+ type = BackboneInterface
23
+ enable = yes
24
+ listen_on = 127.0.0.2
25
+ """
26
+
27
+
28
+ def _run_stack(tmp_path: Path, stdin: str) -> str:
29
+ if not shutil.which("rnsd"):
30
+ pytest.skip("rnsd binary not found in PATH")
31
+
32
+ venv_python: str = sys.executable
33
+ venv_bin: Path = pathlib.Path(venv_python).parent
34
+ rngit_bin: str = str(venv_bin / "rngit")
35
+ git_remote_rns_bin: str = str(venv_bin / "git-remote-rns")
36
+ rnsd: str = str(venv_bin / "rnsd")
37
+
38
+ if not pathlib.Path(rngit_bin).exists():
39
+ pytest.skip("rngit not installed in venv")
40
+
41
+ rns_config_dir: Path = tmp_path / "rns"
42
+ rns_config_dir.mkdir()
43
+ repo_dir: Path = tmp_path / "repo"
44
+ repo_dir.mkdir()
45
+
46
+ _ = subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
47
+ with open(repo_dir / "test.txt", "w") as f:
48
+ _ = f.write("hello")
49
+ _ = subprocess.run(
50
+ ["git", "add", "."], cwd=repo_dir, capture_output=True, check=True
51
+ )
52
+ _ = subprocess.run(
53
+ ["git", "commit", "-m", "init"],
54
+ cwd=repo_dir,
55
+ capture_output=True,
56
+ check=True,
57
+ )
58
+
59
+ rns_config: Path = rns_config_dir / "config"
60
+ _ = rns_config.write_text(RETICULUM_CONFIG)
61
+
62
+ workdir: Path = pathlib.Path.cwd()
63
+
64
+ rnsd_proc: subprocess.Popen[str] = subprocess.Popen(
65
+ [rnsd, "--config", str(rns_config_dir), "-v"],
66
+ stdout=subprocess.PIPE,
67
+ stderr=subprocess.PIPE,
68
+ text=True,
69
+ bufsize=1,
70
+ cwd=str(workdir),
71
+ )
72
+
73
+ # Check if rnsd started successfully
74
+ time.sleep(1)
75
+ if rnsd_proc.poll() is not None:
76
+ out = rnsd_proc.stdout.read().strip() if rnsd_proc.stdout else ""
77
+ err = rnsd_proc.stderr.read().strip() if rnsd_proc.stderr else ""
78
+ pytest.skip(
79
+ f"rnsd failed to start ({rnsd_proc.returncode}). stdout: {out!r}, stderr: {err!r}"
80
+ )
81
+
82
+ def wait_for_rns_ready(timeout: float = 15) -> bool:
83
+ import subprocess as subp
84
+
85
+ time.sleep(2) # Give rnsd time to start
86
+ start = time.time()
87
+ while time.time() - start < timeout:
88
+ result = subp.run(
89
+ ["rnstatus", "--config", str(rns_config_dir), "-a"],
90
+ capture_output=True,
91
+ text=True,
92
+ )
93
+ if "Shared Instance" in result.stdout and "Up" in result.stdout:
94
+ return True
95
+ time.sleep(0.5)
96
+ # Print debug info on failure
97
+ result = subp.run(
98
+ ["rnstatus", "--config", str(rns_config_dir), "-a"],
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ print(f"rnstatus stdout: {result.stdout}")
103
+ print(f"rnstatus stderr: {result.stderr}")
104
+ return False
105
+
106
+ rns_ready = wait_for_rns_ready()
107
+ print(f"RNS ready: {rns_ready}")
108
+
109
+ if not rns_ready:
110
+ rnsd_proc.terminate()
111
+ pytest.skip("RNS shared instance failed to start")
112
+
113
+ server_proc = subprocess.Popen(
114
+ [
115
+ rngit_bin,
116
+ str(repo_dir),
117
+ "--verbose",
118
+ "--config",
119
+ str(rns_config_dir),
120
+ "--announce-interval",
121
+ "1",
122
+ "--allow-all-read",
123
+ ],
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.STDOUT,
126
+ text=True,
127
+ bufsize=1,
128
+ cwd=str(workdir),
129
+ env={**os.environ, "RNS_CONFIG_PATH": str(rns_config_dir)},
130
+ )
131
+
132
+ dest_hash = None
133
+ try:
134
+ assert server_proc.stdout is not None, "Server stdout is None"
135
+
136
+ print("Waiting for rngit output...")
137
+ import select
138
+
139
+ while True:
140
+ ready, _, _ = select.select([server_proc.stdout], [], [], 5) # type: ignore[assignment]
141
+ if ready:
142
+ line = server_proc.stdout.readline()
143
+ if not line:
144
+ print("rngit stdout closed")
145
+ break
146
+
147
+ print(f"SERVER: {line.rstrip()}")
148
+ match = re.search(r"\[INFO\] Destination: <([a-f0-9]+)>", line)
149
+ if match:
150
+ dest_hash = match.group(1)
151
+ print(f"Destination: {dest_hash}")
152
+ break
153
+
154
+ if "error" in line.lower():
155
+ assert False, f"Server error: {line}"
156
+
157
+ else:
158
+ print("Timeout waiting for rngit output")
159
+ break
160
+
161
+ if server_proc.poll() is not None and dest_hash is None:
162
+ stderr_data = server_proc.stderr.read() if server_proc.stderr else ""
163
+ print(f"rngit exited early with code {server_proc.returncode}")
164
+ print(f"stderr: {stderr_data}")
165
+ assert False, f"rngit exited early with code {server_proc.returncode}"
166
+
167
+ assert dest_hash is not None, (
168
+ "Could not get destination hash from server. rngit output above."
169
+ )
170
+ assert len(dest_hash) == 32, f"Invalid destination hash length: {dest_hash}"
171
+
172
+ time.sleep(2)
173
+
174
+ try:
175
+ result = subprocess.run(
176
+ [git_remote_rns_bin, "origin", dest_hash],
177
+ env={**os.environ, "RNS_CONFIG_PATH": str(rns_config_dir)},
178
+ input=stdin,
179
+ capture_output=True,
180
+ text=True,
181
+ timeout=30,
182
+ )
183
+ print(f"STDOUT: {result.stdout}")
184
+ print(f"STDERR: {result.stderr}")
185
+ output = result.stdout + (result.stderr or "")
186
+
187
+ if result.returncode != 0:
188
+ print(f"Client exit code: {result.returncode}")
189
+
190
+ if (
191
+ "timeout" in output.lower()
192
+ or "failed to connect" in output.lower()
193
+ or "error" in output.lower()
194
+ ):
195
+ print(f"Client output: {output[:500]}")
196
+ assert False, (
197
+ "Client failed to connect to server. "
198
+ f"Server hash: {dest_hash}. "
199
+ f"Output: {output}"
200
+ )
201
+
202
+ return output
203
+
204
+ except subprocess.TimeoutExpired as e:
205
+ print("Client timed out!")
206
+ print(f"stdout: {e.stdout}")
207
+ print(f"stderr: {e.stderr}")
208
+ raise
209
+
210
+ finally:
211
+ server_proc.terminate()
212
+ try:
213
+ _ = server_proc.wait(timeout=5)
214
+
215
+ except subprocess.TimeoutExpired:
216
+ server_proc.kill()
217
+ _ = server_proc.wait()
218
+
219
+ rnsd_proc.terminate()
220
+ try:
221
+ _ = rnsd_proc.wait(timeout=5)
222
+
223
+ except subprocess.TimeoutExpired:
224
+ rnsd_proc.kill()
225
+ _ = rnsd_proc.wait()
226
+
227
+
228
+ class TestEndToEnd:
229
+ def test_capabilities(self, tmp_path: Path) -> None:
230
+ output = _run_stack(tmp_path, "capabilities\n\n")
231
+ assert "list" in output, f"'list' missing from capabilities: {output}"
232
+ assert "fetch" in output, f"'fetch' missing from capabilities: {output}"
233
+ assert "push" in output, f"'push' missing from capabilities: {output}"
234
+
235
+ def test_list(self, tmp_path: Path) -> None:
236
+ output = _run_stack(tmp_path, "list\n\n")
237
+ assert "refs/heads" in output, f"Expected refs/heads in output, got: {output}"
238
+ assert "HEAD" in output, f"Expected HEAD in output, got: {output}"