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.
- git_remote_rns-0.0.1/LICENSE +21 -0
- git_remote_rns-0.0.1/PKG-INFO +21 -0
- git_remote_rns-0.0.1/README.md +4 -0
- git_remote_rns-0.0.1/git_remote_rns.egg-info/PKG-INFO +21 -0
- git_remote_rns-0.0.1/git_remote_rns.egg-info/SOURCES.txt +15 -0
- git_remote_rns-0.0.1/git_remote_rns.egg-info/dependency_links.txt +1 -0
- git_remote_rns-0.0.1/git_remote_rns.egg-info/entry_points.txt +3 -0
- git_remote_rns-0.0.1/git_remote_rns.egg-info/requires.txt +7 -0
- git_remote_rns-0.0.1/git_remote_rns.egg-info/top_level.txt +1 -0
- git_remote_rns-0.0.1/pyproject.toml +45 -0
- git_remote_rns-0.0.1/rngit/__init__.py +10 -0
- git_remote_rns-0.0.1/rngit/__main__.py +15 -0
- git_remote_rns-0.0.1/rngit/client.py +322 -0
- git_remote_rns-0.0.1/rngit/server.py +513 -0
- git_remote_rns-0.0.1/rngit/shared.py +30 -0
- git_remote_rns-0.0.1/setup.cfg +4 -0
- git_remote_rns-0.0.1/tests/test_integration.py +238 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rngit
|
|
@@ -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,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,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}"
|