Files
Reticulum/RNS/Utilities/rngit/server.py
T
2026-05-25 17:36:32 +02:00

5010 lines
258 KiB
Python

# Reticulum License
#
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import RNS
import os
import sys
import time
import shutil
import fnmatch
import argparse
import threading
import subprocess
from threading import Lock
from tempfile import TemporaryDirectory
from tempfile import NamedTemporaryFile
from datetime import datetime, timezone
from RNS._version import __version__
from RNS.Utilities.rngit import APP_NAME
from RNS.Utilities.rngit.pages import NomadNetworkNode
from RNS.Utilities.rngit.util import san_ref, san_refs, san_sha
from RNS.vendor.configobj import ConfigObj
from RNS.vendor import umsgpack as mp
from RNS.Utilities.rnid import create_rsg, validate_rsg, get_rsg_hash
from RNS.Utilities.rnid import rsg_meta_from_str, extract_signed_rsg_data
from RNS.Utilities.rnid import check_release_rsm_structure
def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, service=False, interactive=False, print_identity=False, task=None, identity=None, signer=None):
targetverbosity = verbosity-quietness
if service:
targetlogdest = RNS.LOG_FILE
targetverbosity = None
else:
targetlogdest = RNS.LOG_STDOUT
if print_identity: git_node = ReticulumGitNode(configdir=configdir, print_identity=True)
reticulum = RNS.Reticulum(configdir=rnsconfigdir, verbosity=targetverbosity, logdest=targetlogdest)
if not task:
RNS.log("Starting Reticulum Git Node...", RNS.LOG_NOTICE)
git_node = ReticulumGitNode(configdir=configdir, verbosity=targetverbosity)
if not git_node.ready: exit(255)
else: git_node.start()
if interactive:
import code
code.interact(local=globals())
else:
while git_node._should_run: time.sleep(1)
else:
command = task["command"]; operation = task["operation"]
if command == "create":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
if operation == "create": git_client.create_repository(remote=task["remote"])
elif command == "release":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
if operation == "list": git_client.list_releases(remote=task["remote"])
elif operation == "view": git_client.view_release(remote=task["remote"], target=task["target"])
elif operation == "fetch": git_client.fetch_release(remote=task["remote"], target=task["target"], signer=task["signer"], offline=task["offline"])
elif operation == "create": git_client.create_release(remote=task["remote"], target=task["target"], signer=task["signer"], name=task["name"], no_upload=task["no_upload"])
elif operation == "delete": git_client.delete_release(remote=task["remote"], target=task["target"])
elif operation == "latest": git_client.latest_release(remote=task["remote"], target=task["target"])
else: print("Invalid operation"); exit(1)
elif command == "perms":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
if operation == "gperms": git_client.group_permissions(remote=task["remote"])
elif operation == "rperms": git_client.repository_permissions(remote=task["remote"])
else: print("Invalid operation"); exit(1)
elif command == "work":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
scope = task.get("scope", "active")
doc_id = task.get("doc_id")
title = task.get("title")
if operation == "list": git_client.work_list(remote=task["remote"], scope=scope)
elif operation == "view": git_client.work_view(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "create": git_client.work_create(remote=task["remote"], title=title)
elif operation == "propose": git_client.work_propose(remote=task["remote"], title=title)
elif operation == "edit": git_client.work_edit(remote=task["remote"], title=title, doc_id=doc_id, scope=scope)
elif operation == "delete": git_client.work_delete(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "update": git_client.work_comment(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "complete": git_client.work_complete(remote=task["remote"], doc_id=doc_id)
elif operation == "activate": git_client.work_activate(remote=task["remote"], doc_id=doc_id)
elif operation == "perms": git_client.work_permissions(remote=task["remote"], doc_id=doc_id)
else: print("Invalid operation"); exit(1)
elif command == "fork":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
git_client.fork_repository(source=task["source"], target=task["target"])
elif command == "sync":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
git_client.sync_repository(remote=task["remote"])
elif command == "mirror":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
git_client.mirror_repository(source=task["source"], target=task["target"])
else: print("Invalid command"); exit(1)
exit(0)
def main():
subcommands = ["node", "release", "perms", "work", "create", "fork", "sync", "mirror"]
try:
if len(sys.argv) < 2 or sys.argv[1] not in subcommands: subcommand = "node"
else: subcommand = sys.argv[1]; sys.argv.pop(1)
if subcommand == "node":
sys.argv
parser = argparse.ArgumentParser(description="Reticulum Git Repository Node")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument('-p', '--print-identity', action='store_true', default=False, help="print identity and destination info and exit")
parser.add_argument('-s', '--service', action='store_true', default=False, help="rngit is running as a service and should log to file")
parser.add_argument('-i', '--interactive', action='store_true', default=False, help="drop into interactive shell after initialisation")
elif subcommand == "create":
parser = argparse.ArgumentParser(description="Reticulum Git Repository Creation")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to identity", type=str)
parser.add_argument("repository", default=None, help="URL of repository to create", type=str)
elif subcommand == "release":
parser = argparse.ArgumentParser(description="Reticulum Git Release Manager")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to release identity", type=str)
parser.add_argument("-s", "--signer", action="store", metavar="PATH", default=None, help="path to signing identity, if different from release identity", type=str)
parser.add_argument("-n", "--name", action="store", metavar="name", default=None, help="package name if different from repo name", type=str)
parser.add_argument('-L', '--local', action='store_true', default=False, help="generate release locally, but don't upload")
parser.add_argument('-o', '--offline', action='store_true', default=False, help="verify manifest locally, but don't fetch updates")
parser.add_argument("repository", nargs="?", default=None, help="URL of remote repository, or path to RSM manifest", type=str)
parser.add_argument("operation", nargs="?", default=None, help="list, view, fetch, create, latest or delete", type=str)
parser.add_argument("target", nargs="?", default=None, help="tag and path to release artifacts directory", type=str)
elif subcommand == "perms":
parser = argparse.ArgumentParser(description="Reticulum Git Release Manager")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to release identity", type=str)
parser.add_argument("remote", default=None, help="URL of remote group or repository", type=str)
elif subcommand == "work":
parser = argparse.ArgumentParser(description="Reticulum Git Work Document Manager")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to identity", type=str)
parser.add_argument("--scope", action="store", default="active", help="document scope: active, completed or all", type=str)
parser.add_argument("-t", "--title", action="store", default=None, help="document title for create", type=str)
parser.add_argument("-d", "--id", action="store", default=None, help="document ID", type=int)
parser.add_argument("repository", nargs="?", default=None, help="URL of remote repository", type=str)
parser.add_argument("operation", nargs="?", default=None, help="list, view, create, propose, edit, delete, update, complete, activate or perms", type=str)
elif subcommand == "fork":
parser = argparse.ArgumentParser(description="Reticulum Git Repository Forker")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to identity", type=str)
parser.add_argument("source", default=None, help="URL of source repository", type=str)
parser.add_argument("target", default=None, help="URL of target repository", type=str)
elif subcommand == "sync":
parser = argparse.ArgumentParser(description="Reticulum Git Repository Syncer")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to identity", type=str)
parser.add_argument("repository", default=None, help="URL of repository", type=str)
elif subcommand == "mirror":
parser = argparse.ArgumentParser(description="Reticulum Git Mirror Management")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to identity", type=str)
parser.add_argument("--scope", action="store", default="active", help="document scope: active, completed or all", type=str)
parser.add_argument("source", default=None, help="URL of source repository", type=str)
parser.add_argument("target", default=None, help="URL of target repository", type=str)
parser.add_argument('-v', '--verbose', action='count', default=0)
parser.add_argument('-q', '--quiet', action='count', default=0)
parser.add_argument("--version", action="version", version="rngit {version}".format(version=__version__))
args = parser.parse_args()
if args.config: configarg = args.config
else: configarg = None
if args.rnsconfig: rnsconfigarg = args.rnsconfig
else: rnsconfigarg = None
if subcommand == "node":
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, service=args.service, verbosity=args.verbose,
quietness=args.quiet, interactive=args.interactive, print_identity=args.print_identity)
elif subcommand == "create":
task = {"command": subcommand, "operation": "create", "remote": args.repository}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity)
elif subcommand == "release":
if not args.operation: parser.print_help()
task = {"command": subcommand, "operation": args.operation, "remote": args.repository, "target": args.target,
"signer": args.signer, "name": args.name, "no_upload": args.local, "offline": args.offline}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity, signer=args.signer)
elif subcommand == "perms":
args.remote = args.remote.rstrip("/")
url_components_len = len(args.remote.split("/"))
if url_components_len == 5: operation = "rperms"
elif url_components_len == 4: operation = "gperms"
else: parser.print_help(); print("\nInvalid URL"); exit(1)
task = {"command": subcommand, "operation": operation, "remote": args.remote}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity)
elif subcommand == "work":
if not args.operation: parser.print_help(); print()
task = {"command": subcommand, "operation": args.operation, "remote": args.repository,
"scope": args.scope, "doc_id": args.id, "title": args.title}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity)
elif subcommand == "fork":
task = {"command": subcommand, "operation": "fork", "source": args.source, "target": args.target}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity)
elif subcommand == "sync":
task = {"command": subcommand, "operation": "sync", "remote": args.repository}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity)
elif subcommand == "mirror":
task = {"command": subcommand, "operation": "fork", "source": args.source, "target": args.target}
program_setup(configdir=configarg, rnsconfigdir=rnsconfigarg, verbosity=args.verbose, quietness=args.quiet,
task=task, identity=args.identity)
except KeyboardInterrupt:
print("")
exit()
class ReticulumGitClient():
PROTO_SPEC = "rns://"
SIG_EXT = "rsg"
MSG_EXT = "rsm"
PATH_LIST = "/git/list"
PATH_FETCH = "/git/fetch"
PATH_PUSH = "/git/push"
PATH_DELETE = "/git/delete"
PATH_CREATE = "/git/create"
PATH_FORK = "/git/fork"
PATH_SYNC = "/git/sync"
PATH_MIRROR = "/git/mirror"
PATH_RELEASE = "/mgmt/release"
PATH_WORK = "/mgmt/work"
PATH_PERMS = "/mgmt/perms"
RES_OK = 0x00
RES_DISALLOWED = 0x01
RES_INVALID_REQ = 0x02
RES_NOT_FOUND = 0x03
RES_REMOTE_FAIL = 0xFF
IDX_REPOSITORY = 0x00
IDX_RESULT_CODE = 0x01
IDX_GROUP = 0x02
PATH_TIMEOUT = 15
LINK_TIMEOUT = 15
WAIT_SLEEP = 0.2
def __init__(self, configdir=None, verbosity=None, identitypath=None):
self.identity = None
self.userdir = os.path.expanduser("~")
self.config = None
self.destination_aliases = {}
self.verbosity = verbosity or 0
self.path_timeout = self.PATH_TIMEOUT
self.link_timeout = self.LINK_TIMEOUT
self.wait_sleep = self.WAIT_SLEEP
self._should_run = True
self.link_ready = False
self.link_failed = False
self.request_event = threading.Event()
self.request_response = None
self.response_metadata = None
self.response_progress = 0
self.previous_progress = 0
self.response_size = None
self.response_transfer_size = None
self.progress_updated_at = None
self.progress_enabled = True
self.transfer_label = "unknown"
if configdir != None: self.configdir = configdir
else:
if os.path.isdir(self.userdir+"/.config/rngit") and os.path.isfile(self.userdir+"/.config/rngit/config"): self.configdir = self.userdir+"/.rngit/reticulum"
else: self.configdir = self.userdir+"/.rngit"
self.logfile = self.configdir+"/client_log"
self.configpath = self.configdir+"/client_config"
self.identitypath = identitypath or self.configdir+"/client_identity"
if not os.path.isdir(self.configdir): os.makedirs(self.configdir)
self.__ensure_identity()
self.__ensure_config()
self.__apply_config()
def _ensure_git(self):
if not ReticulumGitNode._ensure_git(): self.abort("The \"git\" command is not available. Aborting operation.")
def __ensure_identity(self):
if not os.path.isfile(self.identitypath):
identity = RNS.Identity()
identity.to_file(self.identitypath)
RNS.log(f"Identity generated and persisted to {self.identitypath}", RNS.LOG_DEBUG)
else:
identity = RNS.Identity.from_file(self.identitypath)
RNS.log(f"Client identity loaded from {self.identitypath}", RNS.LOG_DEBUG)
if not identity: self.abort("Could not initialize client identity")
else: self.identity = identity
def __ensure_config(self):
if os.path.isfile(self.configpath):
try: self.config = ConfigObj(self.configpath)
except Exception as e:
RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR)
RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR)
RNS.panic()
else:
RNS.log("Could not load config file, creating default configuration file...")
self.__create_default_config()
RNS.log("Default config file created, make any necessary changes in "+self.configdir+"/config")
def __create_default_config(self):
from RNS.Utilities.rngit.client import __default_rngit_config__ as __default_rngit_client_config__
self.config = ConfigObj(__default_rngit_client_config__)
self.config.filename = self.configpath
if not os.path.isdir(self.configdir): os.makedirs(self.configdir)
self.config.write()
def __apply_config(self):
if "logging" in self.config:
section = self.config["logging"]
if "loglevel" in section: RNS.loglevel = max(RNS.LOG_NONE, min(RNS.LOG_EXTREME, section.as_int("loglevel")))
if "aliases" in self.config:
section = self.config["aliases"]
for alias in section:
alias_hexhash = section[alias]
len_ok = len(alias_hexhash) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2
try: alias_hash = bytes.fromhex(alias_hexhash)
except: alias_hash = None
alias_exists = alias in self.destination_aliases
if not len_ok or not alias_hash: continue
if alias_exists: continue
self.destination_aliases[alias] = RNS.hexrep(alias_hash, delimit=False)
def __resolve_destination_alias(self, alias):
def resolve(alias):
len_match = len(alias) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2
try: hash_bytes = bytes.fromhex(alias)
except: hash_bytes = None
if len_match and hash_bytes: return alias
else: return self.destination_aliases[alias] if alias in self.destination_aliases else alias
resolved = resolve(alias)
return resolved
def abort(self, msg):
print(msg); exit(1)
def parse_remote_url(self, remote):
if not remote.lower().startswith(self.PROTO_SPEC): self.abort("Invalid protocol in remote URL")
components = remote[len(self.PROTO_SPEC):].split("/")
destination_hexhash = self.__resolve_destination_alias(components[0])
if not len(components) == 3: self.abort("Invalid number of URL components")
if not len(destination_hexhash) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: self.abort("Invalid destination hash length")
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: self.abort(f"Invalid destination hash: {e}")
return destination_hash, components[1], components[2]
def parse_remote_group_url(self, remote):
if not remote.lower().startswith(self.PROTO_SPEC): self.abort("Invalid protocol in remote URL")
components = remote[len(self.PROTO_SPEC):].split("/")
destination_hexhash = self.__resolve_destination_alias(components[0])
if not len(components) == 2: self.abort("Invalid number of URL components")
if not len(destination_hexhash) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: self.abort("Invalid destination hash length")
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: self.abort(f"Invalid destination hash: {e}")
return destination_hash, components[1]
def parse_remote_destination_url(self, remote):
if not remote.lower().startswith(self.PROTO_SPEC): self.abort("Invalid protocol in remote URL")
components = remote[len(self.PROTO_SPEC):].split("/")
destination_hexhash = self.__resolve_destination_alias(components[0])
if not len(components): self.abort("Invalid number of URL components")
if not len(destination_hexhash) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: self.abort("Invalid destination hash length")
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: self.abort(f"Invalid destination hash: {e}")
return destination_hash
def connect_remote(self, remote):
destination_hash = self.parse_remote_destination_url(remote)
print(f"Requesting path... ", end="")
if not RNS.Transport.await_path(destination_hash, timeout=self.path_timeout):
print(f"\n", end="")
self.abort(f"Could not resolve path to {RNS.prettyhexrep(destination_hash)}")
else: print(f"\rPath resolved ", end="")
self.remote_identity = RNS.Identity.recall(destination_hash)
if not self.remote_identity: self.abort("Could not recall remote identity")
print(f"\rEstablishing link... ", end="")
self.destination = RNS.Destination(self.remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, "repositories")
self.link = RNS.Link(self.destination)
self.link.set_link_established_callback(self.link_established)
self.link.set_link_closed_callback(self.link_closed)
def link_established(self, link):
print(f"\rLink established ", end="")
link.identify(self.identity)
self.link_ready = True
def link_closed(self, link):
if not self.link_ready: self.link_failed = True
################################
# Synchronous Request Wrappers #
################################
def _response_ready(self, request_receipt):
self.request_response = request_receipt.response
self.response_metadata = request_receipt.metadata
if hasattr(self.request_response, "read") and callable(self.request_response.read):
if not hasattr(self, "tmpdir"): self.tmp_dir = TemporaryDirectory()
response_path = self.request_response.name
base_name = os.path.basename(response_path)
retained_path = os.path.join(self.tmp_dir.name, base_name)
shutil.move(response_path, retained_path)
self.request_response = open(retained_path, "rb")
self.request_event.set()
def _response_failed(self, request_receipt=None):
self.request_response = None
self.request_event.set()
def _on_progress(self, transfer_instance):
if hasattr(transfer_instance, "progress"):
self.response_progress = transfer_instance.progress
self.response_size = transfer_instance.response_size
self.response_transfer_size = transfer_instance.response_transfer_size
elif hasattr(transfer_instance, "get_progress") and callable(transfer_instance.get_progress):
self.response_progress = transfer_instance.get_progress()
self.response_size = transfer_instance.total_size
self.response_transfer_size = transfer_instance.size
now = time.time()
if self.progress_updated_at == None: self.progress_updated_at = now
if now > self.progress_updated_at+0.5:
td = now - self.progress_updated_at
pd = self.response_progress - self.previous_progress
bd = pd*self.response_size if self.response_size else 0
self.response_speed = (bd/td)*8 if td > 0 else 0
self.previous_progress = self.response_progress
self.progress_updated_at = now
if self.progress_enabled and self.response_size:
pi = self.progress_indent if hasattr(self, "progress_indent") else " "
percent = round(self.response_progress * 100, 1)
size = self.response_size
rxd = size*self.response_progress
speed_kbps = (self.response_speed / 1000) if hasattr(self, 'response_speed') else 0
print(f"{pi}Transferring {self.transfer_label}: {percent}% ({RNS.prettysize(rxd)}/{RNS.prettysize(size)}) {RNS.prettyspeed(self.response_speed)} \r", end="")
def send_request(self, path, data, timeout=120, progress=False):
if not self.link_ready: self.abort("Link not ready at request time")
self.request_event.clear()
self.request_response = None
self.response_metadata = None
self.previous_progress = 0
self.progress_updated_at = None
RNS.log(f"Sending request: {path}", RNS.LOG_DEBUG)
progress_callback = self._on_progress if progress else None
request_receipt = self.link.request(path, data, progress_callback=progress_callback, response_callback=self._response_ready, failed_callback=self._response_failed, timeout=timeout)
if request_receipt.resource: request_receipt.resource.progress_callback(self._on_progress)
self.request_event.wait(timeout=timeout)
if self.request_response is None: self.abort("Request failed or timed out")
RNS.log(f"Got response for: {path}", RNS.LOG_DEBUG)
return self.request_response, self.response_metadata
#########################
# Repository Management #
#########################
def create_repository(self, remote=None):
if not remote: self.abort(f"No remote specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path}
response, metadata = self.send_request(self.PATH_CREATE, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte == 0: print(f"Repository {repo_path} created")
elif status_byte == self.RES_INVALID_REQ: self.abort(f"Remote error: Invalid request")
elif status_byte == self.RES_NOT_FOUND: self.abort(f"Not found")
elif status_byte == self.RES_DISALLOWED:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not allowed"
self.abort(f"{error_msg}")
else:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Unknown error"
self.abort(f"Remote error: {error_msg}")
except Exception as e: self.abort(f"Error creating repository: {e}")
finally:
if self.link: self.link.teardown()
####################
# Fork & Mirroring #
####################
def fork_repository(self, source=None, target=None):
if not source: self.abort(f"No source specified")
if not target: self.abort(f"No target specified")
self._remote_clone_operation(source, target, self.PATH_FORK, "fork")
def mirror_repository(self, source=None, target=None):
if not source: self.abort(f"No source specified")
if not target: self.abort(f"No target specified")
self._remote_clone_operation(source, target, self.PATH_MIRROR, "mirror")
def _resolve_aliased_url(self, url):
if url.lower().startswith("rns://"):
destination_hash, group, repo = self.parse_remote_url(url)
if not destination_hash or not group or not repo: self.abort("Invalid source URL")
url = f"rns://{RNS.hexrep(destination_hash, delimit=False)}/{group}/{repo}"
return url
def _remote_clone_operation(self, source, target, path, operation_name):
source = self._resolve_aliased_url(source)
self.connect_remote(target)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(target)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "source": source}
print(f"Remote is {operation_name.lower()}ing repository to {repo_path}...")
response, metadata = self.send_request(path, request_data, timeout=7200)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte == 0: print(f"Repository {operation_name}ed to {repo_path}")
elif status_byte == self.RES_NOT_FOUND:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not found"
self.abort(f"{error_msg}")
elif status_byte == self.RES_INVALID_REQ:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Invalid request"
self.abort(f"{error_msg}")
elif status_byte == self.RES_DISALLOWED:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not allowed"
self.abort(f"{error_msg}")
else:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Unknown error"
self.abort(f"Server error: {error_msg}")
except Exception as e: self.abort(f"Error {operation_name}ing repository: {e}")
finally:
if self.link: self.link.teardown()
def sync_repository(self, remote=None):
if not remote: self.abort(f"No remote specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path}
print(f"Remote is syncing repository...")
response, metadata = self.send_request(self.PATH_SYNC, request_data, timeout=7200)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte == 0: print(f"Repository synced")
elif status_byte == self.RES_NOT_FOUND:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not found"
self.abort(f"{error_msg}")
elif status_byte == self.RES_INVALID_REQ:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Invalid request"
self.abort(f"{error_msg}")
elif status_byte == self.RES_DISALLOWED:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not allowed"
self.abort(f"{error_msg}")
else:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Unknown error"
self.abort(f"Server error: {error_msg}")
except Exception as e: self.abort(f"Error syncing repository: {e}")
finally:
if self.link: self.link.teardown()
######################
# Release Management #
######################
def _edit_release_notes(self, tag="this release"):
editor = os.environ.get("EDITOR", "")
if not editor:
# Try common fallbacks
for fallback in ["nano", "vim", "vi"]:
try:
subprocess.run(["which", fallback], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
editor = fallback
break
except subprocess.CalledProcessError: continue
if not editor:
print("No editor found. Please set $EDITOR environment variable.")
return None
template = RELEASE_NOTES_TEMPLATE.replace("{TAG}", tag)
try:
with NamedTemporaryFile(mode="w+", suffix=".md", delete=False) as tmp:
tmp_path = tmp.name
tmp.write(template)
result = subprocess.run([editor, tmp_path])
if result.returncode != 0:
print(f"Editor exited with error code {result.returncode}")
os.unlink(tmp_path)
return None
with open(tmp_path, "r") as f: content = f.read()
os.unlink(tmp_path)
lines = [line for line in content.split("\n") if not line.strip().startswith("#")]
notes = "\n".join(lines).strip()
if not notes: return None
return notes
except Exception as e:
RNS.log(f"Error getting release notes: {e}", RNS.LOG_ERROR)
return None
def list_releases(self, remote=None):
if not remote: self.abort(f"No remote specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "list"}
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=120)
print("\r \r", end="")
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error: {error_msg}")
if len(response) > 1: unpacked = mp.unpackb(response[1:])
else: unpacked = []
if type(unpacked) == list:
releases = unpacked
latest_release = None
elif type(unpacked) == dict:
releases = unpacked["releases"]
latest_release = unpacked["latest"]
else: self.abort("Invalid release data format from remote")
if not releases: print("No releases for this repository")
else:
print(f"{'Tag':<10} {'Status':<10} {'Created':<17} {'Objs':<5} Notes")
print("-" * 80)
for rel in releases:
tag = rel.get("tag", "unknown")[:10]
status = rel.get("status", "unknown")[:9]
created_ts = rel.get("created", 0)
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(created_ts)) if created_ts else "unknown"
artifacts = str(rel.get("artifacts", 0))
preview = rel.get("preview", "").splitlines()[0][:34]
print(f"{tag:<10} {status:<10} {created:<17} {artifacts:<5} {preview}")
if latest_release: print(f"\nThe latest release is: {latest_release}")
except Exception as e: self.abort(f"Error listing releases: {e}")
finally:
if self.link: self.link.teardown()
def view_release(self, remote=None, target=None):
if not remote: self.abort(f"No remote specified")
if not target: self.abort(f"No target specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "view", "tag": target}
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=300)
print("\r \r", end="")
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) <= 1: self.abort("Empty response from remote")
release = mp.unpackb(response[1:])
print(f"Release : {release.get('tag', target)}")
print(f"Status : {release.get('status', 'unknown')}")
created_ts = release.get('created', 0)
if created_ts: print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(created_ts))}")
print(f"Thanks : {release.get('thanks', 0)}")
notes = release.get('notes', '')
if notes:
print("\nRelease Notes")
print("=============\n")
print(notes)
artifacts = release.get('artifacts', [])
if artifacts:
artifacts_str = f"Artifacts ({len(artifacts)})"
print(f"\n{artifacts_str}")
print("="*len(artifacts_str))
for a in artifacts:
size = a.get('size', 0)
size_str = RNS.prettysize(size) if size else "0 B"
print(f" - {a.get('name', 'unknown')} ({size_str})")
print()
except Exception as e: self.abort(f"Error viewing release: {e}")
finally:
if self.link: self.link.teardown()
def fetch_release(self, remote=None, target=None, signer=None, offline=False):
if offline and not target: target = "latest:all"
if not remote: self.abort(f"No remote specified")
if not target: self.abort(f"No target specified")
if not signer: signer_hash = None
else:
if not len(signer) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: self.abort("Invalid required signer identity hash length")
try: signer_hash = bytes.fromhex(signer)
except Exception as e: self.abort(f"Invalid required signer identity hash: {e}")
local_manifest = None
local_manifest_dir = None
if remote.endswith(f".{self.MSG_EXT}") and os.path.isfile(os.path.expanduser(remote)):
manifest_path = os.path.expanduser(remote)
with open(manifest_path, "rb") as fh: rsg = fh.read()
rsm_contents = extract_signed_rsg_data(rsg)
if not "message" in rsm_contents: self.abort(f"No embedded message in release manifest")
valid, signed_data, signing_identity = validate_rsg(rsg, message=rsm_contents["message"], required_signer=signer_hash)
if not valid: self.abort(f"Release manifest not signed by {RNS.prettyhexrep(signer_hash)}, aborting") if signer_hash else self.abort("Could not validate release manifest signature")
else:
print(f"Valid release manifest signature, signed by {signing_identity}")
rsm_structure_integrity = check_release_rsm_structure(signed_data)
if rsm_structure_integrity != True: self.abort(rsm_structure_integrity)
release_meta = signed_data.get("meta", None)
release_origin = release_meta.get("origin", None)
release_origin_path = release_meta.get("path", None)
remote = f"rns://{RNS.hexrep(release_origin, delimit=False)}/{release_origin_path}"
local_manifest = manifest_path
local_manifest_dir = local_manifest.replace(os.path.basename(local_manifest), "")
print(f"Release origin is {remote}")
if offline and not local_manifest: self.abort("Cannot perform offline verification without a local manifest")
if offline: self.link = None
else:
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
parts = target.split(":")
if len(parts) < 2: self.abort("Invalid release specification")
tag = parts[0]
artifact = parts[1]
def fetch(name):
if offline and name == "manifest.rsm": return local_manifest
elif offline: return local_manifest_dir+name
self.transfer_label = name
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "fetch", "tag": tag, "artifact": name}
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=7200, progress=True)
print("\r \r", end="")
if not response: self.abort(f"No response from remote")
if not metadata:
status_byte = response[0]
if status_byte == self.RES_INVALID_REQ: self.abort(f"Remote error: Invalid request")
elif status_byte == self.RES_NOT_FOUND:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not found"
self.abort(f"{error_msg}")
elif status_byte == self.RES_DISALLOWED:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Not allowed"
self.abort(f"{error_msg}")
else:
error_msg = response[1:].decode("utf-8", errors="ignore") if len(response) > 1 else "Unknown error"
self.abort(f"Remote error: {error_msg}")
else:
if not "name" in metadata: self.abort(f"Invalid result metadata on fetch response")
size = os.stat(response.name).st_size
print(f"{self.progress_indent}Transferring {self.transfer_label}: 100% ({RNS.prettysize(size)}) ")
return response.name
# Fetch release manifest
self.progress_indent = ""
manifest_path = fetch("manifest.rsm")
with open(manifest_path, "rb") as fh: rsg = fh.read()
rsm_contents = extract_signed_rsg_data(rsg)
if not "message" in rsm_contents: self.abort(f"No embedded message in release manifest")
valid, signed_data, signing_identity = validate_rsg(rsg, message=rsm_contents["message"], required_signer=signer_hash)
if not valid: self.abort(f"Release manifest not signed by {RNS.prettyhexrep(signer_hash)}, aborting") if signer_hash else self.abort("Could not validate release manifest signature")
else:
print(f"Release manifest validated, signed by {signing_identity}")
rsm_structure_integrity = check_release_rsm_structure(signed_data)
if rsm_structure_integrity != True: self.abort(rsm_structure_integrity)
release_meta = signed_data.get("meta", None)
release_name = release_meta.get("name", None)
release_version = release_meta.get("version", None)
manifest_out = os.path.basename(f"{release_name}_{release_version}.{self.MSG_EXT}")
with open(manifest_out, "wb") as fh: fh.write(rsg)
def match_artifacts(match_expression, manifest_artifacts):
return [entry for entry in manifest_artifacts if fnmatch.fnmatch(entry.get("name", ""), match_expression)]
fetch_artifacts = []
artifacts = release_meta.get("artifacts", [])
if not artifacts: self.abort("Release manifest contains no artifacts")
if artifact == "all": fetch_artifacts = artifacts
else: fetch_artifacts = match_artifacts(artifact, artifacts)
valid_count = 0
if not fetch_artifacts: self.abort("No available artifacts specified for fetch")
ms = "" if len(fetch_artifacts) == 1 else "s"
op = "Fetching" if not offline else "Validating"
print(f"{op} {len(fetch_artifacts)} artifact{ms}...")
self.progress_indent = " "
for artifact in fetch_artifacts:
name = os.path.basename(artifact["name"])
rsg = artifact["rsg"]
if not offline and os.path.exists(name):
with open(name, "rb") as fh: valid, signed_data, signing_identity = validate_rsg(rsg, fh, required_signer=signer_hash)
if not valid: print(f"Existing file {name} does not match manifest, fetching and overwriting")
else:
print(f"Existing file {name} validated, not fetching again")
continue
artifact_path = fetch(name)
if offline and not os.path.exists(artifact_path): print(f" File {name} from manifest does not exist locally, cannot validate"); continue
with open(artifact_path, "rb") as fh: valid, signed_data, signing_identity = validate_rsg(rsg, fh, required_signer=signer_hash)
if not valid: self.abort(f"Fetched file {name} does not match manifest, aborting") if not offline else print(f" File {name} does not match manifest")
else: shutil.move(artifact_path, name) if not offline else print(f" File {name} validated against manifest {local_manifest}")
if valid: valid_count += 1
if offline:
if valid_count == len(fetch_artifacts): print("\nAll files validated"); exit(0)
else: print("\nRelease is not valid"); exit(1)
except Exception as e: self.abort(f"Error fetching release: {e}")
finally:
if self.link: self.link.teardown()
def create_release(self, remote=None, target=None, signer=None, name=None, no_upload=False):
if signer:
try:
identity_path = os.path.expanduser(signer)
if not os.path.isfile(identity_path): self.abort(f"Signer identity {identity_path} does not exist")
else: signer = RNS.Identity.from_file(signer)
if not signer: self.abort(f"Could not load signer identity from {identity_path}")
except Exception as e: self.abort(f"Could not load signer identity from {identity_path}: {e}")
if not remote: self.abort(f"No remote specified")
if not target: self.abort(f"No target specified")
if not signer: signer = self.identity
if no_upload: self.link = None
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
release_time = int(time.time())
release_time_iso = datetime.fromtimestamp(release_time, tz=timezone.utc).isoformat().replace("+00:00", "Z")
parts = target.split(":")
if len(parts) < 2: self.abort("Invalid release specification\nDid you provide both a tag and artifacts path such as \"1.0.0:./dist\"?")
tag = parts[0]
artifacts_path = os.path.expanduser(parts[1])
commit_hash = None # TODO: Get commit hash from tag
if not os.path.isdir(artifacts_path): self.abort("Specified artifacts directory does not exist")
artifacts = [f for f in os.listdir(artifacts_path) if os.path.isfile(os.path.join(artifacts_path, f))]
if not artifacts: self.abort("No files found in specified artifact directory")
# Get release notes
print(f"Creating release {tag}")
notes = self._edit_release_notes(tag=tag)
if notes is None: print("Release creation cancelled"); return
# Generate manifest
package_name = name or repo
manifest_meta = {"name": package_name ,"version": tag, "released": release_time_iso, "timestamp": release_time,
"origin": destination_hash, "path": f"{group}/{repo}", "commit": commit_hash, "artifacts": []}
try:
manifest_path = artifacts_path+f"/manifest.{self.MSG_EXT}"
rsgs = []
for artifact in artifacts:
if artifact.endswith(f".{self.SIG_EXT}"): continue
if artifact.endswith(f".{self.MSG_EXT}"): continue
artifact_path = os.path.join(artifacts_path, artifact)
signature_path = f"{artifact_path}.{self.SIG_EXT}"
artifact_meta = {"timestamp": release_time}
print(f"Signing {artifact_path} with {signer}")
with open(artifact_path, "rb") as fh: rsg = create_rsg(signer, fh, meta=artifact_meta)
if not rsg: raise SystemError(f"Could not create signature for {artifact_path}")
with open(signature_path, "wb") as fh: fh.write(rsg)
artifact_entry = {"name": artifact, "rsg": rsg}
manifest_meta["artifacts"].append(artifact_entry)
rsgs.append(f"{artifact}.{self.SIG_EXT}")
manifest = create_rsg(signer, notes, embed=True, meta=manifest_meta)
with open(manifest_path, "wb") as fh: fh.write(manifest)
artifacts.extend(rsgs)
artifacts.append(f"manifest.{self.MSG_EXT}")
except Exception as e: self.abort(f"Release manifest generation failed: {e}")
if no_upload: print(f"Local release {package_name}:{tag} generated successfully in {target}"); exit(0)
else: self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
# Step 1: Initialize release
print("Initializing release on remote...")
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "create", "step": "init",
"tag": tag, "hash": commit_hash,
"notes": notes, "notes_format": "markdown" }
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote during release init")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error during init: {error_msg}")
print("Release initialized")
# Step 2: Upload artifacts
ms = "" if len(artifacts) == 1 else "s"
print(f"\nSending {len(artifacts)} artifact{ms}...")
for artifact in artifacts:
self.previous_progress = 0
self.progress_updated_at = None
self.transfer_label = str(artifact)
artifact_path = os.path.join(artifacts_path, artifact)
with open(artifact_path, "rb") as f: artifact_data = f.read()
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "create", "step": "artifact",
"tag": tag, "artifact_name": artifact,
"artifact_data": artifact_data }
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=7200)
if not response or not isinstance(response, bytes) or response[0] != 0:
error_msg = response[1:].decode("utf-8", errors="ignore") if response else "Unknown error"
print(f" Failed to send {artifact}: {error_msg}")
else: print(f" {artifact} ({RNS.prettysize(len(artifact_data))}) transferred"+" "*33)
# Step 3: Finalize release
print("\nFinalizing release...")
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "create", "step": "finalize", "tag": tag }
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=300)
if not response or not isinstance(response, bytes): self.abort("No response from remote during finalize")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error during finalize: {error_msg}")
print(f"Release {tag} published")
except Exception as e: self.abort(f"Error creating release: {e}")
finally:
if hasattr(self, "link") and self.link: self.link.teardown()
def delete_release(self, remote=None, target=None):
if not remote: self.abort(f"No remote specified")
if not target: self.abort(f"No target specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
print(f"Are you sure you want to delete release {target}? [y/N]: ", end="")
try: confirm = input().strip().lower()
except EOFError: confirm = "n"
if confirm != "y":
print("Deletion cancelled")
return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "delete", "tag": target }
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Release {target} deleted")
except Exception as e: self.abort(f"Error deleting release: {e}")
finally:
if self.link: self.link.teardown()
def latest_release(self, remote=None, target=None):
if not remote: self.abort(f"No remote specified")
if not target: self.abort(f"No target specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
print(f"Are you sure you want to set {target} as the latest release? [y/N]: ", end="")
try: confirm = input().strip().lower()
except EOFError: confirm = "n"
if confirm != "y":
print("Update cancelled")
return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "latest", "tag": target }
response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Release {target} set as latest")
except Exception as e: self.abort(f"Error setting latest release: {e}")
finally:
if self.link: self.link.teardown()
##########################
# Permissions Management #
##########################
def group_permissions(self, remote=None):
if not remote: self.abort(f"No remote specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group = self.parse_remote_group_url(remote)
request_data = {self.IDX_GROUP: group, "operation": "gperms", "step": "get"}
response, metadata = self.send_request(self.PATH_PERMS, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
current_content = result.get("content", "")
else: current_content = ""
content = self._edit_permissions(content=current_content)
if content is None: print("Edit cancelled"); return
request_data = {self.IDX_GROUP: group, "operation": "gperms", "step": "set", "content": content}
response, metadata = self.send_request(self.PATH_PERMS, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Permissions updated for group {group}")
except Exception as e: self.abort(f"Error editing permissions: {e}")
finally:
if self.link: self.link.teardown()
def repository_permissions(self, remote=None):
if not remote: self.abort(f"No remote specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "rperms", "step": "get"}
response, metadata = self.send_request(self.PATH_PERMS, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
current_content = result.get("content", "")
else: current_content = ""
content = self._edit_permissions(content=current_content)
if content is None: print("Edit cancelled"); return
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "rperms", "step": "set", "content": content}
response, metadata = self.send_request(self.PATH_PERMS, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Permissions updated for {repo_path}")
except Exception as e: self.abort(f"Error editing permissions: {e}")
finally:
if self.link: self.link.teardown()
########################
# Work Docs Management #
########################
def work_list(self, remote=None, scope="active"):
if not remote: self.abort(f"No remote specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "list", "scope": scope}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
print("\r \r", end="")
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1: result = mp.unpackb(response[1:])
else: result = {"active": [], "completed": []}
scopes_to_show = ["active", "completed", "proposed"] if scope == "all" else [scope]
for s in scopes_to_show:
docs = result.get(s, [])
if docs:
st = f"\n{s.capitalize()} documents"
print(st)
print("="*len(st)); print()
print(f"{'ID':<4} {'Title':<30} {'Author':<17} {'Created':<18} {'Comments'}")
print("-" * 80)
for doc in docs:
doc_id = doc.get("id", "?")
title = doc.get("title", "Untitled")
if len(title) > 29: title = f"{title[:29]}"
author = doc.get("author", "")[:16]+""
created_ts = doc.get("created", 0)
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(created_ts)) if created_ts else "unknown"
comments = doc.get("comments", 0)
print(f"{doc_id:<4} {title:<30} {author:<17} {created:<18} {comments}")
print()
elif scope != "all": print(f"No {s} work documents found.")
if scope == "all" and not result.get("active") and not result.get("completed") and not result.get("proposed"): print("No work documents found.")
except Exception as e: self.abort(f"Error listing work documents: {e}")
finally:
if self.link: self.link.teardown()
def work_view(self, remote=None, doc_id=None, scope="active"):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "view", "doc_id": doc_id, "scope": scope}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
print("\r \r", end="")
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) <= 1: self.abort("Empty response from remote")
doc = mp.unpackb(response[1:])
author_str = f"{doc['meta']['author']} (not locally validated)"
signature_str = "Document not signed"
signature = doc["meta"].get("signature", None)
pubkey = doc["meta"].get("identity", None)
content = doc.get("content", "")
if signature and type(signature) == bytes and len(signature) == RNS.Identity.SIGLENGTH//8:
if pubkey and type(pubkey) == bytes and len(pubkey) == RNS.Identity.KEYSIZE//8:
signature_str = "Not valid"
identity = RNS.Identity(create_keys=False)
identity.load_public_key(pubkey)
signature_validated = identity.validate(signature, content.encode("utf-8"))
if signature_validated:
signature_str = "Valid"
author_str = RNS.prettyhexrep(identity.hash)
dt = f"{doc['meta']['title']} (#{doc['id']})"
print(f"{dt}")
print("="*len(dt))
print(f"Author : {author_str}")
print(f"Signature : {signature_str}")
print(f"Status : {scope.capitalize()}")
print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['created']))}")
print(f"Edited : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['edited']))}")
print(f"Format : {doc['meta']['format']}")
print(f"Updates : {len(doc.get('comments', []))}")
print()
print(doc['content'])
comments = doc.get('comments', [])
if comments:
print("\nUpdates")
print("=======")
for c in comments:
ts = f"#{c['id']} by {c['author']} at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(c['created']))}"
print(f"\n{ts}")
print("-"*len(ts))
print(c['content'])
print()
except Exception as e: self.abort(f"Error viewing work document: {e}")
finally:
if self.link: self.link.teardown()
def work_create(self, remote=None, title=None):
if not remote: self.abort(f"No remote specified")
if not title: self.abort(f"No title specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
content = self._edit_work_content(title=title)
if content is None: print("Creation cancelled"); return
signature = self.identity.sign(content.encode("utf-8"))
if not signature: self.abort("Could not sign work document")
request_data = { self.IDX_REPOSITORY: repo_path, "operation": "create",
"title": title, "content": content, "format": "markdown",
"signature": signature }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=600)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document created as {result['scope']} #{result['id']}")
else: print("Work document created")
except Exception as e: self.abort(f"Error creating work document: {e}")
finally:
if self.link: self.link.teardown()
def work_propose(self, remote=None, title=None):
if not remote: self.abort(f"No remote specified")
if not title: self.abort(f"No title specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
content = self._edit_work_content(title=title)
if content is None: print("Proposal cancelled"); return
signature = self.identity.sign(content.encode("utf-8"))
if not signature: self.abort("Could not sign work document")
request_data = { self.IDX_REPOSITORY: repo_path, "operation": "propose",
"title": title, "content": content, "format": "markdown",
"signature": signature }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=600)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document created as {result['scope']} #{result['id']}")
else: print("Work document proposed")
except Exception as e: self.abort(f"Error creating work document: {e}")
finally:
if self.link: self.link.teardown()
def work_edit(self, remote=None, doc_id=None, title=None, scope="active"):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "view", "doc_id": doc_id, "scope": scope}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=600)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
doc = mp.unpackb(response[1:])
current_content = doc['content']
current_title = doc['meta']['title']
content = self._edit_work_content(title=current_title, content=current_content)
if content is None: print("Edit cancelled"); return
signature = self.identity.sign(content.encode("utf-8"))
if not signature: self.abort("Could not sign work document")
title = title or current_title
request_data = { self.IDX_REPOSITORY: repo_path, "operation": "edit", "doc_id": doc_id,
"scope": scope, "content": content, "title": title, "signature": signature }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=600)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Work document {scope} #{doc_id} updated")
except Exception as e: self.abort(f"Error editing work document: {e}")
finally:
if self.link: self.link.teardown()
def work_delete(self, remote=None, doc_id=None, scope="active"):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
print(f"Are you sure you want to delete {scope} work document #{doc_id}? [y/N]: ", end="")
try: confirm = input().strip().lower()
except EOFError: confirm = "n"
if confirm != "y": print("Deletion cancelled"); return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "delete", "doc_id": doc_id, "scope": scope }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Work document {scope} #{doc_id} deleted")
except Exception as e: self.abort(f"Error deleting work document: {e}")
finally:
if self.link: self.link.teardown()
def work_comment(self, remote=None, doc_id=None, scope="active"):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
# Get content from editor
content = self._edit_work_content(title=f"Update on document #{doc_id}", is_comment=True)
if content is None: print("Update cancelled"); return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "comment", "doc_id": doc_id, "scope": scope,
"content": content, "format": "markdown" }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=600)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Update #{result['id']} added to {scope} document #{doc_id}")
else: print("Update added")
except Exception as e: self.abort(f"Error adding comment: {e}")
finally:
if self.link: self.link.teardown()
def work_complete(self, remote=None, doc_id=None):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path,
"operation": "complete", "doc_id": doc_id}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document #{result['id']} completed")
else: print("Work document completed")
except Exception as e: self.abort(f"Error completing work document: {e}")
finally:
if self.link: self.link.teardown()
def work_activate(self, remote=None, doc_id=None):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path,
"operation": "activate", "doc_id": doc_id}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document #{result['id']} activated")
else: print("Work document activated")
except Exception as e: self.abort(f"Error activating work document: {e}")
finally:
if self.link: self.link.teardown()
def work_permissions(self, remote=None, doc_id=None):
if not remote: self.abort(f"No remote specified")
if doc_id is None: self.abort(f"No document ID specified")
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(self.wait_sleep)
timeout -= self.wait_sleep
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path,
"operation": "perms", "doc_id": doc_id, "step": "get"}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
current_content = result.get("content", "")
else: current_content = ""
content = self._edit_permissions(content=current_content)
if content is None: print("Edit cancelled"); return
request_data = {self.IDX_REPOSITORY: repo_path,
"operation": "perms", "doc_id": doc_id, "step": "set",
"content": content}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=120)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Permissions updated for work document #{doc_id}")
except Exception as e: self.abort(f"Error editing permissions: {e}")
finally:
if self.link: self.link.teardown()
##################
# Editor Helpers #
##################
def _edit_work_content(self, title="", content="", is_comment=False):
editor = os.environ.get("EDITOR", "")
if not editor:
for fallback in ["nano", "vim", "vi"]:
try:
subprocess.run(["which", fallback], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
editor = fallback
break
except subprocess.CalledProcessError: continue
if not editor:
print("No editor found. Please set $EDITOR environment variable.")
return None
if is_comment: template = COMMENT_TEMPLATE
else: template = CREATE_DOC_TEMPLATE
if content: template = content
try:
with NamedTemporaryFile(mode="w+", suffix=".md", delete=False) as tmp:
tmp_path = tmp.name
tmp.write(template)
result = subprocess.run([editor, tmp_path])
if result.returncode != 0:
print(f"Editor exited with error code {result.returncode}")
os.unlink(tmp_path)
return None
with open(tmp_path, "r") as f: edited = f.read()
os.unlink(tmp_path)
lines = [line for line in edited.split("\n") if not (line.strip().startswith(COMMENT_TEMPLATE) or line.strip().startswith(CREATE_DOC_TEMPLATE))]
result = "\n".join(lines).strip()
if not result: return None
return result
except Exception as e:
RNS.log(f"Error editing work content: {e}", RNS.LOG_ERROR)
return None
def _edit_permissions(self, content=""):
editor = os.environ.get("EDITOR", "")
if not editor:
for fallback in ["nano", "vim", "vi"]:
try:
subprocess.run(["which", fallback], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
editor = fallback
break
except subprocess.CalledProcessError: continue
if not editor:
print("No editor found. Please set $EDITOR environment variable.")
return None
if content: template = content
else: template = PERMISSIONS_TEMPLATE
try:
with NamedTemporaryFile(mode="w+", suffix=".txt", delete=False) as tmp:
tmp_path = tmp.name
tmp.write(template)
result = subprocess.run([editor, tmp_path])
if result.returncode != 0:
print(f"Editor exited with error code {result.returncode}")
os.unlink(tmp_path)
return None
with open(tmp_path, "r") as f: edited = f.read()
os.unlink(tmp_path)
return edited
except Exception as e:
RNS.log(f"Error editing permissions: {e}", RNS.LOG_ERROR)
return None
class ReticulumGitNode():
JOBS_INTERVAL = 5
PERM_READ = 0x01
PERM_WRITE = 0x02
PERM_READWRITE = 0x03
PERM_CREATE = 0x04
PERM_STATS = 0x05
PERM_RELEASE = 0x06
PERM_INTERACT = 0x07
PERM_PROPOSE = 0x08
PERM_ADMIN = 0xFE
PERM_R_SMPHR = ["r", "read"]
PERM_W_SMPHR = ["w", "write"]
PERM_RW_SMPHR = ["rw", "readwrite"]
PERM_C_SMPHR = ["c", "create"]
PERM_S_SMPHR = ["s", "stats"]
PERM_REL_SMPHR = ["rel", "release"]
PERM_I_SMPHR = ["i", "interact"]
PERM_P_SMPHR = ["p", "propose"]
PERM_ADM_SMPHR = ["adm", "admin"]
ALL_PERMS = ["read", "write", "create", "stats", "release", "interact", "propose", "admin"]
TGT_NONE = 0x01
TGT_ALL = 0x02
TGT_NONE_SMPHR = ["n", "none", "nobody"]
TGT_ALL_SMPHR = ["a", "all", "everyone"]
ALL_TGTS = TGT_NONE_SMPHR+TGT_ALL_SMPHR
PATH_LIST = "/git/list"
PATH_FETCH = "/git/fetch"
PATH_PUSH = "/git/push"
PATH_DELETE = "/git/delete"
PATH_CREATE = "/git/create"
PATH_FORK = "/git/fork"
PATH_SYNC = "/git/sync"
PATH_MIRROR = "/git/mirror"
PATH_RELEASE = "/mgmt/release"
PATH_WORK = "/mgmt/work"
PATH_PERMS = "/mgmt/perms"
RES_OK = 0x00
RES_DISALLOWED = 0x01
RES_INVALID_REQ = 0x02
RES_NOT_FOUND = 0x03
RES_REMOTE_FAIL = 0xFF
IDX_REPOSITORY = 0x00
IDX_RESULT_CODE = 0x01
IDX_GROUP = 0x02
WORK_DOC_LIMIT = 256*1024
CLONE_PROTOS = ["rns", "http", "https", "ssh"]
def __init__(self, configdir=None, verbosity=None, print_identity=False):
self.identity = None
self.userdir = os.path.expanduser("~")
self.global_allow = RNS.Destination.ALLOW_ALL
self.identity_aliases = {}
self.groups = {}
self.active_links = {}
self.page_servers = {}
self.stats = {}
self.blocked_identities = {}
self.last_announce = 0
self.announce_interval = 0
self.stats_enabled = False
self.stats_job_interval = 180
self.last_stats_job = time.time()
self.link_clean_interval = 5
self.last_link_clean = 0
self.mirror_interval = 24*60*60
self.sync_check_interval = 15*60
self.last_sync_check = time.time()
self.active_links_lock = Lock()
self.stats_lock = Lock()
self.sync_lock = Lock()
self.stats_ignored = {}
self.stats_push_ignored = {}
self.node_name = "Anonymous Git Node"
self.config = None
self.verbosity = verbosity or 0
self.ready = False
self._should_run = False
self._serve_nomadnet = False
if not self._ensure_git(): RNS.log("The \"git\" command is not available. Aborting server startup.", RNS.LOG_ERROR)
else:
if configdir != None: self.configdir = configdir
else:
if os.path.isdir("/etc/rngit") and os.path.isfile("/etc/rngit/config"):
self.configdir = "/etc/rngit"
elif os.path.isdir(self.userdir+"/.config/rngit") and os.path.isfile(self.userdir+"/.config/rngit/config"):
self.configdir = self.userdir+"/.rngit/reticulum"
else:
self.configdir = self.userdir+"/.rngit"
RNS.logfile = self.configdir+"/server_log"
self.configpath = self.configdir+"/config"
self.identitypath = self.configdir+"/repositories_identity"
self.statspath = self.configdir+"/stats"
if os.path.isfile(self.configpath):
try: self.config = ConfigObj(self.configpath)
except Exception as e:
RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR)
RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR)
RNS.panic()
else:
RNS.log("Could not load config file, creating default configuration file...")
self.__create_default_config()
RNS.log("Default config file created, make any necessary changes in "+self.configdir+"/config")
self.__apply_config()
self.__load_stats()
if print_identity:
client_identity_path = self.configdir+"/client_identity"
if not os.path.isfile(client_identity_path):
client_identity = RNS.Identity()
client_identity.to_file(client_identity_path)
RNS.log(f"Client identity generated and persisted to {client_identity_path}", RNS.LOG_VERBOSE)
else: client_identity = RNS.Identity.from_file(client_identity_path)
destination_hash = RNS.Destination.hash_from_name_and_identity(f"{APP_NAME}.repositories", self.identity)
nomadnet_hash = RNS.Destination.hash_from_name_and_identity(f"nomadnetwork.node", self.identity)
print(f"Git Peer Identity : {RNS.prettyhexrep(client_identity.hash)}")
print(f"Repository Node Identity : {RNS.prettyhexrep(self.identity.hash)}")
print(f"Repositories Destination : {RNS.prettyhexrep(destination_hash)}")
if self._serve_nomadnet: print(f"Nomad Network Destination : {RNS.prettyhexrep(nomadnet_hash)}")
exit(0)
self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "repositories")
self.destination.set_link_established_callback(self.remote_connected)
self.register_request_handlers()
RNS.log(f"Reticulum Git Node listening on {RNS.prettyhexrep(self.destination.hash)}", RNS.LOG_NOTICE)
if self._serve_nomadnet: self.page_servers["nomadnet"] = NomadNetworkNode(self)
self.ready = True
def __create_default_config(self):
self.config = ConfigObj(__default_rngit_config__)
self.config.filename = self.configpath
if not os.path.isdir(self.configdir): os.makedirs(self.configdir)
self.config.write()
def __load_stats(self):
with self.stats_lock:
self.stats = { "pages": {"front": {}}, "groups": {} }
if not os.path.isfile(self.statspath):
try:
with open(self.statspath, "wb") as fh: fh.write(mp.packb(self.stats))
except Exception as e: RNS.log(f"Could not persist stats to {self.statspath}: {e}", RNS.LOG_ERROR)
else:
try:
with open(self.statspath, "rb") as fh: self.stats = mp.unpackb(fh.read())
except Exception as e: RNS.log(f"Could not read stats file {self.statspath}: {e}", RNS.LOG_ERROR)
def __persist_stats(self):
with self.stats_lock:
try:
tmp_path = self.statspath+".tmp"
with open(tmp_path, "wb") as fh: fh.write(mp.packb(self.stats))
os.rename(tmp_path, self.statspath)
except Exception as e: RNS.log(f"Could not write stats file to {self.statspath}: {e}", RNS.LOG_ERROR)
def __sync_mirrors(self):
if self.sync_lock.locked(): return
with self.sync_lock:
try:
for group_name in self.groups.copy():
for repository_name in self.groups[group_name]["repositories"].copy():
repo = self.groups[group_name]["repositories"][repository_name]
if repo["mirror"] and time.time() > self.__mirror_synced(repo["path"]) + self.mirror_interval:
self.__sync_mirror(group_name, repository_name)
except Exception as e: RNS.log(f"Could not sync mirrors: {e}", RNS.LOG_ERROR)
def __sync_mirror(self, group_name, repository_name):
RNS.log(f"Syncing mirror {group_name}/{repository_name}", RNS.LOG_INFO)
try:
repo = self.groups[group_name]["repositories"][repository_name]
repository_path = repo["path"]
source_url = repo.get("mirror", False)
if not source_url or type(source_url) != str:
RNS.log(f"Could not determine upstream source for mirror {group_name}/{repository_name}", RNS.LOG_ERROR)
return False
result = subprocess.run(["git", "fetch", source_url, "+refs/*:refs/*"], cwd=repository_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to sync mirror {group_name}/{repository_name} from {source_url}: {result.stderr}", RNS.LOG_ERROR)
return False
try: self.__update_head_to_source_default(repository_path, source_url)
except Exception as e: RNS.log(f"Failed to update HEAD while syncing mirror {group_name}/{repository_name}: {e}", RNS.LOG_WARNING)
if self.__set_mirror_synced(repository_path):
RNS.log(f"Mirror {group_name}/{repository_name} synced successfully from {source_url}", RNS.LOG_INFO)
return True
else:
RNS.log(f"Mirror synced but could not update sync timestamp for {group_name}/{repository_name}", RNS.LOG_WARNING)
return True
except Exception as e:
RNS.log(f"Error syncing mirror {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return False
def __sync_fork(self, group_name, repository_name):
RNS.log(f"Syncing fork {group_name}/{repository_name} with upstream", RNS.LOG_INFO)
try:
repo = self.groups[group_name]["repositories"][repository_name]
repository_path = repo["path"]
source_url = repo.get("fork", False)
if not source_url or source_url == True:
RNS.log(f"Could not determine upstream source for fork {group_name}/{repository_name}", RNS.LOG_ERROR)
return False
result = subprocess.run(["git", "fetch", source_url, "+refs/*:refs/*"], cwd=repository_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to sync fork {group_name}/{repository_name} from {source_url}: {result.stderr}", RNS.LOG_ERROR)
return False
# TODO: Determine whether tracking remote HEAD is actually a good
# idea here. Seems best to just set it initially on clone, and then
# let the fork maintainer decide what HEAD is from then on.
# self.__update_head_to_source_default(repository_path, source_url)
if self.__set_mirror_synced(repository_path):
RNS.log(f"Fork {group_name}/{repository_name} synced successfully from {source_url}", RNS.LOG_INFO)
return True
else:
RNS.log(f"Fork synced but could not update sync timestamp for {group_name}/{repository_name}", RNS.LOG_WARNING)
return True
except Exception as e:
RNS.log(f"Error syncing fork {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return False
def __apply_config(self):
if not os.path.isfile(self.identitypath):
identity = RNS.Identity()
identity.to_file(self.identitypath)
RNS.log(f"Repositories identity generated and persisted to {self.identitypath}", RNS.LOG_VERBOSE)
else:
identity = RNS.Identity.from_file(self.identitypath)
RNS.log(f"Repositories identity loaded from {self.identitypath}", RNS.LOG_VERBOSE)
if not identity:
RNS.log(f"Could not initialize repositories identity. Exiting now.", RNS.LOG_ERROR)
RNS.panic()
else: self.identity = identity
if "aliases" in self.config:
section = self.config["aliases"]
for alias in section:
alias_hexhash = section[alias]
name_ok = not alias in self.ALL_TGTS
len_ok = len(alias_hexhash) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2
try: alias_hash = bytes.fromhex(alias_hexhash)
except: alias_hash = None
alias_exists = alias in self.identity_aliases
if not len_ok or not alias_hash: RNS.log(f"Invalid identity hash for alias {alias} in configuration file, ignoring", RNS.LOG_WARNING); continue
if not name_ok: RNS.log(f"Invalid alias {alias} in configuration file, ignoring", RNS.LOG_WARNING); continue
if alias_exists: RNS.log(f"Duplicate alias {alias} in configuration file, ignoring", RNS.LOG_WARNING); continue
self.identity_aliases[alias] = RNS.hexrep(alias_hash, delimit=False)
if "rngit" in self.config:
section = self.config["rngit"]
if "node_name" in section: self.node_name = section["node_name"]
if "announce_interval" in section: self.announce_interval = section.as_int("announce_interval")*60
if "mirror_interval" in section: self.mirror_interval = max(section.as_int("mirror_interval")*60*60, 0)
if "record_stats" in section: self.stats_enabled = section.as_bool("record_stats")
if "stats_ignore_identities" in section:
ignored = section.as_list("stats_ignore_identities")
for identhexhash in ignored:
identhexhash = self.__resolve_identity_alias(identhexhash)
if not len(identhexhash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: continue
else:
try: self.stats_ignored[bytes.fromhex(identhexhash)] = True
except Exception as e: RNS.log(f"Invalid identity hash for stats ignore: {identhexhash}", RNS.LOG_WARNING)
if "stats_push_ignore_identities" in section:
ignored = section.as_list("stats_push_ignore_identities")
for identhexhash in ignored:
identhexhash = self.__resolve_identity_alias(identhexhash)
if not len(identhexhash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: continue
else:
try: self.stats_push_ignored[bytes.fromhex(identhexhash)] = True
except Exception as e: RNS.log(f"Invalid identity hash for stats ignore: {identhexhash}", RNS.LOG_WARNING)
if "blocked_identities" in section:
blocked = section.as_list("blocked_identities")
for identhexhash in blocked:
identhexhash = self.__resolve_identity_alias(identhexhash)
if not len(identhexhash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: continue
else:
try: self.blocked_identities[bytes.fromhex(identhexhash)] = True
except Exception as e: RNS.log(f"Invalid identity hash for blocklist: {identhexhash}", RNS.LOG_WARNING)
if "logging" in self.config:
section = self.config["logging"]
if "loglevel" in section: RNS.loglevel = max(RNS.LOG_NONE, min(RNS.LOG_EXTREME, section.as_int("loglevel")+self.verbosity))
if "pages" in self.config:
section = self.config["pages"]
if "serve_nomadnet" in section and section.as_bool("serve_nomadnet"): self._serve_nomadnet = True
if "repositories" in self.config:
section = self.config["repositories"]
for group_name in section:
RNS.log(f"Loading repositery group \"{group_name}\"", RNS.LOG_VERBOSE)
group_path = os.path.expanduser(section[group_name])
if not os.path.isdir(group_path): RNS.log(f"The path \"{group_path}\" specified for repository group \"{group_name}\" does not exist, skipping.", RNS.LOG_WARNING)
else: self.load_repository_group(group_name, group_path)
def __resolve_identity_alias(self, alias):
def resolve(alias):
if alias.lower() in self.ALL_TGTS: return alias
len_match = len(alias) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2
try: hash_bytes = bytes.fromhex(alias)
except: hash_bytes = None
if len_match and hash_bytes: return alias
else: return self.identity_aliases[alias] if alias in self.identity_aliases else alias
resolved = resolve(alias)
return resolved
def parse_permission(self, permission_string):
comps = permission_string.split(":")
if not len(comps) == 2: return None, None
else:
perm = comps[0].lower(); target = comps[1]
target = self.__resolve_identity_alias(target)
if perm in self.PERM_R_SMPHR: perm = self.PERM_READ
elif perm in self.PERM_W_SMPHR: perm = self.PERM_WRITE
elif perm in self.PERM_RW_SMPHR: perm = self.PERM_READWRITE
elif perm in self.PERM_C_SMPHR: perm = self.PERM_CREATE
elif perm in self.PERM_S_SMPHR: perm = self.PERM_STATS
elif perm in self.PERM_REL_SMPHR: perm = self.PERM_RELEASE
elif perm in self.PERM_I_SMPHR: perm = self.PERM_INTERACT
elif perm in self.PERM_P_SMPHR: perm = self.PERM_PROPOSE
elif perm in self.PERM_ADM_SMPHR: perm = self.PERM_ADMIN
else: perm = None
if target in self.TGT_NONE_SMPHR: target = self.TGT_NONE
elif target in self.TGT_ALL_SMPHR: target = self.TGT_ALL
elif len(target) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
try: target = bytes.fromhex(target)
except Exception as e:
RNS.log(f"Invalid identity hash \"{target}\" in access permissions: {e}", RNS.LOG_ERROR)
target = None
else: target = None
return perm, target
def parse_request_repository_path(self, path):
components = path.split("/")
if not len(components) == 2: return None, None
else:
limit = 256
group = components[0]
repository_name = components[1]
if len(group) > limit or len(repository_name) > limit: return None, None
else: return group, repository_name
def parse_request_group_path(self, path):
components = path.split("/")
if not len(components) == 1: return None
else:
limit = 256
group = components[0]
if len(group) > limit: return None
else: return group
def resolve_permission(self, remote_identity, group_name, repository_name, permission):
remote_hash = remote_identity.hash
if remote_hash in self.blocked_identities: return False
RNS.log(f"Resolving {group_name}/{repository_name} permission {permission} for {RNS.prettyhexrep(remote_hash)}", RNS.LOG_DEBUG)
if not group_name in self.groups: return False
if not repository_name in self.groups[group_name]["repositories"]: return False
else:
if permission == self.PERM_READ:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["read"]
group_permissions = self.groups[group_name]["read"]
elif permission == self.PERM_WRITE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["write"]
group_permissions = self.groups[group_name]["write"]
elif permission == self.PERM_CREATE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["create"]
group_permissions = self.groups[group_name]["create"]
elif permission == self.PERM_STATS:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["stats"]
group_permissions = self.groups[group_name]["stats"]
elif permission == self.PERM_RELEASE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["release"]
group_permissions = self.groups[group_name]["release"]
elif permission == self.PERM_INTERACT:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["interact"]
group_permissions = self.groups[group_name]["interact"]
elif permission == self.PERM_PROPOSE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["propose"]
group_permissions = self.groups[group_name]["propose"]
elif permission == self.PERM_ADMIN:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["admin"]
group_permissions = self.groups[group_name]["admin"]
else: return False
repository_admins = self.groups[group_name]["repositories"][repository_name]["admin"]
group_admins = self.groups[group_name]["admin"]
if self.TGT_NONE in repository_permissions: return False
elif self.TGT_ALL in repository_permissions: return True
elif remote_hash in repository_permissions: return True
elif remote_hash in repository_admins: return True
else:
if len(repository_permissions) > 0: return False
elif self.TGT_NONE in group_permissions: return False
elif self.TGT_ALL in group_permissions: return True
elif remote_hash in group_permissions: return True
elif remote_hash in group_admins: return True
else: return False
return False
return False
def resolve_group_permission(self, remote_identity, group_name, permission):
remote_hash = remote_identity.hash
RNS.log(f"Resolving {group_name} group permission {permission} for {RNS.prettyhexrep(remote_hash)}", RNS.LOG_DEBUG)
if not group_name in self.groups: return False
else:
if permission == self.PERM_READ: group_permissions = self.groups[group_name]["read"]
elif permission == self.PERM_WRITE: group_permissions = self.groups[group_name]["write"]
elif permission == self.PERM_CREATE: group_permissions = self.groups[group_name]["create"]
elif permission == self.PERM_STATS: group_permissions = self.groups[group_name]["stats"]
elif permission == self.PERM_RELEASE: group_permissions = self.groups[group_name]["release"]
elif permission == self.PERM_INTERACT: group_permissions = self.groups[group_name]["interact"]
elif permission == self.PERM_PROPOSE: group_permissions = self.groups[group_name]["propose"]
elif permission == self.PERM_ADMIN: group_permissions = self.groups[group_name]["admin"]
else: return False
group_admins = self.groups[group_name]["admin"]
if self.TGT_NONE in group_permissions: return False
elif self.TGT_ALL in group_permissions: return True
elif remote_hash in group_permissions: return True
elif remote_hash in group_admins: return True
else: return False
return False
return False
def resolve_doc_permission(self, remote_identity, group_name, repository_name, doc_id, permission):
remote_hash = remote_identity.hash
RNS.log(f"Resolving {group_name}/{repository_name}/{doc_id} document permission {permission} for {RNS.prettyhexrep(remote_hash)}", RNS.LOG_DEBUG)
if not group_name in self.groups: return False
if not repository_name in self.groups[group_name]["repositories"]: return False
work_path = self.groups[group_name]["repositories"][repository_name]["path"]+".work"
doc_allowed_path = work_path+"/"+str(int(doc_id))+".allowed"
allowed_input = None
if os.path.isdir(work_path) and os.path.isfile(doc_allowed_path):
try:
with open(doc_allowed_path, "r") as fh: allowed_input = fh.read()
except Exception as e: RNS.log(f"Error while resolving document permission for {group_name}/{repository_name}/{doc_id}: {e}", RNS.LOG_ERROR)
doc_allowed_permissions = self.permissions_from_allowed_input(allowed_input)
if permission == self.PERM_READ:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["read"]
group_permissions = self.groups[group_name]["read"]
doc_permissions = doc_allowed_permissions["read"]
elif permission == self.PERM_WRITE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["write"]
group_permissions = self.groups[group_name]["write"]
doc_permissions = doc_allowed_permissions["write"]
elif permission == self.PERM_CREATE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["create"]
group_permissions = self.groups[group_name]["create"]
doc_permissions = doc_allowed_permissions["create"]
elif permission == self.PERM_INTERACT:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["interact"]
group_permissions = self.groups[group_name]["interact"]
doc_permissions = doc_allowed_permissions["interact"]
elif permission == self.PERM_PROPOSE:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["propose"]
group_permissions = self.groups[group_name]["propose"]
doc_permissions = doc_allowed_permissions["propose"]
elif permission == self.PERM_ADMIN:
repository_permissions = self.groups[group_name]["repositories"][repository_name]["admin"]
group_permissions = self.groups[group_name]["admin"]
doc_permissions = doc_allowed_permissions["admin"]
else: return False
repository_admins = self.groups[group_name]["repositories"][repository_name]["admin"]
group_admins = self.groups[group_name]["admin"]
doc_admins = doc_allowed_permissions["admin"]
if self.TGT_NONE in doc_permissions: return False
elif self.TGT_ALL in doc_permissions: return True
elif remote_hash in doc_permissions: return True
elif remote_hash in doc_admins: return True
else:
if self.TGT_NONE in repository_permissions: return False
elif self.TGT_ALL in repository_permissions: return True
elif remote_hash in repository_permissions: return True
elif remote_hash in repository_admins: return True
else:
if len(repository_permissions) > 0: return False
elif self.TGT_NONE in group_permissions: return False
elif self.TGT_ALL in group_permissions: return True
elif remote_hash in group_permissions: return True
elif remote_hash in group_admins: return True
else: return False
return False
def permissions_from_allowed_input(self, allowed_input):
read_allowed = []
write_allowed = []
create_allowed = []
stats_allowed = []
release_allowed = []
interact_allowed = []
propose_allowed = []
admin_allowed = []
if allowed_input and type(allowed_input) == str:
for entry in allowed_input.splitlines():
perm_input = entry.strip()
if not perm_input.startswith("#"):
perm, target = self.parse_permission(perm_input)
if not perm or not target: continue
else:
read = False; write = False; create = False; propose = False
stats = False; release = False; interact = False; admin = False
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
if perm == self.PERM_CREATE: create = True
if perm == self.PERM_STATS: stats = True
if perm == self.PERM_RELEASE: release = True
if perm == self.PERM_INTERACT: interact = True
if perm == self.PERM_PROPOSE: propose = True
if perm == self.PERM_ADMIN: admin = True
if read and not target in read_allowed: read_allowed.append(target)
if write and not target in write_allowed: write_allowed.append(target)
if create and not target in create_allowed: create_allowed.append(target)
if stats and not target in stats_allowed: stats_allowed.append(target)
if release and not target in release_allowed: release_allowed.append(target)
if interact and not target in interact_allowed: interact_allowed.append(target)
if propose and not target in propose_allowed: propose_allowed.append(target)
if admin and not target in admin_allowed: admin_allowed.append(target)
permissions = {"read": read_allowed, "write": write_allowed, "create": create_allowed, "stats": stats_allowed,
"release": release_allowed, "interact": interact_allowed, "propose": propose_allowed, "admin": admin_allowed }
return permissions
def load_repository_group(self, group_name, group_path):
if not group_name in self.groups: self.groups[group_name] = { "path": group_path, "name": group_name, "repositories": {}, "dynamic_perms": False,
"read": [], "write": [], "create": [], "stats": [], "release": [],
"interact": [], "propose": [], "admin": [] }
if group_name in self.groups and self.groups[group_name]["path"] != group_path:
RNS.log(f"Repository group path did not match existing entry while loading {group_name}, aborting load", RNS.LOG_ERROR)
return
self.update_group_permissions(group_name)
loaded = 0
group = self.groups[group_name]
for entry in os.listdir(group_path):
path = f"{group_path}/{entry}"
if self.load_repository(group, path): loaded += 1
ms = "y" if loaded == 1 else "ies"
RNS.log(f"Loaded {loaded} repositor{ms} for group \"{group_name}\"", RNS.LOG_VERBOSE)
def update_group_permissions(self, group_name):
if not group_name in self.groups:
RNS.log(f"Attempt to set group permissions for non-existing group {group_name}, aborting", RNS.LOG_WARNING)
return
# Clear permissions before update
for perm in self.ALL_PERMS: self.groups[group_name][perm] = []
# Apply permissions from allowed file if present
group_path = self.groups[group_name]["path"]
allowed_path = group_path+".allowed"
if os.path.isfile(allowed_path):
RNS.log(f"Applying group permissions for {group_name} from {allowed_path}", RNS.LOG_DEBUG)
try:
allowed_input = ""
if os.access(allowed_path, os.X_OK):
allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE)
allowed_input = allowed_result.stdout.decode("utf-8")
self.groups[group_name]["dynamic_perms"] = True
else:
fh = open(allowed_path, "rb")
allowed_input = fh.read().decode("utf-8")
fh.close()
group_permissions = self.permissions_from_allowed_input(allowed_input)
for perm in group_permissions: self.groups[group_name][perm] = group_permissions[perm]
except Exception as e: RNS.log(f"Could not load group permissions from {allowed_path}: {e}", RNS.LOG_ERROR)
# Apply permissions from config file if present
if "access" in self.config:
section = self.config["access"]
for config_group_name in section:
if group_name == config_group_name:
RNS.log(f"Applying group permissions for {group_name} from config file", RNS.LOG_DEBUG)
config_group_permissions = section.as_list(group_name)
for entry in config_group_permissions:
perm, target = self.parse_permission(entry)
if not perm or not target: continue
else:
read = False; write = False; create = False; propose = False
stats = False; release = False; interact = False; admin = False
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
if perm == self.PERM_CREATE: create = True
if perm == self.PERM_STATS: stats = True
if perm == self.PERM_RELEASE: release = True
if perm == self.PERM_INTERACT: interact = True
if perm == self.PERM_PROPOSE: propose = True
if perm == self.PERM_ADMIN: admin = True
if read and not target in self.groups[group_name]["read"]: self.groups[group_name]["read"].append(target)
if write and not target in self.groups[group_name]["write"]: self.groups[group_name]["write"].append(target)
if create and not target in self.groups[group_name]["create"]: self.groups[group_name]["create"].append(target)
if stats and not target in self.groups[group_name]["stats"]: self.groups[group_name]["stats"].append(target)
if release and not target in self.groups[group_name]["release"]: self.groups[group_name]["release"].append(target)
if interact and not target in self.groups[group_name]["interact"]: self.groups[group_name]["interact"].append(target)
if propose and not target in self.groups[group_name]["propose"]: self.groups[group_name]["propose"].append(target)
if admin and not target in self.groups[group_name]["admin"]: self.groups[group_name]["admin"].append(target)
def load_repository(self, group, path):
if not group or not path: return False
group_name = group["name"]
if not (os.path.isdir(path) and not path.endswith(".work") and not path.endswith(".releases")): return False
else:
if not self.__is_git_repository(path): RNS.log(f"The directory \"{path}\" is not a git repository, skipping", RNS.LOG_WARNING)
else:
if not self.__is_bare_repository(path):
RNS.log(f"The directory \"{path}\" is not a bare git repository, skipping", RNS.LOG_WARNING)
RNS.log(f"You can change it to a bare repository using \"git config --bool core.bare true\".", RNS.LOG_WARNING)
else:
repository_name = os.path.basename(path)
allowed_path = f"{path}.allowed"
allowed_input = ""
dynamic_perms = False
if os.path.isfile(allowed_path):
if os.access(allowed_path, os.X_OK):
allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE)
allowed_input = allowed_result.stdout.decode("utf-8")
dynamic_perms = True
else:
fh = open(allowed_path, "rb")
allowed_input = fh.read().decode("utf-8")
fh.close()
fork = self.__is_fork(path)
mirror = self.__is_mirror(path)
p = self.permissions_from_allowed_input(allowed_input)
group["repositories"][repository_name] = { "name": repository_name, "group": group_name, "path": path, "fork": fork, "mirror": mirror }
for perm in self.ALL_PERMS: group["repositories"][repository_name][perm] = p[perm] if perm in p else []
return True
return False
def start(self):
self._should_run = True
threading.Thread(target=self.jobs, daemon=True).start()
def announce(self):
RNS.log("Announcing repositories destination", RNS.LOG_VERBOSE)
self.destination.announce()
self.last_announce = time.time()
def jobs(self):
while self._should_run:
time.sleep(self.JOBS_INTERVAL)
try:
if self.announce_interval and time.time() > self.last_announce + self.announce_interval:
self.announce()
if time.time() > self.last_stats_job + self.stats_job_interval:
self.__persist_stats()
self.last_stats_job = time.time()
if self.mirror_interval > 0 and time.time() > self.last_sync_check + self.sync_check_interval:
self.__sync_mirrors()
self.last_sync_check = time.time()
if time.time() > self.last_link_clean + self.link_clean_interval:
stale_links = []
with self.active_links_lock:
for link_id in self.active_links:
link = self.active_links[link_id]
if not link.status == RNS.Link.ACTIVE: stale_links.append(link_id)
cleaned_links = 0
for link_id in stale_links:
link = None
with self.active_links_lock:
if link_id in self.active_links:
link = self.active_links.pop(link_id)
cleaned_links += 1
if link and hasattr(link, "temporary_directories"):
for tmpdir in link.temporary_directories:
try:
tmpdir.cleanup()
RNS.log(f"Cleaned up {tmpdir.name}", RNS.LOG_DEBUG)
except Exception as e:
RNS.log(f"Error while cleaning temporary directory: {e}", RNS.LOG_ERROR)
self.last_link_clean = time.time()
if cleaned_links > 0: RNS.log(f"Cleaned {cleaned_links} links", RNS.LOG_DEBUG)
except Exception as e: RNS.log(f"Error while running periodic jobs: {e}", RNS.LOG_ERROR)
##################
# System Helpers #
##################
def log_request(self, msg, remote_identity):
if remote_identity and remote_identity.hash in self.blocked_identities: RNS.log(f"Blocked: {msg}", RNS.LOG_DEBUG)
else: RNS.log(msg, RNS.LOG_VERBOSE)
@staticmethod
def _ensure_git():
try: subprocess.run(["git", "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); return True
except: return False
def __is_git_repository(self, path):
try:
result = subprocess.run(["git", "rev-parse", "--git-dir"], cwd=path, check=True, capture_output=True, text=True)
if not result: return False
else: check = result.stdout.strip()
if check == ".": return True
else: return False
except: return False
def __is_bare_repository(self, path):
try:
result = subprocess.run(["git", "config", "--bool", "core.bare"], cwd=path, check=True, capture_output=True, text=True)
if not result: return False
else: check = result.stdout.strip()
if check == "true": return True
else: return False
except: return False
def __is_fork(self, path):
try:
result = subprocess.run(["git", "config", "repository.rngit.type"], cwd=path, check=True, capture_output=True, text=True)
if not result: return False
else: check = result.stdout.strip()
if not check == "fork": return False
else:
result = subprocess.run(["git", "config", "repository.rngit.upstream.source"], cwd=path, check=True, capture_output=True, text=True)
if not result: return False
else: source = result.stdout.strip()
return source
except: return False
def __is_mirror(self, path):
try:
result = subprocess.run(["git", "config", "repository.rngit.type"], cwd=path, check=True, capture_output=True, text=True)
if not result: return False
else: check = result.stdout.strip()
if not check == "mirror": return False
else:
result = subprocess.run(["git", "config", "repository.rngit.upstream.source"], cwd=path, check=True, capture_output=True, text=True)
if not result: return False
else: source = result.stdout.strip()
return source
except: return False
def __mirror_synced(self, path):
try:
result = subprocess.run(["git", "config", "repository.rngit.upstream.sync"], cwd=path, check=True, capture_output=True, text=True)
if not result: return 0
else: synced = int(result.stdout.strip())
return synced
except: return False
def __set_mirror_synced(self, path):
try:
result = subprocess.run(["git", "config", "repository.rngit.upstream.sync", str(int(time.time()))], cwd=path, check=True, capture_output=True, text=True)
if result: return True
else:
RNS.log(f"Could not set mirror sync time for {path}: {result.stderr}", RNS.LOG_ERROR)
return False
except: return False
def last_upstream_sync(self, path):
sync_time = self.__mirror_synced(path)
return sync_time if sync_time else 0
def __update_head_to_source_default(self, repo_path, source_url):
RNS.log(f"Updating HEAD for {repo_path} from {source_url}", RNS.LOG_VERBOSE)
target_branch = None
try:
result = subprocess.run(["git", "ls-remote", "--symref", source_url, "HEAD"],
capture_output=True, text=True, timeout=30, check=False)
if result.returncode == 0:
for line in result.stdout.split("\n"):
if line.startswith("ref: refs/heads/"):
parts = line.split("\t")
if len(parts) >= 2 and parts[1] == "HEAD":
target_branch = parts[0][5:].strip() # Remove "ref: " prefixes
break
except subprocess.TimeoutExpired: RNS.log(f"Timeout querying remote HEAD from {source_url}", RNS.LOG_WARNING)
except Exception as e: RNS.log(f"Could not query remote HEAD from {source_url}: {e}", RNS.LOG_ERROR)
if target_branch:
RNS.log(f"Determined target branch as {target_branch} from {source_url}", RNS.LOG_DEBUG)
branch_check = subprocess.run(["git", "show-ref", "--verify", "--quiet", target_branch],
cwd=repo_path, capture_output=True, check=False)
if branch_check.returncode != 0:
RNS.log(f"Remote default branch {target_branch} not found locally, using fallback", RNS.LOG_WARNING)
target_branch = None
if not target_branch:
try:
RNS.log(f"Could not determine target branch from {source_url}, falling back to first available local branch", RNS.LOG_WARNING)
result = subprocess.run(["git", "for-each-ref", "--format=%(refname:short)", "refs/heads", "--count=1"],
cwd=repo_path, capture_output=True, text=True, check=False)
if result.returncode == 0 and result.stdout.strip(): target_branch = f"refs/heads/{result.stdout.strip()}"
else: return False
except Exception as e:
RNS.log(f"Could not determine fallback branch for {repo_path}: {e}", RNS.LOG_ERROR)
return False
try:
result = subprocess.run(["git", "symbolic-ref", "HEAD", target_branch],
cwd=repo_path, capture_output=True, text=True, check=False)
if result.returncode == 0: RNS.log(f"Updated {repo_path} HEAD to {target_branch}", RNS.LOG_VERBOSE); return True
else: RNS.log(f"Failed to update HEAD to {target_branch}: {result.stderr}", RNS.LOG_WARNING); return False
except Exception as e:
RNS.log(f"Error updating HEAD: {e}", RNS.LOG_ERROR)
return False
######################
# Connectivity Setup #
######################
def register_request_handlers(self):
ga_list = self.global_allowed_list if self.global_allow == RNS.Destination.ALLOW_LIST else None
self.destination.register_request_handler(self.PATH_LIST, self.handle_list, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_FETCH, self.handle_fetch, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_PUSH, self.handle_push, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_CREATE, self.handle_create, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_PERMS, self.handle_perms, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_FORK, self.handle_fork, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_SYNC, self.handle_sync, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_MIRROR, self.handle_mirror, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_DELETE, self.handle_delete, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_RELEASE, self.handle_release, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_WORK, self.handle_work, allow=self.global_allow, allowed_list=ga_list)
def remote_connected(self, link):
RNS.log(f"Peer connected to {self.destination}", RNS.LOG_DEBUG)
link.set_remote_identified_callback(self.remote_identified)
link.set_link_closed_callback(self.remote_disconnected)
def remote_disconnected(self, link):
RNS.log(f"Peer disconnected from {self.destination}", RNS.LOG_DEBUG)
def remote_identified(self, link, identity):
self.active_links[link.link_id] = link
RNS.log(f"Peer identified as {link.get_remote_identity()} on {link}", RNS.LOG_DEBUG)
###########################
# Git Operations Handlers #
###########################
def handle_list(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"List request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
# Check for_push permission if requested
for_push = data.get("for_push", False)
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
if for_push: access = write_access
else: access = read_access
if not access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not allowed" if read_access else self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
try:
RNS.log(f"Listing refs for {group_name}/{repository_name}", RNS.LOG_VERBOSE)
# Get HEAD symref
head_path = os.path.join(repository_path, "HEAD")
head_ref = "master" # Use "master" as default
if os.path.exists(head_path):
with open(head_path, "rb") as fh:
head_content = fh.read()
if head_content.startswith(b"ref: "): head_ref = head_content[5:].strip().decode("utf-8", errors="ignore")
execv = ["git", "for-each-ref", "--format", "%(objectname) %(refname)"]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False, text=True)
if result.returncode != 0:
RNS.log(f"Error while listing refs for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not list refs"
# Build response in format: refs + @<ref> HEAD
response_lines = result.stdout.strip()
# Deduplicate refs - TODO: Re-evaluate this
seen_refs = set()
unique_lines = []
for line in response_lines.split('\n'):
if not line.strip(): continue
parts = line.split(' ', 1)
if len(parts) == 2:
ref_name = parts[1]
if ref_name not in seen_refs:
seen_refs.add(ref_name)
unique_lines.append(line)
if unique_lines: output = '\n'.join(unique_lines) + f"\n@{head_ref} HEAD\n"
else: output = f"@{head_ref} HEAD\n"
return b"\x00" + output.encode("utf-8")
except Exception as e:
RNS.log(f"Error while handling list request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_fetch(self, path, data, request_id, link_id, remote_identity, requested_at):
self.log_request(f"Fetch request from remote {remote_identity}", remote_identity)
with self.active_links_lock:
if not link_id in self.active_links: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
else: link = self.active_links[link_id]
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
refs = data.get("refs", [])
if not refs: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No refs specified"
try:
ref_names = san_refs([r["ref"] for r in refs])
if not ref_names: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
RNS.log(f"Fetching refs {ref_names} for {group_name}/{repository_name}", RNS.LOG_VERBOSE)
if not hasattr(link, "temporary_directories"): link.temporary_directories = []
tmpdir = TemporaryDirectory()
link.temporary_directories.append(tmpdir)
tmp_path = tmpdir.name
RNS.log(f"Created {tmp_path} for {link}", RNS.LOG_DEBUG)
bundle_path = os.path.join(tmp_path, "fetch.bundle")
execv = ["git", "bundle", "create", "--no-progress", bundle_path]
for r in refs:
if not san_ref(r["ref"]): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
else:
execv.append(r["ref"])
# Per-ref have: The client already has this ancestor,
# so the server can exclude objects reachable from it.
if "have" in r and r["have"]:
have_sha = san_sha(r["have"])
if not have_sha: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
cat_result = subprocess.run(["git", "cat-file", "-t", have_sha], cwd=repository_path, capture_output=True, check=False)
if cat_result.returncode == 0: execv.append(f"^{have_sha}")
else: RNS.log(f"Client have-sha {have_sha} not found in repository, skipping", RNS.LOG_WARNING)
# Global have list: SHAs of objects the client already has.
# Exclude objects reachable from these to produce thin bundles.
have_shas = data.get("have", [])
for sha in have_shas:
if not san_sha(sha): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
cat_result = subprocess.run(["git", "cat-file", "-t", sha], cwd=repository_path, capture_output=True, check=False)
if cat_result.returncode == 0: execv.append(f"^{sha}")
else: RNS.log(f"Client have-sha {sha} not found in repository, skipping", RNS.LOG_WARNING)
result = subprocess.run(execv, cwd=repository_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
if "empty bundle" in result.stderr.lower():
# All objects reachable from the requested refs are already
# available to the client. Return success with no bundle data.
RNS.log(f"Empty bundle for {ref_names}, all objects already on client", RNS.LOG_DEBUG)
return b"\x00"
else:
RNS.log(f"Error while fetching refs {ref_names} for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not fetch refs"
self.fetch_succeeded(group_name, repository_name, remote_identity)
return open(bundle_path, "rb"), {self.IDX_RESULT_CODE: self.RES_OK}
except Exception as e:
RNS.log(f"Error while handling fetch request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_push(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Push request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
if not write_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
local_ref = san_ref(data.get("local_ref", ""))
remote_ref = san_ref(data.get("remote_ref", ""))
force = data.get("force", False)
bundle_data = data.get("bundle", None)
operations = data.get("operations", None)
if bundle_data:
if not local_ref or not remote_ref: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Missing ref specification"
try:
RNS.log(f"Push {local_ref}:{remote_ref} to {group_name}/{repository_name}", RNS.LOG_VERBOSE)
with TemporaryDirectory() as tmpdir:
bundle_path = os.path.join(tmpdir, "push.bundle")
if isinstance(bundle_data, str): bundle_data = bundle_data.encode("utf-8")
with open(bundle_path, "wb") as f: f.write(bundle_data)
execv = ["git", "bundle", "verify", bundle_path]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0:
RNS.log(f"Bundle verification failed for push {local_ref}:{remote_ref} to {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not verify bundle"
execv = ["git", "fetch", bundle_path, f"{local_ref}:{remote_ref}"]
if force: execv.append("--force")
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0:
RNS.log(f"Bundle verification failed for push {local_ref}:{remote_ref} to {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not verify bundle"
self.push_succeeded(group_name, repository_name, remote_identity)
return b"\x00"
except Exception as e:
RNS.log(f"Error while handling push request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
elif operations:
if not type(operations) == list: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid data for operations"
try:
for op in operations:
action = op.get("action", "")
ref = san_ref(op.get("ref", ""))
sha = san_sha(op.get("sha", ""))
op_force = op.get("force", False)
if action != "update_ref": return self.RES_INVALID_REQ.to_bytes(1, "big") + f"Unknown operation: {action}".encode("utf-8")
if not ref: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not ref.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not sha: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
# Verify the target object exists in the repository
cat_result = subprocess.run(["git", "cat-file", "-t", sha], cwd=repository_path, capture_output=True, check=False)
if cat_result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Object {sha} does not exist in repository".encode("utf-8")
# Check force flag: If the ref already exists and
# points to a different SHA, the push must be forced.
rev_result = subprocess.run(["git", "rev-parse", ref], cwd=repository_path, capture_output=True, text=True, check=False)
if rev_result.returncode == 0:
existing_sha = rev_result.stdout.strip()
if existing_sha != sha and not op_force:
return self.RES_DISALLOWED.to_bytes(1, "big") + f"Ref {ref} already exists at different SHA (force required)".encode("utf-8")
RNS.log(f"Updating ref {ref} to {sha} in {group_name}/{repository_name}", RNS.LOG_VERBOSE)
execv = ["git", "update-ref", ref, sha]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0:
RNS.log(f"Error while updating ref {ref} to {sha} for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not update refs"
self.push_succeeded(group_name, repository_name, remote_identity)
return b"\x00"
except Exception as e:
RNS.log(f"Error while handling push operations for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request data"
def handle_delete(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Delete request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
if not write_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
ref_to_delete = san_ref(data.get("ref", ""))
if not ref_to_delete: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not ref_to_delete.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
try:
RNS.log(f"Deleting ref {ref_to_delete} in {group_name}/{repository_name}", RNS.LOG_VERBOSE)
execv = ["git", "update-ref", "-d", ref_to_delete]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0:
RNS.log(f"Error while deleting ref {ref_to_delete} for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not delete ref"
else:
self.push_succeeded(group_name, repository_name, remote_identity)
return b"\x00"
except Exception as e:
RNS.log(f"Error while handling delete request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
##################################
# Repository Management Handlers #
##################################
def handle_create(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Create request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
if not group_name or not repository_name: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not group_name in self.groups: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
create_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_CREATE)
group_path = self.groups[group_name]["path"]
if not os.path.exists(group_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not create_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = os.path.join(group_path, repository_name)
repository_exists = group_name in self.groups and repository_name in self.groups[group_name]["repositories"]
path_exists = os.path.exists(repository_path)
if repository_exists or path_exists:
existing_read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
if existing_read_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Repository already exists"
else: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
else:
try:
RNS.log(f"Creating repository {group_name}/{repository_name} for {remote_identity}", RNS.LOG_VERBOSE)
os.makedirs(repository_path, mode=0o755)
result = subprocess.run(["git", "init", "--bare"], cwd=repository_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to initialize bare repository at {repository_path}: {result.stderr}", RNS.LOG_ERROR)
try: shutil.rmtree(repository_path)
except: RNS.log(f"Could not clean up failed repository init at {repository_path}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not initialize repository"
try:
allowed_path = repository_path+".allowed"
tmp_allowed = allowed_path + ".tmp"
repository_permissions = REPO_CREATE_PERMS_TEMPLATE.replace("{IDENTITY_HASH}", RNS.hexrep(remote_identity.hash, delimit=False))
with open(tmp_allowed, "w", encoding="utf-8") as fh: fh.write(repository_permissions)
os.rename(tmp_allowed, allowed_path)
except Exception as e:
RNS.log(f"Could not set default repository permissions for {group_name}/{repository_name}", RNS.LOG_ERROR)
try: shutil.rmtree(repository_path)
except: RNS.log(f"Could not clean up failed repository init at {repository_path}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not initialize repository"
group = self.groups[group_name]
if not self.load_repository(group, repository_path):
RNS.log(f"Repository {repository_path} created, but runtime loading failed", RNS.LOG_ERROR)
try: shutil.rmtree(repository_path)
except: RNS.log(f"Could not clean up failed repository init at {repository_path}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Failed to register repository"
else:
RNS.log(f"Repository {group_name}/{repository_name} created and loaded successfully", RNS.LOG_DEBUG)
return b"\x00"
except Exception as e:
RNS.log(f"Error while creating repository {group_name}/{repository_name} for {remote_identity}: {e}", RNS.LOG_ERROR)
try:
if os.path.exists(repository_path): shutil.rmtree(repository_path)
except: RNS.log(f"Could not clean up failed repository creation at {repository_path}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_fork(self, path, data, request_id, link_id, remote_identity, requested_at):
self.log_request(f"Fork request from remote {remote_identity}", remote_identity)
return self._handle_remote_clone(path, data, request_id, link_id, remote_identity, requested_at, "fork")
def handle_mirror(self, path, data, request_id, link_id, remote_identity, requested_at):
self.log_request(f"Mirror request from remote {remote_identity}", remote_identity)
return self._handle_remote_clone(path, data, request_id, link_id, remote_identity, requested_at, "mirror")
def _handle_remote_clone(self, path, data, request_id, link_id, remote_identity, requested_at, repo_type):
if repo_type not in ["mirror", "fork"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
with self.active_links_lock:
if not link_id in self.active_links: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
else: link = self.active_links[link_id]
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
source_url = data.get("source", "")
if not source_url: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No source specified"
if not type(source_url) == str: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid source URL"
if not source_url.lower().split("://")[0] in self.CLONE_PROTOS: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Prohibited source URL"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
if not group_name or not repository_name: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not group_name in self.groups: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
create_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_CREATE)
group_path = self.groups[group_name]["path"]
if not os.path.exists(group_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not create_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
final_repository_path = os.path.join(group_path, repository_name)
repository_exists = group_name in self.groups and repository_name in self.groups[group_name]["repositories"]
path_exists = os.path.exists(final_repository_path)
if repository_exists or path_exists:
existing_read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
if existing_read_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Repository already exists"
else: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
try:
RNS.log(f"{repo_type.capitalize()}ing {source_url} to {group_name}/{repository_name} for {remote_identity}", RNS.LOG_VERBOSE)
if not hasattr(link, "temporary_directories"): link.temporary_directories = []
tmpdir = TemporaryDirectory()
link.temporary_directories.append(tmpdir)
tmp_path = tmpdir.name
RNS.log(f"Created temporary directory {tmp_path} for {link}", RNS.LOG_DEBUG)
repo_temp_path = os.path.join(tmp_path, repository_name)
os.makedirs(repo_temp_path, mode=0o755)
result = subprocess.run(["git", "init", "--bare"], cwd=repo_temp_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to initialize bare repository at {repo_temp_path}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Failed to initialize repository"
RNS.log(f"Fetching from {source_url}...", RNS.LOG_VERBOSE)
result = subprocess.run(["git", "fetch", source_url, "+refs/*:refs/*"], cwd=repo_temp_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to fetch from {source_url}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Failed to fetch from source: {result.stderr}".encode("utf-8")
try: self.__update_head_to_source_default(repo_temp_path, source_url)
except Exception as e: RNS.log(f"Failed to update HEAD for repository cloned from {source_url}: {e}", RNS.LOG_ERROR)
result = subprocess.run(["git", "config", "repository.rngit.type", repo_type], cwd=repo_temp_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to set rngit.type config: {result.stderr}", RNS.LOG_WARNING)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Failed to configure repository type: {result.stderr}".encode("utf-8")
result = subprocess.run(["git", "config", "repository.rngit.upstream.source", source_url], cwd=repo_temp_path, capture_output=True, text=True, check=False)
if result.returncode != 0:
RNS.log(f"Failed to set rngit.upstream.source config: {result.stderr}", RNS.LOG_WARNING)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Failed to configure repository upstream source: {result.stderr}".encode("utf-8")
if repo_type in ["mirror", "fork"]:
if not self.__set_mirror_synced(repo_temp_path):
RNS.log(f"Failed to set rngit.upstream.sync config: {result.stderr}", RNS.LOG_WARNING)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Failed to configure repository type: {result.stderr}".encode("utf-8")
try:
allowed_path = final_repository_path + ".allowed"
tmp_allowed = allowed_path + ".tmp"
repository_permissions = REPO_CREATE_PERMS_TEMPLATE.replace("{IDENTITY_HASH}", RNS.hexrep(remote_identity.hash, delimit=False))
with open(tmp_allowed, "w", encoding="utf-8") as fh: fh.write(repository_permissions)
os.rename(tmp_allowed, allowed_path)
except Exception as e:
RNS.log(f"Could not set default repository permissions for {group_name}/{repository_name}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not initialize repository"
try:
shutil.move(repo_temp_path, final_repository_path)
RNS.log(f"Deployed fetched repository from {repo_temp_path} to {final_repository_path}", RNS.LOG_VERBOSE)
except Exception as e:
RNS.log(f"Failed to deploy fetched repository to group directory", RNS.LOG_WARNING)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not write repository"
group = self.groups[group_name]
if not self.load_repository(group, final_repository_path):
RNS.log(f"Repository {final_repository_path} created, but runtime loading failed", RNS.LOG_ERROR)
try: shutil.rmtree(final_repository_path)
except: RNS.log(f"Could not clean up failed repository init at {final_repository_path}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Failed to register repository"
RNS.log(f"Repository {group_name}/{repository_name} {repo_type}ed successfully from {source_url}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error while {repo_type}ing repository {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
try:
if os.path.exists(final_repository_path): shutil.rmtree(final_repository_path)
except: pass
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_sync(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Upstream sync request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not write_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repo = self.groups[group_name]["repositories"][repository_name]
repository_path = repo["path"]
try:
if repo["mirror"]:
if self.__sync_mirror(group_name, repository_name): return b"\x00"
else: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Mirror sync failed"
elif repo["fork"]:
if self.__sync_fork(group_name, repository_name): return b"\x00"
else: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Fork sync failed"
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Repository is neither fork nor mirror"
except Exception as e:
RNS.log(f"Error while handling sync request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
###############################
# Release Management Handlers #
###############################
def handle_release(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Release request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
operation = data.get("operation")
if not operation: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
release_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_RELEASE)
access = False
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if operation in ["create", "delete", "latest"] and release_access and read_access: access = True
elif operation in ["list", "view", "fetch"] and read_access: access = True
else: access = False
if not access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
releases_path = f"{repository_path}.releases"
try:
if operation == "list" and read_access: return self._release_list(releases_path)
elif operation == "view" and read_access: return self._release_view(releases_path, data)
elif operation == "fetch" and read_access: return self._release_fetch(releases_path, data)
elif operation == "create" and release_access: return self._release_create(releases_path, repository_path, data, remote_identity)
elif operation == "delete" and release_access: return self._release_delete(releases_path, data)
elif operation == "latest" and release_access: return self._release_latest(releases_path, data)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
except Exception as e:
RNS.log(f"Error while handling release request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def releases_list_data(self, releases_path):
try:
tags = {}
releases = []
latest_release = None
if not os.path.isdir(releases_path): return releases, None
for entry in os.listdir(releases_path):
release_dir = os.path.join(releases_path, entry)
if not os.path.isdir(release_dir): continue
meta_path = os.path.join(release_dir, "META")
if not os.path.isfile(meta_path): continue
try:
meta = ConfigObj(meta_path)
release_tag = meta.get("tag", entry)
release_status = meta.get("status", "unknown")
release_info = { "tag": release_tag,
"hash": meta.get("hash", ""),
"created": meta.as_int("created") if "created" in meta else 0,
"status": release_status,
"created_by": meta.get("created_by", "") }
notes_preview = ""
notes_format = "markdown"
for notes_file in ["RELEASE.md", "RELEASE.mu", "RELEASE.txt"]:
notes_path = os.path.join(release_dir, notes_file)
if os.path.isfile(notes_path):
try:
with open(notes_path, "r", encoding="utf-8") as f:
notes_full = f.read()
notes_preview = ""
for line in notes_full.splitlines():
if not line.startswith("#") and not line.startswith(">"):
notes_preview += f"{line}\n"
notes_preview = notes_preview.strip()
if notes_path.endswith(".mu"): notes_format = "micron"
elif notes_path.endswith(".txt"): notes_format = "text"
except Exception: pass
break
release_info["preview"] = notes_preview
release_info["format"] = notes_format
artifacts_dir = os.path.join(release_dir, "artifacts")
if os.path.isdir(artifacts_dir):
release_info["artifacts"] = len([f for f in os.listdir(artifacts_dir) if os.path.isfile(os.path.join(artifacts_dir, f))])
else: release_info["artifacts"] = 0
releases.append(release_info)
tags[release_tag] = True if release_status == "published" else False
except Exception as e:
RNS.log(f"Error reading release metadata for {entry}: {e}", RNS.LOG_VERBOSE)
continue
try:
latest_path = os.path.join(releases_path, "latest")
if os.path.isfile(latest_path):
with open(latest_path, "r") as fh: latest_tag = fh.read().strip()
if latest_tag in tags and tags[latest_tag] == True: latest_release = latest_tag
except Exception as e: RNS.log(f"Could not determine latest release for {releases_path}: {e}", RNS.LOG_ERROR)
releases.sort(key=lambda x: x.get("created", 0), reverse=True)
return releases, latest_release
except Exception as e:
RNS.log(f"Error listing releases for {releases_path}: {e}", RNS.LOG_ERROR)
return None, None
def _release_list(self, releases_path):
if not os.path.isdir(releases_path): return b"\x00" + mp.packb([])
releases, latest_release = self.releases_list_data(releases_path)
if releases == None: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error listing releases"
release_data = {"releases": releases, "latest": latest_release}
return b"\x00" + mp.packb(release_data)
def release_data(self, release_dir, tag):
try:
meta_path = os.path.join(release_dir, "META")
if not os.path.isfile(meta_path):
RNS.log(f"Release metadata missing for {release_dir}/{tag}", RNS.LOG_ERROR)
return None
meta = ConfigObj(meta_path)
release_info = { "tag": meta.get("tag", tag),
"hash": meta.get("hash", ""),
"created": meta.as_int("created") if "created" in meta else 0,
"status": meta.get("status", "unknown"),
"created_by": meta.get("created_by", "") }
notes_content = ""
notes_format = "text"
for notes_file, fmt in [("RELEASE.md", "markdown"), ("RELEASE.mu", "micron")]:
notes_path = os.path.join(release_dir, notes_file)
if os.path.isfile(notes_path):
try:
with open(notes_path, "r", encoding="utf-8") as f: notes_content = f.read()
notes_format = fmt
except Exception as e: RNS.log(f"Error reading release notes: {e}", RNS.LOG_DEBUG)
break
release_info["notes"] = notes_content
release_info["notes_format"] = notes_format
artifacts = []
artifacts_dir = os.path.join(release_dir, "artifacts")
if os.path.isdir(artifacts_dir):
for artifact in os.listdir(artifacts_dir):
artifact_path = os.path.join(artifacts_dir, artifact)
if os.path.isfile(artifact_path):
artifacts.append({ "name": artifact, "size": os.path.getsize(artifact_path)})
release_info["artifacts"] = artifacts
thanks_path = os.path.join(release_dir, "THANKS")
thanks_count = 0
if os.path.isfile(thanks_path):
try:
with open(thanks_path, "rb") as f:
thanks_data = mp.unpackb(f.read())
thanks_count = thanks_data.get("count", 0)
except Exception: pass
release_info["thanks"] = thanks_count
return release_info
except Exception as e:
RNS.log(f"Error while getting release data for {release_dir}/{tag}: {e}", RNS.LOG_ERROR)
return None
def _release_view(self, releases_path, data):
tag = data.get("tag", "")
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
latest_release = None
if tag == "latest":
try:
latest_path = os.path.join(releases_path, "latest")
if os.path.isfile(latest_path):
with open(latest_path, "r") as fh: latest_tag = fh.read().strip()
latest_release = latest_tag
except Exception as e: RNS.log(f"Could not determine latest release for {releases_path}: {e}", RNS.LOG_ERROR)
if not latest_release: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"No latest release found"
else: tag = latest_release
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found"
release_info = self.release_data(release_dir, tag)
if not release_info: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error getting release data"
return b"\x00" + mp.packb(release_info)
def _release_fetch(self, releases_path, data):
tag = data.get("tag", "")
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
artifact = data.get("artifact", "")
if "/" in artifact: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid artifact specified"
if not artifact: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid artifact specified"
tag = os.path.basename(tag)
artifact = os.path.basename(artifact)
latest_release = None
if tag == "latest":
try:
latest_path = os.path.join(releases_path, "latest")
if os.path.isfile(latest_path):
with open(latest_path, "r") as fh: latest_tag = fh.read().strip()
latest_release = latest_tag
except Exception as e: RNS.log(f"Could not determine latest release for {releases_path}: {e}", RNS.LOG_ERROR)
if not latest_release: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"No latest release found"
else: tag = latest_release
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found"
artifact_path = os.path.join(release_dir, "artifacts", artifact)
if not os.path.isfile(artifact_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Artifact not found"
return [open(artifact_path, "rb"), {"name": artifact.encode("utf-8")}]
def _release_create(self, releases_path, repository_path, data, remote_identity):
step = data.get("step")
if not step: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if step == "init": return self._release_create_init(releases_path, repository_path, data, remote_identity)
elif step == "artifact": return self._release_create_artifact(releases_path, data)
elif step == "finalize": return self._release_create_finalize(releases_path, data)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
def _release_create_init(self, releases_path, repository_path, data, remote_identity):
tag = data.get("tag", "")
commit_hash = data.get("hash")
notes = data.get("notes", "")
notes_format = data.get("notes_format", "markdown") # "markdown" or "micron"
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
if not tag or tag in [".", ".."]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag name"
try:
tag_check = subprocess.run(["git", "rev-parse", "--verify", f"refs/tags/{tag}"],
cwd=repository_path, capture_output=True, check=False)
if tag_check.returncode != 0: return self.RES_INVALID_REQ.to_bytes(1, "big") + f"Tag '{tag}' does not exist in repository".encode("utf-8")
if not os.path.isdir(releases_path): os.makedirs(releases_path, mode=0o755)
release_dir = os.path.join(releases_path, tag)
if os.path.isdir(release_dir): return self.RES_DISALLOWED.to_bytes(1, "big") + b"Release already exists"
os.makedirs(release_dir, mode=0o755)
os.makedirs(os.path.join(release_dir, "artifacts"), mode=0o755)
meta = ConfigObj()
meta.filename = os.path.join(release_dir, "META")
meta["tag"] = tag
if commit_hash: meta["hash"] = commit_hash
meta["created"] = int(time.time())
meta["status"] = "draft"
meta["created_by"] = RNS.hexrep(remote_identity.hash, delimit=False)
meta.write()
if notes:
notes_filename = "RELEASE.mu" if notes_format == "micron" else "RELEASE.md"
notes_path = os.path.join(release_dir, notes_filename)
with open(notes_path, "w", encoding="utf-8") as f: f.write(notes)
thanks_path = os.path.join(release_dir, "THANKS")
with open(thanks_path, "wb") as f: f.write(mp.packb({"count": 0}))
RNS.log(f"Created release {tag} in draft status", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error creating release: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_create_artifact(self, releases_path, data):
tag = data.get("tag", "")
artifact_name = data.get("artifact_name")
artifact_data = data.get("artifact_data")
if not tag or not artifact_name: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Missing tag or artifact name"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if artifact_data is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No artifact data"
tag = os.path.basename(tag)
artifact_name = os.path.basename(artifact_name)
try:
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found"
meta_path = os.path.join(release_dir, "META")
meta = ConfigObj(meta_path)
if meta.get("status") != "draft": return self.RES_DISALLOWED.to_bytes(1, "big") + b"Release was finalized and is not writable"
artifacts_dir = os.path.join(release_dir, "artifacts")
artifact_path = os.path.join(artifacts_dir, artifact_name)
if not os.path.isdir(artifacts_dir): os.makedirs(artifacts_dir, mode=0o755)
with open(artifact_path, "wb") as f:
if isinstance(artifact_data, str): f.write(artifact_data.encode("utf-8"))
else: f.write(artifact_data)
RNS.log(f"Added artifact {artifact_name} to release {tag}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error adding artifact: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_create_finalize(self, releases_path, data):
tag = data.get("tag", "")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
try:
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found"
meta_path = os.path.join(release_dir, "META")
meta = ConfigObj(meta_path)
if meta.get("status") != "draft": return self.RES_DISALLOWED.to_bytes(1, "big") + b"Release was finalized and is not writable"
meta["status"] = "published"
meta["published_at"] = int(time.time())
meta.write()
try:
latest_path = os.path.join(releases_path, "latest")
tmp_path = latest_path+".tmp"
with open(tmp_path, "w") as fh: fh.write(tag)
os.rename(tmp_path, latest_path)
RNS.log(f"Set {tag} as latest release for {releases_path}", RNS.LOG_VERBOSE)
except Exception as e: RNS.log(f"Error setting latest release for {releases_path}: {e}", RNS.LOG_ERROR)
RNS.log(f"Finalized release {tag} for {releases_path}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error finalizing release: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_delete(self, releases_path, data):
tag = data.get("tag", "")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found"
try:
shutil.rmtree(release_dir)
RNS.log(f"Deleted release {tag} from {releases_path}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error deleting release from {releases_path}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_latest(self, releases_path, data):
tag = data.get("tag", "")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found"
try:
latest_path = os.path.join(releases_path, "latest")
tmp_path = latest_path+".tmp"
with open(tmp_path, "w") as fh: fh.write(tag)
os.rename(tmp_path, latest_path)
RNS.log(f"Set {tag} as latest release for {releases_path}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error setting latest release for {releases_path}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
##########################
# Work Document Handlers #
##########################
def handle_work(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Work request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
operation = data.get("operation")
if not operation: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
interact_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_INTERACT)
propose_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_PROPOSE)
admin_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_ADMIN)
access = False
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if operation in ["read", "view", "comment", "edit", "delete", "perms"]:
if data.get("doc_id", None):
try: doc_id = int(data.get("doc_id", None))
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_READ)
read_access |= admin_access
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
if operation in ["comment"]:
if data.get("doc_id", None):
try: doc_id = int(data.get("doc_id", None))
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
interact_access |= self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_INTERACT)
if operation in ["edit"]:
if data.get("doc_id", None):
try: doc_id = int(data.get("doc_id", None))
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
interact_access |= self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_INTERACT)
write_access |= self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_WRITE)
comment_access = interact_access and (read_access or write_access)
manage_access = interact_access and write_access
if operation in ["list", "view"] and read_access: access = True
elif operation in ["comment"] and comment_access: access = True
elif operation in ["propose"] and propose_access: access = True
elif operation in ["create", "edit", "delete"] and manage_access: access = True
elif operation in ["complete", "activate"] and manage_access: access = True
elif operation in ["perms"] and admin_access: access = True
else: access = False
if not access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
work_path = f"{repository_path}.work"
try:
if operation == "list" and read_access: return self._work_list(work_path, data, remote_identity)
elif operation == "view" and read_access: return self._work_view(work_path, data, remote_identity)
elif operation == "comment" and comment_access: return self._work_comment(work_path, data, remote_identity)
elif operation == "create" and manage_access: return self._work_create(work_path, data, remote_identity)
elif operation == "propose" and propose_access: return self._work_propose(work_path, data, remote_identity)
elif operation == "edit" and manage_access: return self._work_edit(work_path, data, remote_identity)
elif operation == "delete" and manage_access: return self._work_delete(work_path, data, remote_identity)
elif operation == "complete" and manage_access: return self._work_complete(work_path, data, remote_identity)
elif operation == "activate" and manage_access: return self._work_activate(work_path, data, remote_identity)
elif operation == "perms" and admin_access: return self._work_perms(work_path, data, remote_identity)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
except Exception as e:
RNS.log(f"Error while handling work request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_get_next_id(self, work_path):
def scope_next_id(base_path):
if not os.path.isdir(base_path): return 1
try:
entries = [int(d) for d in os.listdir(base_path) if d.isdigit()]
if not entries: return 1
return max(entries) + 1
except: return 1
return max(scope_next_id(os.path.join(work_path, scope)) for scope in ["active", "completed", "proposed"])
def _work_get_next_comment_id(self, base_path):
if not os.path.isdir(base_path): return 1
try:
entries = [int(d) for d in os.listdir(base_path) if d.isdigit()]
if not entries: return 1
return max(entries) + 1
except: return 1
def _work_load_document(self, doc_path):
try:
with open(doc_path, "rb") as f: return mp.unpackb(f.read())
except: return None
def _work_save_document(self, doc_path, document):
try:
dir_path = os.path.dirname(doc_path)
if not os.path.isdir(dir_path): os.makedirs(dir_path, mode=0o755)
tmp_path = doc_path + ".tmp"
with open(tmp_path, "wb") as f: f.write(mp.packb(document))
os.rename(tmp_path, doc_path)
return True
except Exception as e:
RNS.log(f"Error persisting work document: {e}", RNS.LOG_ERROR)
return False
def _work_list(self, work_path, data, remote_identity):
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
scope = data.get("scope", "active")
result = {"active": [], "completed": [], "proposed": []}
for folder_name, key in [("active", "active"), ("completed", "completed"), ("proposed", "proposed")]:
if scope not in [folder_name, "all"]: continue
folder_path = os.path.join(work_path, folder_name)
if not os.path.isdir(folder_path): continue
for entry in os.listdir(folder_path):
doc_dir = os.path.join(folder_path, entry)
if not os.path.isdir(doc_dir): continue
try:
doc_id = int(entry)
read_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_READ)
if not read_access: continue
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): continue
doc = self._work_load_document(root_path)
if not doc: continue
meta = doc.get("meta", {})
comment_count = len([f for f in os.listdir(doc_dir) if f.isdigit() and os.path.isfile(os.path.join(doc_dir, f))])
result[key].append({ "id": doc_id, "title": meta.get("title", "Untitled"),
"created": meta.get("created", 0), "edited": meta.get("edited", 0),
"author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "",
"format": meta.get("format", "markdown"), "comments": comment_count })
except: continue
for key in result: result[key].sort(key=lambda x: x["created"], reverse=True)
return b"\x00" + mp.packb(result)
def _work_view(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "all")
if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed", "proposed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
if not doc_dir: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
comments = []
if os.path.isdir(doc_dir):
for entry in os.listdir(doc_dir):
if not entry.isdigit(): continue
comment_path = os.path.join(doc_dir, entry)
if not os.path.isfile(comment_path): continue
try:
comment_id = int(entry)
comment = self._work_load_document(comment_path)
if not comment: continue
meta = comment.get("meta", {})
comments.append({ "id": comment_id, "content": comment.get("content", ""),
"created": meta.get("created", 0), "edited": meta.get("edited", 0),
"author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "",
"format": meta.get("format", "markdown") })
except: continue
comments.sort(key=lambda x: x["id"])
meta = doc.get("meta", {})
result = { "id": doc_id, "scope": scope,
"content": doc.get("content", ""), "comments": comments,
"meta": { "title": meta.get("title", "Untitled"),
"created": meta.get("created", 0),
"edited": meta.get("edited", 0),
"author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "",
"identity": meta.get("identity", None),
"signature": meta.get("signature", None),
"format": meta.get("format", "markdown") } }
return b"\x00" + mp.packb(result)
def _work_create(self, work_path, data, remote_identity):
title = data.get("title", "").strip()
content = data.get("content", "").strip()
format_type = data.get("format", "markdown")
signature = data.get("signature", None)
signed_data = content.encode("utf-8")
sig_length = RNS.Identity.SIGLENGTH//8
limit = self.WORK_DOC_LIMIT
if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided"
if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length"
if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if len(title)+len(content)+len(format_type) > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Title is required"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
try:
active_path = os.path.join(work_path, "active")
doc_id = self._work_get_next_id(work_path)
doc_dir = os.path.join(active_path, str(doc_id))
now = time.time()
document = { "content": content,
"meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown",
"title": title, "created": now, "edited": now, "author": remote_identity.hash,
"signature": signature, "identity": remote_identity.get_public_key() } }
root_path = os.path.join(doc_dir, "root")
if not self._work_save_document(root_path, document):
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document"
RNS.log(f"Created work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00" + mp.packb({"id": doc_id, "scope": "active"})
except Exception as e:
RNS.log(f"Error creating work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_propose(self, work_path, data, remote_identity):
title = data.get("title", "").strip()
content = data.get("content", "").strip()
format_type = data.get("format", "markdown")
signature = data.get("signature", None)
signed_data = content.encode("utf-8")
sig_length = RNS.Identity.SIGLENGTH//8
limit = self.WORK_DOC_LIMIT
if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided"
if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length"
if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if len(title)+len(content)+len(format_type) > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Title is required"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
try:
proposed_path = os.path.join(work_path, "proposed")
doc_id = self._work_get_next_id(work_path)
doc_dir = os.path.join(proposed_path, str(doc_id))
now = time.time()
document = { "content": content,
"meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown",
"title": title, "created": now, "edited": now, "author": remote_identity.hash,
"signature": signature, "identity": remote_identity.get_public_key() } }
root_path = os.path.join(doc_dir, "root")
if not self._work_save_document(root_path, document):
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document"
try:
owner_permissions = f"i:{RNS.hexrep(remote_identity.hash, delimit=False)}\n"
owner_permissions += f"w:{RNS.hexrep(remote_identity.hash, delimit=False)}\n"
allowed_path = work_path + f"/{doc_id}.allowed"
tmp_path = allowed_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f: f.write(owner_permissions)
os.rename(tmp_path, allowed_path)
except Exception as e:
RNS.log(f"Error setting permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error setting document ownership"
RNS.log(f"Proposed work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00" + mp.packb({"id": doc_id, "scope": "proposed"})
except Exception as e:
RNS.log(f"Error proposing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_edit(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
content = data.get("content", "")
title = data.get("title", "")
signature = data.get("signature", None)
signed_data = content.encode("utf-8")
sig_length = RNS.Identity.SIGLENGTH//8
limit = self.WORK_DOC_LIMIT
size = 0
if title: size += len(title)
if content: size += len(content)
if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided"
if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length"
if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if not content and not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No changes specified"
if not doc_id: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed", "proposed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
if not doc_dir: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
if title: doc["meta"]["title"] = title.strip()
if content: doc["content"] = content.strip()
doc["meta"]["edited"] = time.time()
doc["meta"]["signature"] = signature
doc["meta"]["identity"] = remote_identity.get_public_key()
if not self._work_save_document(root_path, doc): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document"
RNS.log(f"Edited work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error editing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_delete(self, work_path, data, remote_identity):
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed", "proposed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
is_author = doc.get("meta", {}).get("author") == remote_identity.hash
admin_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_ADMIN)
if not (is_author or admin_access): return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
allowed_path = work_path + f"/{doc_id}.allowed"
os.unlink(allowed_path)
except Exception as e:
RNS.log(f"Error while deleting permissions file for {work_path}/{doc_id}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
try:
shutil.rmtree(doc_dir)
RNS.log(f"Deleted work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error deleting work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_comment(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
content = data.get("content", "").strip()
signature = data.get("signature", None)
format_type = data.get("format", "markdown")
limit = self.WORK_DOC_LIMIT
size = len(content)
if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
scope = None
doc_dir = None
for s in ["active", "completed", "proposed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
try:
comment_id = self._work_get_next_comment_id(doc_dir)
now = time.time()
comment = { "content": content,
"meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown",
"title": None, "created": now, "edited": now,
"signature": signature, "author": remote_identity.hash } }
comment_path = os.path.join(doc_dir, str(comment_id))
if not self._work_save_document(comment_path, comment): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving comment"
RNS.log(f"Added comment {comment_id} to work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00" + mp.packb({"id": comment_id})
except Exception as e:
RNS.log(f"Error adding comment: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_complete(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
active_dir = os.path.join(work_path, "active", str(doc_id))
completed_base = os.path.join(work_path, "completed")
if not os.path.isdir(active_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(active_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
completed_dir = os.path.join(completed_base, str(doc_id))
shutil.move(active_dir, completed_dir)
RNS.log(f"Completed work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00" + mp.packb({"id": doc_id, "scope": "completed"})
except Exception as e:
RNS.log(f"Error completing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_activate(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
completed_dir = os.path.join(work_path, "completed", str(doc_id))
active_base = os.path.join(work_path, "active")
if not os.path.isdir(completed_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(completed_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
active_dir = os.path.join(active_base, str(doc_id))
shutil.move(completed_dir, active_dir)
RNS.log(f"Activated work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00" + mp.packb({"id": doc_id, "scope": "active"})
except Exception as e:
RNS.log(f"Error activating work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_perms(self, work_path, data, remote_identity):
step = data.get("step")
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
interact_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_INTERACT)
manage_access = interact_access and write_access
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not manage_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
if not step: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if step == "get": return self._work_get_permissions(work_path, data, remote_identity, group_name, repository_name)
elif step == "set": return self._work_set_permissions(work_path, data, remote_identity, group_name, repository_name)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid step"
def _work_get_permissions(self, work_path, data, remote_identity, group_name, repository_name):
doc_id = data.get("doc_id")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed", "proposed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
if not doc_dir: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(doc_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
is_author = doc.get("meta", {}).get("author") == remote_identity.hash
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
interact_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_INTERACT)
admin_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_ADMIN)
manage_access = interact_access and write_access
if not ((is_author and manage_access) or admin_access): return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
try:
allowed_path = work_path + f"/{doc_id}.allowed"
if os.path.isfile(allowed_path):
with open(allowed_path, "r", encoding="utf-8") as f: content = f.read()
else: content = ""
return b"\x00" + mp.packb({"content": content})
except Exception as e:
RNS.log(f"Error getting document permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error getting permissions"
def _work_set_permissions(self, work_path, data, remote_identity, group_name, repository_name):
doc_id = data.get("doc_id")
content = data.get("content", "")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed", "proposed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
if not doc_dir: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(doc_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
is_author = doc.get("meta", {}).get("author") == remote_identity.hash
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
interact_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_INTERACT)
admin_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_ADMIN)
manage_access = interact_access and write_access
if not ((is_author and manage_access) or admin_access): return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
valid = True
error_line = None
invalid_perm = ""
for line_num, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if not stripped or stripped.startswith("#"): continue
perm, target = self.parse_permission(stripped)
if not perm or not target:
valid = False
error_line = line_num
invalid_perm = stripped
break
if not valid: return self.RES_INVALID_REQ.to_bytes(1, "big") + f"Invalid permission \"{invalid_perm}\" on line {error_line}".encode("utf-8")
try:
allowed_path = work_path + f"/{doc_id}.allowed"
tmp_path = allowed_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f: f.write(content)
os.rename(tmp_path, allowed_path)
RNS.log(f"Permissions for work document {group_name}/{repository_name}/{doc_id} updated by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00"
except Exception as e:
RNS.log(f"Error setting permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error setting permissions"
##################################
# Permission Management Handlers #
##################################
def handle_perms(self, path, data, request_id, remote_identity, requested_at):
self.log_request(f"Permissions request from remote {remote_identity}", remote_identity)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
try:
operation = data.get("operation")
if not operation: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if operation == "gperms":
if not self.IDX_GROUP in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No group specified"
group_name = self.parse_request_group_path(data[self.IDX_GROUP])
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
admin_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_ADMIN)
return self._group_permissions(group_name, data, remote_identity)
elif operation == "rperms":
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
admin_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_ADMIN)
if not admin_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
return self._repository_permissions(group_name, repository_name, data, remote_identity)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
except Exception as e:
RNS.log(f"Error while handling permissions request from {remote_identity}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _group_permissions(self, group_name, data, remote_identity):
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
admin_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_ADMIN)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
step = data.get("step")
if not step: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if step == "get": return self._group_get_permissions(group_name, data, remote_identity)
elif step == "set": return self._group_set_permissions(group_name, data, remote_identity)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid step"
def _group_get_permissions(self, group_name, data, remote_identity):
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
admin_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_ADMIN)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
try:
group_path = self.groups[group_name]["path"]
allowed_path = group_path + ".allowed"
if os.path.isfile(allowed_path):
with open(allowed_path, "r", encoding="utf-8") as f: content = f.read()
else: content = ""
return b"\x00" + mp.packb({"content": content})
except Exception as e:
RNS.log(f"Error getting group permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error getting permissions"
def _group_set_permissions(self, group_name, data, remote_identity):
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
admin_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_ADMIN)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
content = data.get("content", "")
valid = True
error_line = None
invalid_perm = ""
for line_num, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if not stripped or stripped.startswith("#"): continue
perm, target = self.parse_permission(stripped)
if not perm or not target:
valid = False
error_line = line_num
invalid_perm = stripped
break
if not valid: return self.RES_INVALID_REQ.to_bytes(1, "big") + f"Invalid permission \"{invalid_perm}\" on line {error_line}".encode("utf-8")
try:
group_path = self.groups[group_name]["path"]
allowed_path = group_path + ".allowed"
tmp_path = allowed_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f: f.write(content)
os.rename(tmp_path, allowed_path)
RNS.log(f"Permissions for group {group_name} updated by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error setting permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error setting permissions"
def _repository_permissions(self, group_name, repository_name, data, remote_identity):
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
admin_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_ADMIN)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
step = data.get("step")
if not step: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if step == "get": return self._repository_get_permissions(group_name, repository_name, data, remote_identity)
elif step == "set": return self._repository_set_permissions(group_name, repository_name, data, remote_identity)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid step"
def _repository_get_permissions(self, group_name, repository_name, data, remote_identity):
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
admin_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_ADMIN)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
try:
repo_path = self.groups[group_name]["repositories"][repository_name]["path"]
allowed_path = repo_path + ".allowed"
if os.path.isfile(allowed_path):
with open(allowed_path, "r", encoding="utf-8") as f: content = f.read()
else: content = ""
return b"\x00" + mp.packb({"content": content})
except Exception as e:
RNS.log(f"Error getting repository permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error getting permissions"
def _repository_set_permissions(self, group_name, repository_name, data, remote_identity):
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
read_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_READ)
admin_access = self.resolve_group_permission(remote_identity, group_name, self.PERM_ADMIN)
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
content = data.get("content", "")
valid = True
error_line = None
invalid_perm = ""
for line_num, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if not stripped or stripped.startswith("#"): continue
perm, target = self.parse_permission(stripped)
if not perm or not target:
valid = False
error_line = line_num
invalid_perm = stripped
break
if not valid: return self.RES_INVALID_REQ.to_bytes(1, "big") + f"Invalid permission \"{invalid_perm}\" on line {error_line}".encode("utf-8")
try:
repo_path = self.groups[group_name]["repositories"][repository_name]["path"]
allowed_path = repo_path + ".allowed"
tmp_path = allowed_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f: f.write(content)
os.rename(tmp_path, allowed_path)
RNS.log(f"Permissions for repository {group_name}/{repository_name} updated by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00"
except Exception as e:
RNS.log(f"Error setting permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error setting permissions"
###################
# Node Statistics #
###################
STATS_INIT_REPO = {"view": {}, "fetch": {}, "push": {}, "download": {}, "release_download": {}}
STATS_INIT_GROUP = {"view": {}, "repositories": {}}
def repository_stats(self, remote_identity, group_name, repository_name, lookback_days=14):
if not self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_STATS): return None
else:
with self.stats_lock:
now = time.time()
day_seconds = 86400
days = []
day_labels = []
for i in range(lookback_days - 1, -1, -1):
day_ts = now - (i * day_seconds)
day_str = time.strftime("%Y-%m-%d", time.localtime(day_ts))
days.append(day_str)
day_labels.append(time.strftime("%b %d", time.localtime(day_ts)))
timeline_labels = [f"{lookback_days} days ago", "Today"]
repo_stats = { "group": group_name, "repository": repository_name,
"lookback_days": lookback_days, "date_range": f"{day_labels[0]} - {day_labels[-1]}",
"days": days, "day_labels": day_labels, "timeline_labels": timeline_labels,
"views": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
"fetches": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
"pushes": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
"downloads": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
"release_downloads": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
"downloads_combined": {"daily": [], "total": 0, "peak": 0, "peak_day": None} }
group_stats = self.stats.get("groups", {}).get(group_name, {})
repo_data = group_stats.get("repositories", {}).get(repository_name, {})
view_stats = repo_data.get("view", {})
for day in days:
count = view_stats.get(day, 0)
repo_stats["views"]["daily"].append(count)
repo_stats["views"]["total"] += count
if count > repo_stats["views"]["peak"]:
repo_stats["views"]["peak"] = count
repo_stats["views"]["peak_day"] = day
fetch_stats = repo_data.get("fetch", {})
for day in days:
count = fetch_stats.get(day, 0)
repo_stats["fetches"]["daily"].append(count)
repo_stats["fetches"]["total"] += count
if count > repo_stats["fetches"]["peak"]:
repo_stats["fetches"]["peak"] = count
repo_stats["fetches"]["peak_day"] = day
push_stats = repo_data.get("push", {})
for day in days:
count = push_stats.get(day, 0)
repo_stats["pushes"]["daily"].append(count)
repo_stats["pushes"]["total"] += count
if count > repo_stats["pushes"]["peak"]:
repo_stats["pushes"]["peak"] = count
repo_stats["pushes"]["peak_day"] = day
download_stats = repo_data.get("download", {})
for day in days:
count = download_stats.get(day, 0)
repo_stats["downloads"]["daily"].append(count)
repo_stats["downloads"]["total"] += count
if count > repo_stats["downloads"]["peak"]:
repo_stats["downloads"]["peak"] = count
repo_stats["downloads"]["peak_day"] = day
release_download_stats = repo_data.get("release_download", {})
for day in days:
count = release_download_stats.get(day, 0)
repo_stats["release_downloads"]["daily"].append(count)
repo_stats["release_downloads"]["total"] += count
if count > repo_stats["release_downloads"]["peak"]:
repo_stats["release_downloads"]["peak"] = count
repo_stats["release_downloads"]["peak_day"] = day
for day in days:
count = download_stats.get(day, 0) + release_download_stats.get(day, 0)
repo_stats["downloads_combined"]["daily"].append(count)
repo_stats["downloads_combined"]["total"] += count
if count > repo_stats["downloads_combined"]["peak"]:
repo_stats["downloads_combined"]["peak"] = count
repo_stats["downloads_combined"]["peak_day"] = day
view_total = repo_stats["views"]["total"] + repo_stats["downloads"]["total"] + repo_stats["release_downloads"]["total"]
fetch_total = repo_stats["fetches"]["total"]
push_total = repo_stats["pushes"]["total"]
total_score = ( view_total * 0.2 +
fetch_total * 2.0 +
push_total * 5 )
repo_stats["activity_score"] = int(total_score)
actual_days = lookback_days
all_activity_days = set()
for stats_dict in (view_stats, fetch_stats, push_stats):
for day, count in stats_dict.items():
if count > 0: all_activity_days.add(day)
if all_activity_days:
earliest_day = min(all_activity_days)
try:
earliest_ts = time.mktime(time.strptime(earliest_day, "%Y-%m-%d"))
span_seconds = now - earliest_ts
actual_days = max(1, int(span_seconds // day_seconds) + 1)
except (ValueError, TypeError): pass
if actual_days > lookback_days: actual_days = lookback_days
daily_score = total_score / actual_days if actual_days > 0 else 0
repo_stats["actual_days"] = actual_days
if daily_score == 0: repo_stats["activity_level"] = "inactive"
elif daily_score < 3: repo_stats["activity_level"] = "low"
elif daily_score < 10: repo_stats["activity_level"] = "moderate"
else: repo_stats["activity_level"] = "high"
return repo_stats
def view_succeeded(self, group_name, repository_name, remote_identity):
if remote_identity and remote_identity.hash in self.stats_ignored: return
if self.stats_enabled:
if group_name == None and repository_name == None: self.record_page_view("front")
elif repository_name == None: self.record_group_view(group_name)
else: self.record_repository_view(group_name, repository_name)
def fetch_succeeded(self, group_name, repository_name, remote_identity):
if remote_identity and remote_identity.hash in self.stats_ignored: return
if self.stats_enabled:
if group_name and repository_name: self.record_fetch(group_name, repository_name)
def push_succeeded(self, group_name, repository_name, remote_identity):
if self.stats_enabled:
if group_name and repository_name: self.record_push(group_name, repository_name)
def download_succeeded(self, group_name, repository_name, remote_identity):
if remote_identity and remote_identity.hash in self.stats_ignored: return
if self.stats_enabled:
if group_name and repository_name: self.record_download(group_name, repository_name)
def release_download_succeeded(self, group_name, repository_name, remote_identity):
if remote_identity and remote_identity.hash in self.stats_ignored: return
if self.stats_enabled:
if group_name and repository_name: self.record_release_download(group_name, repository_name)
def _get_day(self):
timefmt = "%Y-%m-%d"
timestamp = time.localtime(time.time())
return time.strftime(timefmt, timestamp)
def record_page_view(self, page):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not day in self.stats["pages"]["front"]: self.stats["pages"]["front"][day] = 0
self.stats["pages"]["front"][day] += 1
except Exception as e: RNS.log(f"Error while recording page view stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
def record_group_view(self, group_name):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = self.STATS_INIT_GROUP
if not "view" in self.stats["groups"][group_name]: self.stats["groups"][group_name]["view"] = {}
stats = self.stats["groups"][group_name]["view"]
if not day in stats: stats[day] = 0
stats[day] += 1
except Exception as e: RNS.log(f"Error while recording group view stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
def record_repository_view(self, group_name, repository_name):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = self.STATS_INIT_GROUP
repos = self.stats["groups"][group_name]["repositories"]
if not repository_name in repos: repos[repository_name] = self.STATS_INIT_REPO
if not "view" in repos[repository_name]: repos[repository_name]["view"] = {}
stats = repos[repository_name]["view"]
if not day in stats: stats[day] = 0
stats[day] += 1
except Exception as e: RNS.log(f"Error while recording repository view stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
def record_fetch(self, group_name, repository_name):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = self.STATS_INIT_GROUP
repos = self.stats["groups"][group_name]["repositories"]
if not repository_name in repos: repos[repository_name] = self.STATS_INIT_REPO
if not "fetch" in repos[repository_name]: repos[repository_name]["fetch"] = {}
stats = repos[repository_name]["fetch"]
if not day in stats: stats[day] = 0
stats[day] += 1
except Exception as e: RNS.log(f"Error while recording fetch stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
def record_push(self, group_name, repository_name):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = self.STATS_INIT_GROUP
repos = self.stats["groups"][group_name]["repositories"]
if not repository_name in repos: repos[repository_name] = self.STATS_INIT_REPO
if not "push" in repos[repository_name]: repos[repository_name]["push"] = {}
stats = repos[repository_name]["push"]
if not day in stats: stats[day] = 0
stats[day] += 1
except Exception as e: RNS.log(f"Error while recording push stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
def record_download(self, group_name, repository_name):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = self.STATS_INIT_GROUP
repos = self.stats["groups"][group_name]["repositories"]
if not repository_name in repos: repos[repository_name] = self.STATS_INIT_REPO
if not "download" in repos[repository_name]: repos[repository_name]["download"] = {}
stats = repos[repository_name]["download"]
if not day in stats: stats[day] = 0
stats[day] += 1
except Exception as e: RNS.log(f"Error while recording download stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
def record_release_download(self, group_name, repository_name):
def job():
try:
with self.stats_lock:
day = self._get_day()
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = self.STATS_INIT_GROUP
repos = self.stats["groups"][group_name]["repositories"]
if not repository_name in repos: repos[repository_name] = self.STATS_INIT_REPO
if not "release_download" in repos[repository_name]: repos[repository_name]["release_download"] = {}
stats = repos[repository_name]["release_download"]
if not day in stats: stats[day] = 0
stats[day] += 1
except Exception as e: RNS.log(f"Error while recording release download stats: {e}", RNS.LOG_ERROR)
threading.Thread(target=job, daemon=True).start()
__default_rngit_config__ = '''# This is the default rngit config file.
# You will need to edit it to specify repository locations and
# access permissions.
[rngit]
# Automatic announce interval in minutes.
# 6 hours by default.
announce_interval = 360
# An optional name for this node, included
# in announces.
# node_name = Anonymous Git Node
# You can enable collecting view, fetch and push statistics
# which can be displayed on the stats pages of repositories.
# Remember to set the "s" (stats) permission appropriately
# for statistics to actually be viewable by anyone.
# record_stats = no
# stats_ignore_identities = 9710b86ba12c42d1d8f30f74fe509286
# stats_push_ignore_identities = 5bffebe038654304dafcbe12cbcd0412
# You can block specific identities from any interaction
# with this node.
# blocked_identities = d31aeea49873006f13b3415520666a4e
[repositories]
# You can define multiple repository groups, each with a path
# to the directory containing "repo_name.git" directories.
internal = /path/to/directory/with/git/repositories
public = /another/path/to/directory/with/git/repositories
showcase = /another/path/to/directory/with/git/repositories
# To add a short description to your repositories, you can
# either place a "repo_name.description" file in the same
# directory as the repository folder, or set it in the bare
# repository with `git config repository.description`.
# If you have mirrored repositories with the "rngit mirror"
# command, you can configure the global mirroring interval
# in hours.
# mirror_interval = 24
[aliases]
# You can define aliases for commonly used identity hashes
# in this section. Each line must be in the format
# aliased_name = IDENTITY_HASH
#
# These hashes are used for the permissions system and
# identity resolution. For rngit CLI client operations,
# aliases must be defined in ~/.rngit/client_config.
# alice = d09285e660cfe27cee6d9a0beb58b7e0
# bob = ffcffb4e255e156e77f79b82c13086a6
[access]
# You can apply permissions for all repositories within
# different repository collections like this:
public = r:all, w:9710b86ba12c42d1d8f30f74fe509286
internal = rw:9710b86ba12c42d1d8f30f74fe509286
# By default, all repositories sourced from the con-
# figured repository collection paths have no permissions
# enabled, and will be neither readable nor writable.
#
# The following permissions are supported:
# r = read (clone, fetch, view)
# w = write (push, create and manage work documents)
# rw = read/write
# c = create (create new repositories in group)
# s = stats (view repository statistics)
# rel = release (create and manage releases)
# i = interact (comment on work documents)
# p = propose (propose new work documents)
# adm = admin (full administrative access)
#
# To configure permissions per repository, you must create
# an ".allowed" file matching the repository name. If the
# repository is in a folder called "my_project.git", create
# a "my_project.allowed" file next to it. This file must
# contain a permission statement on each line in the form of
# "r:IDENTITY_HASH", "w:IDENTITY_HASH" or "r:IDENTITY_HASH".
# Instead of IDENTITY_HASH, you can also use "all" or "none".
#
# You can also make the allow-files executable, and have them
# evaluate or source the permissions from somewhere else,
# and then output the results to stdout.
#
# Additionally, you can create a "group.allowed" file in the
# root of a repository group directory, which will apply to
# all repositories within this group. The same syntax and
# functionality applies here.
[pages]
# You can run a nomadnet-compatible page node to serve
# repository information if required. Access permissions
# will follow those configured per group and repository.
#
# The page server supports automatic markdown to micron
# conversion for repository readmes and other files. If
# you have the pygments Python module installed, syntax
# highlighting will also be automatically applied.
#
# The page server is highly customizable, and you can
# provide custom templates for each page type by placing
# a corresponding "template_name.mu" file in the
# ~/.rngit/templates directory. The supported template
# names are "base", "front", "group", "repo", "tree",
# "blob", "commits", "commit", "refs", "stats", "releases",
# "release", "work" and "work_doc". You should include a
# {PAGE_CONTENT} variable somewhere in your templates,
# the rendered page content will be injected into this
# variable.
# serve_nomadnet = no
# It is possible to disable Nerd Font icons and instead
# use simpler (but more compatible) unicode icons.
# unicode_icons = yes
[logging]
# Valid log levels are 0 through 7:
# 0: Log only critical information
# 1: Log errors and lower log levels
# 2: Log warnings and lower log levels
# 3: Log notices and lower log levels
# 4: Log info and lower (this is the default)
# 5: Verbose logging
# 6: Debug logging
# 7: Extreme logging
loglevel = 4
'''.splitlines()
RELEASE_NOTES_TEMPLATE = """# Enter release notes for {TAG}.
# Lines starting with '#' will be ignored.
# Save and exit the editor when done, or exit without saving to abort.
"""
COMMENT_TEMPLATE = "# Remove this line and enter your update. Save and exit when done, or save an empty document to abort abort."
CREATE_DOC_TEMPLATE = "# Remove this line and enter your document content. Save and exit when done, or save an empty document to abort abort."
PERMISSIONS_TEMPLATE ="# No permissions are currently defined for this entity. Add them below, and save and exit when you are done."
REPO_CREATE_PERMS_TEMPLATE = "adm:{IDENTITY_HASH}"
if __name__ == "__main__": main()