feat: integrate RNS FileSync functionality, including handler setup, API endpoints, and frontend components for file synchronization

This commit is contained in:
Ivan 2026-07-19 13:46:43 -05:00
parent a9cc4b996b
commit 9f71f3420c
No known key found for this signature in database
130 changed files with 9412 additions and 112 deletions

View file

@ -6,6 +6,13 @@ screenshots/
vendor/lxmfy/tests/
vendor/lxmfy/docs/
vendor/lxmfy/.gitea/
vendor/rns_filesync/tests/
vendor/rns_filesync/docs/
vendor/rns_filesync/docker/
vendor/rns_filesync/packaging/
vendor/rns_filesync/sideband/
vendor/rns_filesync/man/
vendor/rns_filesync/scripts/
vendor/offline/
# Development files

View file

@ -13,6 +13,13 @@ recursive-exclude vendor/offline *
recursive-exclude vendor/lxmfy/tests *
recursive-exclude vendor/lxmfy/docs *
recursive-exclude vendor/lxmfy/docker *
recursive-exclude vendor/rns_filesync/tests *
recursive-exclude vendor/rns_filesync/docs *
recursive-exclude vendor/rns_filesync/docker *
recursive-exclude vendor/rns_filesync/packaging *
recursive-exclude vendor/rns_filesync/sideband *
recursive-exclude vendor/rns_filesync/man *
recursive-exclude vendor/rns_filesync/scripts *
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

View file

@ -275,6 +275,7 @@ tasks:
tests/backend/test_rns_link_manager.py
tests/backend/test_rns_link_fuzzing.py
tests/backend/test_rns_link_plugin.py
tests/backend/test_rns_filesync_security.py
-q
- task: test:eect
@ -283,6 +284,11 @@ tasks:
cmds:
- uv run pytest tests/backend/eect/packs -m eect -q --tb=short
test:filesync:security:
desc: Adversarial and Hypothesis fuzz tests for RNS FileSync
cmds:
- uv run pytest tests/backend/test_rns_filesync_security.py tests/backend/test_rns_filesync_handler.py -q --tb=short
test:lv:
desc: Live Validation ladder (set MESHCHAT_LIVE_VALIDATION=1 for L2-L3)
cmds:

1
android/.gitignore vendored
View file

@ -17,6 +17,7 @@ local.properties
# Gradle-synced Chaquopy Python tree (see app/build.gradle)
/app/src/main/python/meshchatx/
/app/src/main/python/lxmfy/
/app/src/main/python/rns_filesync/
# libcodec2.so copied from vendor wheels (scripts/android/sync-codec2-jni-libs.sh)
/app/src/main/jniLibs/*/libcodec2.so

View file

@ -28,7 +28,7 @@ cd android
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease
```
There is a **single** application variant (no product flavors). Gradle syncs the **entire** `meshchatx/` tree into `app/src/main/python/meshchatx/` (including `public/repository-server-bundled` for the in-app repository server), and syncs vendored **`vendor/lxmfy/lxmfy`** into `app/src/main/python/lxmfy/` (required for bots; not installed via Chaquopy pip). The `fetchRepositoryBundledWheels` task runs before sync when bundled wheels are missing; if repo root `dist/reticulum_meshchatx-*.whl` exists (e.g. from `python -m build --wheel -o dist .`), that wheel is preferred over PyPI for the bundled set.
There is a **single** application variant (no product flavors). Gradle syncs the **entire** `meshchatx/` tree into `app/src/main/python/meshchatx/` (including `public/repository-server-bundled` for the in-app repository server), and syncs vendored **`vendor/lxmfy/lxmfy`** into `app/src/main/python/lxmfy/` (required for bots; not installed via Chaquopy pip) plus **`vendor/rns_filesync/rns_filesync`** into `app/src/main/python/rns_filesync/` (required for FileSync). The `fetchRepositoryBundledWheels` task runs before sync when bundled wheels are missing; if repo root `dist/reticulum_meshchatx-*.whl` exists (e.g. from `python -m build --wheel -o dist .`), that wheel is preferred over PyPI for the bundled set.
### Native ABIs (universal APK)

View file

@ -6,6 +6,7 @@ plugins {
def repoRoot = rootProject.projectDir.parentFile
def meshchatSourceDir = new File(repoRoot, "meshchatx")
def lxmfySourceDir = new File(repoRoot, "vendor/lxmfy/lxmfy")
def rnsFilesyncSourceDir = new File(repoRoot, "vendor/rns_filesync/rns_filesync")
def repositoryBundledWheelsDir = new File(meshchatSourceDir, "public/repository-server-bundled/bundled")
def chaquopyBuildPython = System.getenv("CHAQUOPY_BUILD_PYTHON")
def pythonTempDir = new File(buildDir, "python-tmp")
@ -268,9 +269,37 @@ tasks.register("syncLxmfyPython", Sync) {
into(file("$projectDir/src/main/python/lxmfy"))
}
/*
* Desktop installs rns_filesync via setuptools (pyproject where includes vendor/rns_filesync).
* Chaquopy only sees android/app/src/main/python plus pip installs, so the
* vendored package must be synced here or FileSync imports fail at boot.
*/
tasks.register("syncRnsFilesyncPython", Sync) {
if (!rnsFilesyncSourceDir.exists()) {
throw new org.gradle.api.GradleException(
"Missing vendored rns_filesync package at ${rnsFilesyncSourceDir}. " +
"Expected vendor/rns_filesync/rns_filesync from the MeshChatX tree."
)
}
def initPy = new File(rnsFilesyncSourceDir, "__init__.py")
if (!initPy.exists()) {
throw new org.gradle.api.GradleException("Missing ${initPy}; vendored rns_filesync tree looks incomplete.")
}
doFirst {
delete(file("$projectDir/src/main/python/rns_filesync"))
}
from(rnsFilesyncSourceDir)
includeEmptyDirs = false
into(file("$projectDir/src/main/python/rns_filesync"))
}
tasks.configureEach { task ->
if (task.name.toLowerCase().contains("merge") && task.name.toLowerCase().contains("pythonsources")) {
task.dependsOn(tasks.named("syncMeshchatPython"), tasks.named("syncLxmfyPython"))
task.dependsOn(
tasks.named("syncMeshchatPython"),
tasks.named("syncLxmfyPython"),
tasks.named("syncRnsFilesyncPython"),
)
}
}

View file

@ -8,6 +8,9 @@ ROOT = Path(__file__).resolve().parent
_vendor_lxmfy = ROOT / "vendor" / "lxmfy"
if _vendor_lxmfy.is_dir() and (_vendor_lxmfy / "lxmfy").is_dir():
sys.path.insert(0, str(_vendor_lxmfy))
_vendor_rns_filesync = ROOT / "vendor" / "rns_filesync"
if _vendor_rns_filesync.is_dir() and (_vendor_rns_filesync / "rns_filesync").is_dir():
sys.path.insert(0, str(_vendor_rns_filesync))
from cx_Freeze import Executable, setup # noqa: E402
@ -59,6 +62,7 @@ packages = [
"LXMF",
"LXST",
"lxmfy",
"rns_filesync",
"websockets",
"pycparser",
"cffi",

View file

@ -80,7 +80,7 @@ Critical lifecycle facts:
| `tests/frontend/` | vitest |
| `tests/e2e/` | Playwright |
| `docs/en/` | In-app / shipped English docs |
| `vendor/` | Vendored deps (for example LXMFy) |
| `vendor/` | Vendored deps (LXMFy, RNS FileSync) |
| `Taskfile.yml` | Preferred command entrypoints |
| `docs/agents/` | Agent guidance (this tree) |
| `AGENTS.md` | Short pointer to `docs/agents/` |

View file

@ -24,7 +24,7 @@ Keep Chaquopy backend boot, WebView file choosers, storage locks, and external n
## Navigation and packaging
- External http(s) links open in the system browser. Do not navigate the WebView away from the app.
- Vendored `lxmfy` is synced into Chaquopy `src/main/python/`. Android pip does not install it like desktop setuptools.
- Vendored `lxmfy` and `rns_filesync` are synced into Chaquopy `src/main/python/`. Android pip does not install them like desktop setuptools.
- RNS panic containment matters on Android (see `deferred-network-startup`).
## RNode on Android

View file

@ -17,12 +17,15 @@ Use these when messages or pages fail despite interfaces showing as enabled.
## File transfer and shell
| Tool | Purpose |
| ---- | ------------------------------------------ |
| RNCP | Send or fetch files over Reticulum |
| RNSH | Remote shell sessions with streamed output |
| Tool | Purpose |
| ------------ | ---------------------------------------------------- |
| RNCP | Send or fetch files over Reticulum |
| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
| RNSH | Remote shell sessions with streamed output |
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
FileSync uses the bundled `rns_filesync` package and keeps sync state under the active identity storage directory.
## Messaging helpers
@ -69,7 +72,7 @@ Translator calls respect **privacy mode**. When privacy mode blocks outbound HTT
## Coming soon
The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
The registry marks **RNS Tunnel** as coming soon. It does not have a route in the current release.
## Relay chat server

Binary file not shown.

View file

@ -741,6 +741,17 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.rncp_handler = value
@property
def rns_filesync_handler(self):
return (
self.current_context.rns_filesync_handler if self.current_context else None
)
@rns_filesync_handler.setter
def rns_filesync_handler(self, value):
if self.current_context:
self.current_context.rns_filesync_handler = value
@property
def rnsh_manager(self):
return self.current_context.rnsh_manager if self.current_context else None
@ -2892,6 +2903,15 @@ class ReticulumMeshChat:
except Exception:
pass
if top_level == "rns_filesync":
try:
module = importlib.import_module("rns_filesync")
ver = getattr(module, "__version__", None)
if ver:
return str(ver)
except Exception:
pass
embedded_specs: dict[str, tuple[str, str]] = {
"aiohttp": ("aiohttp", "__version__"),
"aiohttp-session": ("aiohttp_session", "__version__"),
@ -2901,6 +2921,8 @@ class ReticulumMeshChat:
"bcrypt": ("bcrypt", "__version__"),
"ply": ("ply", "__version__"),
"lxmfy": ("lxmfy", "__version__"),
"rns-filesync": ("rns_filesync", "__version__"),
"rns_filesync": ("rns_filesync", "__version__"),
}
if package_name in embedded_specs:
mod_name, attr = embedded_specs[package_name]
@ -8082,6 +8104,7 @@ class ReticulumMeshChat:
"ply": self.get_package_version("ply"),
"bcrypt": self.get_package_version("bcrypt"),
"lxmfy": self.get_package_version("lxmfy"),
"rns_filesync": self.get_package_version("rns-filesync"),
},
"storage_path": self.storage_path,
"database_path": _safe_database_path(),
@ -13541,6 +13564,250 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
# --- RNS FileSync ---
def _filesync_require_handler():
return self._require_rns_tool_handler(
self.rns_filesync_handler,
"RNS FileSync",
)
@routes.get("/api/v1/filesync/status")
async def filesync_status(_request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
return web.json_response(self.rns_filesync_handler.get_status())
@routes.post("/api/v1/filesync/start")
async def filesync_start(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = {}
with contextlib.suppress(Exception):
data = await request.json()
if not isinstance(data, dict):
data = {}
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.start,
sync_directory=data.get("sync_directory"),
monitor=data.get("monitor"),
announce_interval=data.get("announce_interval"),
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "failed to start")},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/stop")
async def filesync_stop(_request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
try:
result = await asyncio.to_thread(self.rns_filesync_handler.stop)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
return web.json_response(result)
@routes.get("/api/v1/filesync/peers")
async def filesync_peers(_request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
return web.json_response({"peers": self.rns_filesync_handler.list_peers()})
@routes.get("/api/v1/filesync/files")
async def filesync_files(_request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
return web.json_response({"files": self.rns_filesync_handler.list_files()})
@routes.post("/api/v1/filesync/connect")
async def filesync_connect(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
identity_hash = data.get("identity_hash", "")
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.connect_peer,
identity_hash,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "connect failed"), **result},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/disconnect")
async def filesync_disconnect(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
peer_id = data.get("peer_id", "")
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.disconnect_peer,
peer_id,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "disconnect failed")},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/announce")
async def filesync_announce(_request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
try:
result = await asyncio.to_thread(self.rns_filesync_handler.announce_now)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "announce failed")},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/browse")
async def filesync_browse(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
peer_id = data.get("peer_id", "")
timeout = data.get("timeout", 10.0)
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.browse_peer,
peer_id,
timeout,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{
"message": result.get("error", "browse failed"),
"files": result.get("files", []),
},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/download")
async def filesync_download(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
peer_id = data.get("peer_id", "")
path = data.get("path", "")
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.download_file,
peer_id,
path,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "download failed"), **result},
status=400,
)
return web.json_response(result)
@routes.get("/api/v1/filesync/acl")
async def filesync_acl_get(_request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
return web.json_response(self.rns_filesync_handler.get_acl())
@routes.post("/api/v1/filesync/acl")
async def filesync_acl_post(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
perms = data.get("perms")
if perms is not None and not isinstance(perms, list):
return web.json_response(
{"message": "perms must be a list"},
status=400,
)
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.update_acl,
identity_hash=data.get("identity_hash"),
perms=perms,
enforce=data.get("enforce"),
rules_text=data.get("rules_text"),
replace=bool(data.get("replace", False)),
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "acl update failed")},
status=400,
)
return web.json_response(result)
@routes.patch("/api/v1/filesync/settings")
async def filesync_settings(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.update_settings,
sync_directory=data.get("sync_directory"),
monitor=data.get("monitor"),
announce_interval=data.get("announce_interval"),
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "settings update failed")},
status=400,
)
return web.json_response(result)
# --- Plugin API ---
@routes.get("/api/v1/plugins")

View file

@ -52,6 +52,9 @@ lxmf 1.0.1
lxmfy 1.6.5
License: BSD-0-Clause
Author: Quad4 <team@quad4.io>
rns-filesync 1.0.0
License: BSD-2-Clause
Author: Sudo-Ivan / Quad4.io
lxst 0.5.0
License: Other/Proprietary License
Author: Mark Qvist

View file

@ -95,6 +95,12 @@
"author": "Quad4 <team@quad4.io>",
"license": "BSD-0-Clause"
},
{
"name": "rns-filesync",
"version": "1.0.0",
"author": "Sudo-Ivan / Quad4.io",
"license": "BSD-2-Clause"
},
{
"name": "lxst",
"version": "0.5.0",

View file

@ -34,6 +34,7 @@ from meshchatx.src.backend.rnpath_handler import RNPathHandler
from meshchatx.src.backend.rnpath_trace_handler import RNPathTraceHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.rnsh_manager import RNSHManager
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.rnx_manager import RNXManager
from meshchatx.src.backend.rrc import RRCManager, RRCServerManager
@ -93,6 +94,7 @@ class IdentityContext:
self.notification_sound_manager = None
self.auto_propagation_manager = None
self.rncp_handler = None
self.rns_filesync_handler = None
self.rnsh_manager = None
self.rnx_manager = None
self.rnstatus_handler = None
@ -135,6 +137,14 @@ class IdentityContext:
except Exception:
pass
def _filesync_emit(self, message):
try:
from meshchatx.src.backend.async_utils import AsyncUtils
AsyncUtils.run_async(self.app._broadcast_websocket_message(message))
except Exception:
pass
def setup(self):
print(f"Setting up Identity Context for {self.identity_hash}...")
@ -306,6 +316,12 @@ class IdentityContext:
storage_dir=self.app.storage_dir,
)
self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed
self.rns_filesync_handler = RnsFilesyncHandler(
reticulum_instance=getattr(self.app, "reticulum", None),
identity=self.identity,
storage_dir=self.storage_path,
emit_callback=self._filesync_emit,
)
self.rnsh_manager = RNSHManager(
storage_dir=self.storage_path,
reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
@ -733,6 +749,11 @@ class IdentityContext:
self.rncp_handler.teardown_receive_destination()
self.rncp_handler = None
if self.rns_filesync_handler:
with contextlib.suppress(Exception):
self.rns_filesync_handler.teardown()
self.rns_filesync_handler = None
self.rnstatus_handler = None
self.rnpath_handler = None
self.rnpath_trace_handler = None

View file

@ -134,10 +134,19 @@ def _python_roots_from_pyproject(repo_root: Path) -> tuple[str, ...]:
return tuple(sorted(set(names), key=lambda n: n.lower()))
def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
if _dist_for_requirement_name("lxmfy") is not None:
def _bundled_vendor_license_row(
repo_root: Path,
*,
vendor_dir: str,
dist_name: str,
package_name: str | None = None,
) -> dict[str, Any] | None:
lookup_name = package_name or dist_name
if _dist_for_requirement_name(lookup_name) is not None:
return None
vp = repo_root / "vendor" / "lxmfy" / "pyproject.toml"
if _dist_for_requirement_name(dist_name) is not None:
return None
vp = repo_root / "vendor" / vendor_dir / "pyproject.toml"
if not vp.is_file():
return None
try:
@ -149,7 +158,7 @@ def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
if not isinstance(proj, dict):
return None
name = proj.get("name")
if name != "lxmfy":
if name != dist_name and name != lookup_name:
return None
version = proj.get("version")
version_s = version.strip() if isinstance(version, str) and version.strip() else ""
@ -165,36 +174,72 @@ def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
elif an or ae:
author = an or ae
lic = proj.get("license")
license_s = lic.strip() if isinstance(lic, str) and lic.strip() else ""
if isinstance(lic, dict):
license_s = str(lic.get("text") or lic.get("file") or "").strip() or ""
elif isinstance(lic, str) and lic.strip():
license_s = lic.strip()
else:
license_s = ""
return {
"name": "lxmfy",
"name": dist_name,
"version": version_s,
"author": author,
"license": license_s,
}
def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
return _bundled_vendor_license_row(
repo_root,
vendor_dir="lxmfy",
dist_name="lxmfy",
)
def _bundled_rns_filesync_license_row(repo_root: Path) -> dict[str, Any] | None:
return _bundled_vendor_license_row(
repo_root,
vendor_dir="rns_filesync",
dist_name="rns-filesync",
package_name="rns_filesync",
)
def _merge_bundled_vendor_rows(
repo_root: Path,
rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
merged = list(rows)
existing = {str(r.get("name") or "").lower() for r in merged}
for row_fn, names in (
(_bundled_lxmfy_license_row, ("lxmfy",)),
(_bundled_rns_filesync_license_row, ("rns-filesync", "rns_filesync")),
):
if any(n in existing for n in names):
continue
row = row_fn(repo_root)
if row is None:
continue
merged.append(row)
existing.add(str(row.get("name") or "").lower())
merged.sort(key=lambda r: str(r.get("name", "")).lower())
return merged
def _merge_bundled_lxmfy(
repo_root: Path,
rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if any(str(r.get("name") or "").lower() == "lxmfy" for r in rows):
return rows
row = _bundled_lxmfy_license_row(repo_root)
if row is None:
return rows
merged = [*rows, row]
merged.sort(key=lambda r: str(r.get("name", "")).lower())
return merged
return _merge_bundled_vendor_rows(repo_root, rows)
def _collect_backend_licenses_live() -> list[dict[str, Any]]:
repo = _repo_root()
for root in _ROOT_DIST_CANDIDATES:
if _dist_for_requirement_name(root) is not None:
return _merge_bundled_lxmfy(repo, _collect_python_transitive((root,)))
return _merge_bundled_vendor_rows(repo, _collect_python_transitive((root,)))
roots = _python_roots_from_pyproject(repo)
return _merge_bundled_lxmfy(repo, _collect_python_transitive(roots))
return _merge_bundled_vendor_rows(repo, _collect_python_transitive(roots))
def _load_embedded_backend_licenses() -> list[dict[str, Any]] | None:

View file

@ -0,0 +1,400 @@
# SPDX-License-Identifier: 0BSD
"""Identity-scoped wrapper around vendored rns_filesync.FileSyncService."""
from __future__ import annotations
import contextlib
import json
import os
import threading
from collections.abc import Callable
from typing import Any
from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT
from rns_filesync.permissions import PermissionStore
from rns_filesync.service import FileSyncService
class RnsFilesyncHandler:
"""Host FileSync against the shared Reticulum stack for one identity."""
def __init__(
self,
reticulum_instance,
identity,
storage_dir: str,
emit_callback: Callable[[dict[str, Any]], None] | None = None,
):
self.reticulum = reticulum_instance
self.identity = identity
self.storage_dir = storage_dir
self._emit_callback = emit_callback
self._lock = threading.RLock()
self.service: FileSyncService | None = None
self._root = os.path.join(storage_dir, "filesync")
self._settings_path = os.path.join(self._root, "settings.json")
self._acl_path = os.path.join(self._root, "acl.txt")
self._sync_directory = os.path.join(self._root, "sync")
self._monitor = True
self._announce_interval = ANNOUNCE_INTERVAL_DEFAULT
self._load_settings()
def _emit(self, event_type: str, payload: dict[str, Any] | None = None) -> None:
if not self._emit_callback:
return
message = {"type": event_type}
if payload:
message.update(payload)
try:
self._emit_callback(message)
except Exception:
pass
def _default_sync_directory(self) -> str:
return os.path.join(self._root, "sync")
def _load_settings(self) -> None:
os.makedirs(self._root, exist_ok=True)
if not os.path.isfile(self._settings_path):
return
try:
with open(self._settings_path, encoding="utf-8") as handle:
data = json.load(handle)
except Exception:
return
if not isinstance(data, dict):
return
sync_dir = data.get("sync_directory")
if isinstance(sync_dir, str) and sync_dir.strip():
self._sync_directory = os.path.realpath(os.path.expanduser(sync_dir.strip()))
monitor = data.get("monitor")
if isinstance(monitor, bool):
self._monitor = monitor
interval = data.get("announce_interval")
if isinstance(interval, int) and interval >= 10:
self._announce_interval = interval
def _save_settings(self) -> None:
os.makedirs(self._root, exist_ok=True)
payload = {
"sync_directory": self._sync_directory,
"monitor": self._monitor,
"announce_interval": self._announce_interval,
}
tmp = f"{self._settings_path}.tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
os.replace(tmp, self._settings_path)
def _load_permissions(self) -> PermissionStore:
permissions = PermissionStore()
if os.path.isfile(self._acl_path):
with contextlib.suppress(Exception):
permissions.load_file(self._acl_path)
return permissions
def _save_acl(self, permissions: PermissionStore) -> None:
os.makedirs(self._root, exist_ok=True)
lines: list[str] = []
rules = permissions.as_dict()
for perm in ("read", "write", "delete"):
for target in rules.get(perm, []):
short = {"read": "r", "write": "w", "delete": "d"}[perm]
lines.append(f"{short}:{target}")
if permissions.enabled and not lines:
lines.append("# enforce=true")
elif not permissions.enabled:
lines.insert(0, "# enforce=false")
else:
lines.insert(0, "# enforce=true")
tmp = f"{self._acl_path}.tmp"
with open(tmp, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
os.replace(tmp, self._acl_path)
def _wire_callbacks(self, service: FileSyncService) -> None:
service.on_peer_connected = lambda payload: self._emit(
"filesync.peer.connected",
payload if isinstance(payload, dict) else {"peer": payload},
)
service.on_peer_disconnected = lambda payload: self._emit(
"filesync.peer.disconnected",
payload if isinstance(payload, dict) else {"peer": payload},
)
service.on_sync_progress = lambda payload: self._emit(
"filesync.sync.progress",
payload if isinstance(payload, dict) else {"progress": payload},
)
service.on_file_updated = lambda payload: self._emit(
"filesync.file.updated",
payload if isinstance(payload, dict) else {"path": payload},
)
service.on_file_deleted = lambda payload: self._emit(
"filesync.file.deleted",
payload if isinstance(payload, dict) else {"path": payload},
)
service.on_error = lambda payload: self._emit(
"filesync.error",
payload if isinstance(payload, dict) else {"error": str(payload)},
)
def _permissions(self) -> PermissionStore:
if self.service is not None:
return self.service.permissions
return self._load_permissions()
def get_status(self) -> dict[str, Any]:
with self._lock:
if self.service is not None and self.service.get_status().get("running"):
status = self.service.get_status()
status["monitor"] = self._monitor
status["announce_interval"] = self._announce_interval
status["config_directory"] = self._root
return status
return {
"running": False,
"sync_directory": self._sync_directory,
"identity_hash": (
self.identity.hash.hex()
if getattr(self.identity, "hash", None) is not None
else None
),
"destination_hash": None,
"peers": 0,
"files": 0,
"whitelist": self._permissions().enabled,
"monitor": self._monitor,
"announce_interval": self._announce_interval,
"config_directory": self._root,
}
def start(
self,
*,
sync_directory: str | None = None,
monitor: bool | None = None,
announce_interval: int | None = None,
) -> dict[str, Any]:
with self._lock:
if sync_directory is not None:
cleaned = str(sync_directory).strip()
if not cleaned:
return {"ok": False, "error": "sync_directory is required"}
self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
if monitor is not None:
self._monitor = bool(monitor)
if announce_interval is not None:
try:
interval = int(announce_interval)
except (TypeError, ValueError):
return {"ok": False, "error": "invalid announce_interval"}
if interval < 10:
return {"ok": False, "error": "announce_interval must be >= 10"}
self._announce_interval = interval
if self.service is not None:
status = self.service.get_status()
if status.get("running"):
return {"ok": True, "already_running": True, **status}
os.makedirs(self._sync_directory, exist_ok=True)
permissions = self._load_permissions()
if os.path.isfile(self._acl_path):
try:
with open(self._acl_path, encoding="utf-8") as handle:
first = handle.readline().strip()
if first == "# enforce=false":
permissions._enforce = False
except Exception:
pass
service = FileSyncService(
identity=self.identity,
sync_directory=self._sync_directory,
storage_dir=self._root,
reticulum=self.reticulum,
permissions=permissions,
own_reticulum=False,
)
self._wire_callbacks(service)
dest = service.start(
monitor=self._monitor,
announce_interval=self._announce_interval,
)
self.service = service
self._save_settings()
status = service.get_status()
return {
"ok": True,
"destination_hash": dest,
**status,
}
def stop(self) -> dict[str, Any]:
with self._lock:
if self.service is None:
return {"ok": True, "running": False}
with contextlib.suppress(Exception):
self.service.stop()
self.service = None
return {"ok": True, "running": False}
def teardown(self) -> None:
self.stop()
def list_peers(self) -> list[dict[str, Any]]:
with self._lock:
if self.service is None:
return []
return self.service.list_peers()
def list_files(self) -> list[dict[str, Any]]:
with self._lock:
if self.service is None:
return []
return self.service.list_files()
def connect_peer(self, identity_hash: str) -> dict[str, Any]:
with self._lock:
if self.service is None:
return {"ok": False, "error": "filesync is not running"}
cleaned = str(identity_hash or "").strip().lower().replace(":", "")
if not cleaned:
return {"ok": False, "error": "identity_hash is required"}
return self.service.connect_peer(cleaned)
def disconnect_peer(self, peer_id: str) -> dict[str, Any]:
with self._lock:
if self.service is None:
return {"ok": False, "error": "filesync is not running"}
cleaned = str(peer_id or "").strip()
if not cleaned:
return {"ok": False, "error": "peer_id is required"}
self.service.disconnect_peer(cleaned)
return {"ok": True, "peer_id": cleaned}
def announce_now(self) -> dict[str, Any]:
with self._lock:
if self.service is None:
return {"ok": False, "error": "filesync is not running"}
self.service.announce_now()
return {"ok": True}
def browse_peer(self, peer_id: str, timeout: float = 10.0) -> dict[str, Any]:
with self._lock:
if self.service is None:
return {"ok": False, "error": "filesync is not running", "files": []}
cleaned = str(peer_id or "").strip()
if not cleaned:
return {"ok": False, "error": "peer_id is required", "files": []}
try:
timeout_f = float(timeout)
except (TypeError, ValueError):
timeout_f = 10.0
files = self.service.browse_peer(cleaned, timeout=timeout_f)
return {"ok": True, "peer_id": cleaned, "files": files}
def download_file(self, peer_id: str, path: str) -> dict[str, Any]:
with self._lock:
if self.service is None:
return {"ok": False, "error": "filesync is not running"}
cleaned_peer = str(peer_id or "").strip()
cleaned_path = str(path or "").strip()
if not cleaned_peer:
return {"ok": False, "error": "peer_id is required"}
if not cleaned_path:
return {"ok": False, "error": "path is required"}
return self.service.download_file(cleaned_peer, cleaned_path)
def get_acl(self) -> dict[str, Any]:
with self._lock:
permissions = self._permissions()
return {
"enforce": permissions.enabled,
"rules": permissions.as_dict(),
}
def update_acl(
self,
*,
identity_hash: str | None = None,
perms: list[str] | None = None,
enforce: bool | None = None,
rules_text: str | None = None,
replace: bool = False,
) -> dict[str, Any]:
with self._lock:
if replace or rules_text is not None:
permissions = PermissionStore()
if rules_text:
permissions.load_allowed_text(rules_text)
else:
permissions = self._permissions()
if self.service is None:
# Work on a fresh copy so we can persist even when stopped.
permissions = self._load_permissions()
if identity_hash and perms is not None:
granted = permissions.grant(identity_hash, perms)
if not granted and perms:
return {"ok": False, "error": "no valid permissions provided"}
if enforce is not None:
permissions._enforce = bool(enforce)
if self.service is not None:
self.service.permissions = permissions
self._save_acl(permissions)
return {
"ok": True,
"enforce": permissions.enabled,
"rules": permissions.as_dict(),
}
def update_settings(
self,
*,
sync_directory: str | None = None,
monitor: bool | None = None,
announce_interval: int | None = None,
) -> dict[str, Any]:
with self._lock:
running = False
if self.service is not None:
running = bool(self.service.get_status().get("running"))
if sync_directory is not None:
if running:
return {
"ok": False,
"error": "stop filesync before changing sync directory",
}
cleaned = str(sync_directory).strip()
if not cleaned:
return {"ok": False, "error": "sync_directory is required"}
self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
if monitor is not None:
self._monitor = bool(monitor)
if announce_interval is not None:
try:
interval = int(announce_interval)
except (TypeError, ValueError):
return {"ok": False, "error": "invalid announce_interval"}
if interval < 10:
return {"ok": False, "error": "announce_interval must be >= 10"}
self._announce_interval = interval
self._save_settings()
return {
"ok": True,
"sync_directory": self._sync_directory,
"monitor": self._monitor,
"announce_interval": self._announce_interval,
"running": running,
}

View file

@ -20,6 +20,7 @@ _CRITICAL_IMPORTS = (
"RNS",
"LXMF",
"lxmfy",
"rns_filesync",
"aiohttp",
"bcrypt",
"cbor2",

View file

@ -0,0 +1,620 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<ToolsPageHeader
icon="folder-sync"
:title="$t('rns_filesync.title')"
:description="$t('rns_filesync.description')"
:eyebrow="$t('rns_filesync.eyebrow')"
accent="green"
/>
<div
class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
>
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div
class="p-4 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100 dark:border-emerald-900/20"
>
<div
class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2"
>
{{ $t("rns_filesync.usage_steps") }}
</div>
<div class="space-y-1.5 text-xs text-emerald-800/80 dark:text-emerald-300/80 leading-relaxed">
<p>{{ $t("rns_filesync.step_1") }}</p>
<p>{{ $t("rns_filesync.step_2") }}</p>
<p>{{ $t("rns_filesync.step_3") }}</p>
</div>
</div>
<div
class="border-b border-gray-200 dark:border-zinc-700 overflow-x-auto overscroll-x-contain -mx-4 px-4 sm:mx-0 sm:px-0"
>
<div class="flex w-max min-w-full sm:w-auto gap-1 sm:gap-2">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="[
activeTab === tab.id
? 'border-b-2 border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400',
'shrink-0 px-3 sm:px-4 py-2 text-sm font-semibold transition',
]"
@click="activeTab = tab.id"
>
{{ $t(tab.labelKey) }}
</button>
</div>
</div>
<div v-if="activeTab === 'status'" class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="glass-label">{{ $t("rns_filesync.sync_directory") }}</label>
<input
v-model="syncDirectory"
type="text"
class="glass-input w-full font-mono text-sm"
:disabled="status.running"
:placeholder="$t('rns_filesync.sync_directory_placeholder')"
/>
</div>
<div>
<label class="glass-label">{{ $t("rns_filesync.announce_interval") }}</label>
<input
v-model.number="announceInterval"
type="number"
min="10"
class="glass-input w-full"
/>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="monitor" type="checkbox" class="rounded" />
{{ $t("rns_filesync.monitor") }}
</label>
<div class="flex flex-wrap gap-2">
<button
v-if="!status.running"
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="busy"
@click="startService"
>
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
{{ $t("rns_filesync.start") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
:disabled="busy"
@click="stopService"
>
<MaterialDesignIcon icon-name="stop" class="w-4 h-4" />
{{ $t("rns_filesync.stop") }}
</button>
<button
type="button"
class="secondary-chip px-4 py-2 text-sm"
:disabled="busy || !status.running"
@click="announceNow"
>
<MaterialDesignIcon icon-name="bullhorn" class="w-4 h-4" />
{{ $t("rns_filesync.announce") }}
</button>
<button
type="button"
class="secondary-chip px-4 py-2 text-sm"
:disabled="busy"
@click="refreshStatus"
>
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
{{ $t("rns_filesync.refresh") }}
</button>
</div>
<div class="space-y-2 text-sm">
<div class="flex flex-wrap gap-x-4 gap-y-1">
<span>
{{ $t("rns_filesync.running") }}:
<strong>{{ status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no") }}</strong>
</span>
<span>
{{ $t("rns_filesync.peers_count") }}:
<strong>{{ status.peers || 0 }}</strong>
</span>
<span>
{{ $t("rns_filesync.files_count") }}:
<strong>{{ status.files || 0 }}</strong>
</span>
</div>
<div v-if="status.destination_hash" class="font-mono text-xs break-all">
<div class="font-semibold mb-1">{{ $t("rns_filesync.destination_hash") }}</div>
<button
type="button"
class="text-left hover:underline"
@click="copyHash(status.destination_hash)"
>
{{ status.destination_hash }}
</button>
</div>
<div v-if="lastProgress" class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("rns_filesync.last_progress") }}: {{ lastProgress }}
</div>
</div>
</div>
<div v-else-if="activeTab === 'peers'" class="space-y-4">
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="connectHash"
type="text"
class="glass-input flex-1 font-mono text-sm"
:placeholder="$t('rns_filesync.peer_hash_placeholder')"
/>
<button
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="busy || !status.running"
@click="connectPeer"
>
{{ $t("rns_filesync.connect") }}
</button>
</div>
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy"
@click="refreshPeers"
>
{{ $t("rns_filesync.refresh") }}
</button>
<div v-if="peers.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("rns_filesync.no_peers") }}
</div>
<ul v-else class="space-y-2">
<li
v-for="peer in peers"
:key="peer.peer_id"
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-zinc-700"
>
<div class="min-w-0 font-mono text-xs break-all">
<div>{{ peer.peer_id }}</div>
<div class="text-gray-500 dark:text-gray-400">
{{ peer.destination_hash }} · {{ peer.status }}
</div>
</div>
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm text-red-600 dark:text-red-300"
:disabled="busy"
@click="disconnectPeer(peer.peer_id)"
>
{{ $t("rns_filesync.disconnect") }}
</button>
</li>
</ul>
</div>
<div v-else-if="activeTab === 'files'" class="space-y-4">
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy"
@click="refreshFiles"
>
{{ $t("rns_filesync.refresh") }}
</button>
<div v-if="files.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("rns_filesync.no_files") }}
</div>
<ul v-else class="space-y-2">
<li
v-for="file in files"
:key="file.path"
class="p-3 rounded-lg border border-gray-200 dark:border-zinc-700 font-mono text-xs"
>
<div class="break-all">{{ file.path }}</div>
<div class="text-gray-500 dark:text-gray-400 mt-1">
{{ file.size }} B · {{ file.hash }}
</div>
</li>
</ul>
</div>
<div v-else-if="activeTab === 'browse'" class="space-y-4">
<div class="flex flex-col sm:flex-row gap-2">
<select v-model="browsePeerId" class="glass-input flex-1 font-mono text-sm">
<option value="">{{ $t("rns_filesync.select_peer") }}</option>
<option v-for="peer in peers" :key="peer.peer_id" :value="peer.peer_id">
{{ peer.peer_id }}
</option>
</select>
<button
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="busy || !status.running || !browsePeerId"
@click="browsePeer"
>
{{ $t("rns_filesync.browse") }}
</button>
</div>
<div v-if="remoteFiles.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("rns_filesync.no_remote_files") }}
</div>
<ul v-else class="space-y-2">
<li
v-for="file in remoteFiles"
:key="file.path || file"
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-zinc-700"
>
<div class="min-w-0 font-mono text-xs break-all">
{{ file.path || file }}
<span v-if="file.size != null" class="text-gray-500"> · {{ file.size }} B</span>
</div>
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy || !browsePeerId"
@click="downloadFile(file.path || file)"
>
{{ $t("rns_filesync.download") }}
</button>
</li>
</ul>
</div>
<div v-else-if="activeTab === 'acl'" class="space-y-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="aclEnforce" type="checkbox" class="rounded" @change="saveEnforce" />
{{ $t("rns_filesync.acl_enforce") }}
</label>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="aclHash"
type="text"
class="glass-input flex-1 font-mono text-sm"
:placeholder="$t('rns_filesync.peer_hash_placeholder')"
/>
<div class="flex items-center gap-3 text-sm">
<label class="flex items-center gap-1">
<input v-model="aclRead" type="checkbox" />
r
</label>
<label class="flex items-center gap-1">
<input v-model="aclWrite" type="checkbox" />
w
</label>
<label class="flex items-center gap-1">
<input v-model="aclDelete" type="checkbox" />
d
</label>
</div>
<button
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="busy"
@click="grantAcl"
>
{{ $t("rns_filesync.acl_grant") }}
</button>
</div>
<pre
class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto"
>{{ aclRulesText }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
export default {
name: "RnsFilesyncPage",
components: {
MaterialDesignIcon,
ToolsPageHeader,
},
data() {
return {
activeTab: "status",
tabs: [
{ id: "status", labelKey: "rns_filesync.tab_status" },
{ id: "peers", labelKey: "rns_filesync.tab_peers" },
{ id: "files", labelKey: "rns_filesync.tab_files" },
{ id: "browse", labelKey: "rns_filesync.tab_browse" },
{ id: "acl", labelKey: "rns_filesync.tab_acl" },
],
busy: false,
status: {
running: false,
peers: 0,
files: 0,
destination_hash: null,
sync_directory: "",
},
syncDirectory: "",
announceInterval: 300,
monitor: true,
connectHash: "",
peers: [],
files: [],
browsePeerId: "",
remoteFiles: [],
aclEnforce: false,
aclHash: "",
aclRead: true,
aclWrite: false,
aclDelete: false,
aclRules: {},
lastProgress: "",
_wsHandlers: [],
};
},
computed: {
aclRulesText() {
try {
return JSON.stringify(this.aclRules || {}, null, 2);
} catch {
return "{}";
}
},
},
async mounted() {
this.bindWs();
await this.refreshAll();
},
beforeUnmount() {
for (const [type, handler] of this._wsHandlers) {
offWsEvent(type, handler);
}
this._wsHandlers = [];
},
methods: {
bindWs() {
const bind = (type, handler) => {
onWsEvent(type, handler);
this._wsHandlers.push([type, handler]);
};
bind("filesync.sync.progress", (payload) => {
this.lastProgress = JSON.stringify(payload);
});
bind("filesync.peer.connected", async () => {
await this.refreshPeers();
await this.refreshStatus();
});
bind("filesync.peer.disconnected", async () => {
await this.refreshPeers();
await this.refreshStatus();
});
bind("filesync.file.updated", async () => {
await this.refreshFiles();
ToastUtils.info(this.$t("rns_filesync.file_updated"));
});
bind("filesync.file.deleted", async () => {
await this.refreshFiles();
ToastUtils.info(this.$t("rns_filesync.file_deleted"));
});
bind("filesync.error", (payload) => {
const detail = payload?.error || payload?.message || "";
ToastUtils.error(
`${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`,
);
});
},
async refreshAll() {
await Promise.all([
this.refreshStatus(),
this.refreshPeers(),
this.refreshFiles(),
this.refreshAcl(),
]);
},
async refreshStatus() {
try {
const response = await window.api.get("/api/v1/filesync/status");
const status = response?.data || {};
this.status = status;
if (status.sync_directory) {
this.syncDirectory = status.sync_directory;
}
if (typeof status.announce_interval === "number") {
this.announceInterval = status.announce_interval;
}
if (typeof status.monitor === "boolean") {
this.monitor = status.monitor;
}
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
}
},
async refreshPeers() {
try {
const response = await window.api.get("/api/v1/filesync/peers");
const data = response?.data || {};
this.peers = Array.isArray(data.peers) ? data.peers : [];
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
}
},
async refreshFiles() {
try {
const response = await window.api.get("/api/v1/filesync/files");
const data = response?.data || {};
this.files = Array.isArray(data.files) ? data.files : [];
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
}
},
async refreshAcl() {
try {
const response = await window.api.get("/api/v1/filesync/acl");
const data = response?.data || {};
this.aclEnforce = Boolean(data.enforce);
this.aclRules = data.rules || {};
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
}
},
async startService() {
this.busy = true;
try {
if (!this.status.running && this.syncDirectory) {
await window.api.patch("/api/v1/filesync/settings", {
sync_directory: this.syncDirectory,
monitor: this.monitor,
announce_interval: this.announceInterval,
});
}
await window.api.post("/api/v1/filesync/start", {
sync_directory: this.syncDirectory || undefined,
monitor: this.monitor,
announce_interval: this.announceInterval,
});
ToastUtils.success(this.$t("rns_filesync.started"));
await this.refreshAll();
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async stopService() {
this.busy = true;
try {
await window.api.post("/api/v1/filesync/stop", {});
ToastUtils.success(this.$t("rns_filesync.stopped"));
await this.refreshAll();
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async announceNow() {
this.busy = true;
try {
await window.api.post("/api/v1/filesync/announce", {});
ToastUtils.success(this.$t("rns_filesync.announced"));
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async connectPeer() {
this.busy = true;
try {
await window.api.post("/api/v1/filesync/connect", {
identity_hash: this.connectHash,
});
ToastUtils.success(this.$t("rns_filesync.connected"));
this.connectHash = "";
await this.refreshPeers();
await this.refreshStatus();
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async disconnectPeer(peerId) {
this.busy = true;
try {
await window.api.post("/api/v1/filesync/disconnect", {
peer_id: peerId,
});
ToastUtils.success(this.$t("rns_filesync.disconnected"));
await this.refreshPeers();
await this.refreshStatus();
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async browsePeer() {
this.busy = true;
try {
const response = await window.api.post("/api/v1/filesync/browse", {
peer_id: this.browsePeerId,
});
const data = response?.data || {};
this.remoteFiles = Array.isArray(data.files) ? data.files : [];
ToastUtils.success(this.$t("rns_filesync.browse_done"));
} catch (err) {
this.remoteFiles = [];
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async downloadFile(path) {
this.busy = true;
try {
await window.api.post("/api/v1/filesync/download", {
peer_id: this.browsePeerId,
path,
});
ToastUtils.info(this.$t("rns_filesync.download_started"));
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async grantAcl() {
const perms = [];
if (this.aclRead) perms.push("read");
if (this.aclWrite) perms.push("write");
if (this.aclDelete) perms.push("delete");
this.busy = true;
try {
await window.api.post("/api/v1/filesync/acl", {
identity_hash: this.aclHash,
perms,
enforce: this.aclEnforce,
});
ToastUtils.success(this.$t("rns_filesync.acl_updated"));
this.aclHash = "";
await this.refreshAcl();
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async saveEnforce() {
this.busy = true;
try {
await window.api.post("/api/v1/filesync/acl", {
enforce: this.aclEnforce,
});
ToastUtils.success(this.$t("rns_filesync.acl_updated"));
await this.refreshAcl();
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async copyHash(hash) {
try {
await navigator.clipboard.writeText(hash);
ToastUtils.success(this.$t("rns_filesync.copied"));
} catch {
ToastUtils.error(this.$t("rns_filesync.error"));
}
},
},
};
</script>

View file

@ -40,6 +40,7 @@
:online-interface-count="onlineInterfaces.length"
:offline-interface-count="offlineInterfaces.length"
:search-query="searchQuery"
:preferred-renderer="preferredRenderer"
:engine-mode="engineMode"
:fps="fps"
@update:is-showing-controls="isShowingControls = $event"
@ -47,6 +48,7 @@
@update:enable-physics="enablePhysics = $event"
@update:hop-max-filter="onUserHopMaxFilterChange"
@update:search-query="searchQuery = $event"
@update:preferred-renderer="onPreferredRendererChange"
@manual-update="manualUpdate"
/>
<NetworkVisualiserLegend
@ -93,6 +95,7 @@ import {
loadVisualiserDisplayPrefs,
persistVisualiserAutoReload,
persistVisualiserLiveLayout,
persistVisualiserRenderer,
VISUALISER_DISPLAY_PREFS_CHANGED,
} from "../../js/settings/settingsVisualiserPrefs.js";
import ToastUtils from "../../js/ToastUtils";
@ -821,6 +824,13 @@ export default {
this.autoReload = p.autoReload;
this.preferredRenderer = p.renderer || "auto";
},
async onPreferredRendererChange(next) {
const normalized = next === "webgl" || next === "vis" || next === "auto" ? next : "auto";
if (normalized === this.preferredRenderer) return;
this.preferredRenderer = normalized;
persistVisualiserRenderer(normalized, { emit: false });
await this.reinitRenderer();
},
destroyActiveRenderer() {
this.hoverTooltip = null;
if (this.webglEngine) {
@ -926,7 +936,7 @@ export default {
const layoutEdges = this.edges.get().map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2.5 ? 150 : 180,
length: e.width >= 2.5 ? 260 : 300,
}));
const settled = settleLayout({ nodes: layoutNodes, edges: layoutEdges, iterations: 0 });
const positions = settled?.positions || {};
@ -1642,7 +1652,7 @@ export default {
graph.layout_edges = graphEdges.map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2.5 ? 150 : 180,
length: e.width >= 2.5 ? 260 : 300,
}));
}

View file

@ -53,16 +53,26 @@
<div class="grid grid-cols-2 gap-2">
<div
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
:title="engineModeTitle"
:title="engineSelectTitle"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
<label
for="visualiser-engine-select"
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5 block"
>
{{ $t("visualiser.engine") }}
</div>
<div class="text-xs font-bold truncate" :class="engineModeClass">
{{ engineModeLabel }}
</div>
</label>
<select
id="visualiser-engine-select"
class="w-full bg-transparent text-xs font-bold truncate border-0 p-0 pr-5 focus:outline-hidden focus:ring-0 cursor-pointer"
:class="engineSelectClass"
:value="preferredRenderer"
:aria-label="$t('visualiser.engine')"
@change="onEngineSelectChange"
>
<option value="auto">{{ $t("visualiser.renderer_option_auto") }}</option>
<option value="webgl">{{ $t("visualiser.renderer_option_webgl") }}</option>
<option value="vis">{{ $t("visualiser.renderer_option_vis") }}</option>
</select>
</div>
<div
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
@ -239,11 +249,18 @@ export default {
onlineInterfaceCount: { type: Number, default: 0 },
offlineInterfaceCount: { type: Number, default: 0 },
searchQuery: { type: String, default: "" },
preferredRenderer: {
type: String,
default: "auto",
validator(v) {
return ["auto", "webgl", "vis"].includes(v);
},
},
engineMode: {
type: String,
default: "checking",
validator(v) {
return ["checking", "wasm", "fallback"].includes(v);
return ["checking", "wasm", "fallback", "webgl"].includes(v);
},
},
fps: { type: Number, default: 0 },
@ -254,6 +271,7 @@ export default {
"update:enablePhysics",
"update:hopMaxFilter",
"update:searchQuery",
"update:preferredRenderer",
"manual-update",
],
data() {
@ -277,23 +295,17 @@ export default {
if (this.hopMaxFilter === null) return this.$t("visualiser.all");
return String(this.hopMaxFilter);
},
engineModeLabel() {
if (this.engineMode === "webgl") return this.$t("visualiser.engine_webgl");
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm");
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback");
return this.$t("visualiser.engine_checking");
},
engineModeTitle() {
engineSelectTitle() {
if (this.engineMode === "webgl") return this.$t("visualiser.engine_webgl_hint");
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm_hint");
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback_hint");
return this.$t("visualiser.engine_checking_hint");
return this.$t("visualiser.renderer_desc");
},
engineModeClass() {
engineSelectClass() {
if (this.engineMode === "webgl") return "text-sky-600 dark:text-sky-400";
if (this.engineMode === "wasm") return "text-emerald-600 dark:text-emerald-400";
if (this.engineMode === "fallback") return "text-amber-600 dark:text-amber-400";
return "text-gray-500 dark:text-zinc-400";
return "text-gray-800 dark:text-zinc-100";
},
fpsDisplay() {
const n = Number(this.fps);
@ -302,6 +314,12 @@ export default {
},
},
methods: {
onEngineSelectChange(e) {
const next = e?.target?.value;
if (next === "auto" || next === "webgl" || next === "vis") {
this.$emit("update:preferredRenderer", next);
}
},
onHopSliderInput(e) {
const v = hopSliderPosToMaxHops(Number(e.target.value));
this.$emit("update:hopMaxFilter", v);

View file

@ -381,7 +381,7 @@ export function buildFullGraph(req) {
layout_edges: (path.edges || []).map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2 ? 150 : 180,
length: e.width >= 2 ? 260 : 300,
})),
};
}

View file

@ -86,6 +86,14 @@ export const CORE_COMMAND_ENTRIES = [
type: "navigation",
route: { name: "rncp" },
},
{
id: "nav-rns-filesync",
title: "nav_rns_filesync",
description: "nav_rns_filesync_desc",
icon: "folder-sync",
type: "navigation",
route: { name: "rns-filesync" },
},
{
id: "nav-rnsh",
title: "nav_rnsh",

View file

@ -212,7 +212,7 @@ export const CORE_TOOLS_ENTRIES = [
},
{
name: "rns-filesync",
comingSoon: true,
route: { name: "rns-filesync" },
icon: "folder-sync",
iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
titleKey: "tools.rns_filesync.title",

View file

@ -2601,8 +2601,8 @@
"description": "IP-Tunneling über Reticulum-Verbindungen (in Entwicklung)."
},
"rns_filesync": {
"title": "RNSFilesync",
"description": "Dateisync zwischen Mesh-Peers (in Entwicklung)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy-Bots",
@ -3249,7 +3249,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Remote-Shell über Reticulum",
"nav_rnx": "RNX",
"nav_rnx_desc": "Remote-Befehlsausführung über Reticulum"
"nav_rnx_desc": "Remote-Befehlsausführung über Reticulum",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"settings": {
"shortcut_saved": "Verknüpfung gespeichert",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX kann den Windows-Inhaltsschutz nutzen (dasselbe DRM-ähnliche Display-Flag wie Medien-Apps), damit Screenshots, Recorder und Windows Recall das App-Fenster nicht erfassen. Später änderbar unter Einstellungen → Datenschutz.",
"windows_screen_security_enable": "Bildschirmsicherheit aktivieren",
"windows_screen_security_later": "Nicht jetzt"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2798,8 +2798,8 @@
"description": "Carry IP traffic over Reticulum links (in development)."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Sync files between mesh peers (in development)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Bots",
@ -3025,6 +3025,7 @@
"step_2": "2. To **send** a file, get the **Destination Hash** of a listening node, enter the **Local File Path**, and click **Send File**.",
"step_3": "3. To **fetch** a file, get the **Destination Hash** of a node listening with 'Allow Fetch' enabled, enter the **Remote File Path**, and click **Fetch File**.",
"send_file": "Send File",
"fetch_file": "Fetch File",
"listen": "Listen",
"destination_hash": "Destination Hash",
@ -3067,6 +3068,58 @@
"web_path_hint": "Browsers cannot expose full file paths. Use the desktop app to browse, or paste the full path to the file on this machine.",
"web_save_path_prompt": "Enter the absolute folder path on the machine running MeshChat (server) where the fetched file should be saved:"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
},
"rnsh": {
"remote_shell": "Remote shell",
"usage_title": "Usage",
@ -3752,6 +3805,8 @@
"nav_rnprobe_desc": "Probe reachability and delay",
"nav_rncp": "RNCP",
"nav_rncp_desc": "File copy over Reticulum (RNCP)",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum",
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Remote shell over Reticulum",
"nav_rnx": "RNX",

View file

@ -2794,8 +2794,8 @@
"description": "Tunelización IP sobre los enlaces de Reticulum (en desarrollo)."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Sincronización de archivos entre pares de malla (en desarrollo)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "Bots LXMFy",
@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Shell remoto sobre Reticulum",
"nav_rnx": "RNX",
"nav_rnx_desc": "Ejecución remota de comandos sobre Reticulum"
"nav_rnx_desc": "Ejecución remota de comandos sobre Reticulum",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Chat de retransmision",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX puede usar la protección de contenido de Windows (la misma marca tipo DRM que usan las apps de medios) para que capturas, grabadoras y Windows Recall no capturen la ventana. Puede cambiarlo después en Ajustes → Privacidad.",
"windows_screen_security_enable": "Activar seguridad de pantalla",
"windows_screen_security_later": "Ahora no"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2794,8 +2794,8 @@
"description": "Kuljeta IP-liikennettä Reticulum-linkkien yli (kehitysvaiheessa)."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Synkronoi tiedostoja verkon vertaisten välillä (kehitysvaiheessa)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy-botit",
@ -3701,7 +3701,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Etäkuori Reticulumin yli",
"nav_rnx": "RNX",
"nav_rnx_desc": "Etäkomentojen suoritus Reticulumin yli"
"nav_rnx_desc": "Etäkomentojen suoritus Reticulumin yli",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"rnx": {
"title": "RNX - Reticulum Remote Execution",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX voi käyttää Windowsin sisällönsuojausta (samaa DRM-tyylistä näyttölippua kuin mediaohjelmat), jotta kuvakaappaukset, tallentimet ja Windows Recall eivät kaappaa sovellusikkunaa. Voit muuttaa tätä myöhemmin kohdassa Asetukset → Tietosuoja.",
"windows_screen_security_enable": "Ota näytön suojaus käyttöön",
"windows_screen_security_later": "Ei nyt"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2794,8 +2794,8 @@
"description": "Tunnel IP sur les liaisons Reticulum (en développement)."
},
"rns_filesync": {
"title": "Fichiers RNSync",
"description": "Synchronisation des fichiers entre les paires de mailles (en développement)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "Bots LXMFy",
@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Shell distant sur Reticulum",
"nav_rnx": "RNX",
"nav_rnx_desc": "Exécution distante de commandes sur Reticulum"
"nav_rnx_desc": "Exécution distante de commandes sur Reticulum",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Chat relais",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX peut utiliser la protection de contenu Windows (le même indicateur daffichage de type DRM que les apps multimédias) pour que les captures, enregistreurs et Windows Recall ne capturent pas la fenêtre. Modifiable plus tard dans Paramètres → Confidentialité.",
"windows_screen_security_enable": "Activer la sécurité décran",
"windows_screen_security_later": "Pas maintenant"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2846,8 +2846,8 @@
"description": "Tunnel IP su collegamenti Reticulum (in sviluppo)."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Sincronizzazione file tra peer mesh (in sviluppo)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Bot",
@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Shell remota su Reticulum",
"nav_rnx": "RNX",
"nav_rnx_desc": "Esecuzione remota di comandi su Reticulum"
"nav_rnx_desc": "Esecuzione remota di comandi su Reticulum",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Chat relay",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX può usare la protezione contenuti di Windows (lo stesso flag display in stile DRM delle app multimediali) così screenshot, registratori e Windows Recall non catturano la finestra. Modificabile in seguito in Impostazioni → Privacy.",
"windows_screen_security_enable": "Attiva sicurezza schermo",
"windows_screen_security_later": "Non ora"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2794,8 +2794,8 @@
"description": "IP tunneling over Reticulum links (in ontwikkeling)."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Bestand synchroniseren tussen mesh peers (in ontwikkeling)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Bots",
@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Remote shell over Reticulum",
"nav_rnx": "RNX",
"nav_rnx_desc": "Externe opdrachtuitvoering over Reticulum"
"nav_rnx_desc": "Externe opdrachtuitvoering over Reticulum",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Relaychat",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX kan Windows-inhoudsbescherming gebruiken (dezelfde DRM-achtige displayvlag als media-apps) zodat screenshots, recorders en Windows Recall het appvenster niet vastleggen. Later te wijzigen onder Instellingen → Privacy.",
"windows_screen_security_enable": "Schermbeveiliging inschakelen",
"windows_screen_security_later": "Niet nu"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2601,8 +2601,8 @@
"description": "IP-туннелирование по каналам Reticulum (в разработке)."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Синхронизация файлов между узлами mesh (в разработке)."
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Боты",
@ -3249,7 +3249,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Удалённая оболочка через Reticulum",
"nav_rnx": "RNX",
"nav_rnx_desc": "Удалённое выполнение команд через Reticulum"
"nav_rnx_desc": "Удалённое выполнение команд через Reticulum",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"settings": {
"shortcut_saved": "Ярлык сохранён",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX может использовать защиту содержимого Windows (тот же DRM-подобный флаг отображения, что у медиаприложений), чтобы снимки, запись и Windows Recall не захватывали окно. Позже можно изменить в Настройки → Конфиденциальность.",
"windows_screen_security_enable": "Включить защиту экрана",
"windows_screen_security_later": "Не сейчас"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -2794,8 +2794,8 @@
"description": "IP地道通向Reticulum链接(正在开发)."
},
"rns_filesync": {
"title": "RNS 文件同步",
"description": "网络端点之间的文件同步( 正在开发中) 。"
"title": "RNS FileSync",
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy 波兹",
@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "通过 Reticulum 的远程 Shell",
"nav_rnx": "RNX",
"nav_rnx_desc": "通过 Reticulum 远程执行命令"
"nav_rnx_desc": "通过 Reticulum 远程执行命令",
"nav_rns_filesync": "RNS FileSync",
"nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "中继聊天",
@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX 可使用 Windows 内容保护(与媒体应用相同的 DRM 风格显示标志),使截图、录屏和 Windows Recall 无法捕获应用窗口。之后可在 设置 → 隐私 中更改。",
"windows_screen_security_enable": "启用屏幕安全",
"windows_screen_security_later": "暂不"
},
"rns_filesync": {
"eyebrow": "Directory sync",
"title": "RNS FileSync",
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
"usage_steps": "How to use FileSync:",
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
"tab_status": "Status",
"tab_peers": "Peers",
"tab_files": "Files",
"tab_browse": "Browse",
"tab_acl": "ACL",
"sync_directory": "Sync directory",
"sync_directory_placeholder": "Path under this identity storage",
"announce_interval": "Announce interval (seconds)",
"monitor": "Watch directory for changes",
"start": "Start",
"stop": "Stop",
"announce": "Announce now",
"refresh": "Refresh",
"running": "Running",
"yes": "Yes",
"no": "No",
"peers_count": "Peers",
"files_count": "Files",
"destination_hash": "Destination hash",
"last_progress": "Last progress",
"peer_hash_placeholder": "Identity hash (hex)",
"connect": "Connect",
"disconnect": "Disconnect",
"no_peers": "No connected peers yet.",
"no_files": "No local files in the sync inventory yet.",
"select_peer": "Select a peer",
"browse": "Browse",
"no_remote_files": "No remote files listed. Connect and browse a peer.",
"download": "Download",
"acl_enforce": "Enforce ACL (deny by default)",
"acl_grant": "Grant",
"acl_updated": "ACL updated",
"started": "FileSync started",
"stopped": "FileSync stopped",
"announced": "Announce sent",
"connected": "Peer connect requested",
"disconnected": "Peer disconnected",
"browse_done": "Browse complete",
"download_started": "Download requested",
"file_updated": "File updated",
"file_deleted": "File deleted",
"copied": "Copied to clipboard",
"error": "FileSync error"
}
}

View file

@ -171,6 +171,11 @@ const router = createRouter({
path: "/rncp",
component: () => import("./components/rncp/RNCPPage.vue"),
},
{
name: "rns-filesync",
path: "/rns-filesync",
component: () => import("./components/filesync/RnsFilesyncPage.vue"),
},
{
name: "rnsh",
path: "/rnsh",

View file

@ -17,12 +17,15 @@ Use these when messages or pages fail despite interfaces showing as enabled.
## File transfer and shell
| Tool | Purpose |
| ---- | ------------------------------------------ |
| RNCP | Send or fetch files over Reticulum |
| RNSH | Remote shell sessions with streamed output |
| Tool | Purpose |
| ------------ | ---------------------------------------------------- |
| RNCP | Send or fetch files over Reticulum |
| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
| RNSH | Remote shell sessions with streamed output |
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
FileSync uses the bundled `rns_filesync` package and keeps sync state under the active identity storage directory.
## Messaging helpers
@ -69,7 +72,7 @@ Translator calls respect **privacy mode**. When privacy mode blocks outbound HTT
## Coming soon
The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
The registry marks **RNS Tunnel** as coming soon. It does not have a route in the current release.
## Relay chat server

View file

@ -1,6 +1,6 @@
{
"version": "1.2.0",
"wasm": "sha384-Z1L+iI6+ud0bH8JYg54AHdwlr+/43VFTU6s91BEQQpiTjEAfGCnH2CweclVMpX5x",
"wasm": "sha384-u89AYwtv9w30MHieh7YmTmorpfsR5A8bh+9wRJR1rgvqaRc2xjgxEiM5mvJAsA9V",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}

View file

@ -44,13 +44,14 @@ meshchatx = "meshchatx.meshchat:main"
meshchat = "meshchatx.meshchat:main"
meshchatx-repository-http = "meshchatx.repository_http_standalone:main"
lxmfy = "lxmfy.cli:main"
rns-filesync = "rns_filesync.cli:main"
[project.urls]
Homepage = "https://github.com/Quad4-Software/MeshChatX"
[tool.setuptools.packages.find]
where = [".", "vendor/lxmfy"]
include = ["meshchatx*", "lxmfy*"]
where = [".", "vendor/lxmfy", "vendor/rns_filesync"]
include = ["meshchatx*", "lxmfy*", "rns_filesync*"]
exclude = ["tests*"]
namespaces = false

View file

@ -1,7 +1,7 @@
{
"include": ["meshchatx/src/backend", "meshchatx/src/env_utils.py"],
"exclude": ["**/vendor/**", "**/build/**", "**/node_modules/**", "**/__pycache__/**"],
"extraPaths": [".", "vendor/lxmfy"],
"extraPaths": [".", "vendor/lxmfy", "vendor/rns_filesync"],
"stubPath": "typings",
"pythonVersion": "3.11",
"typeCheckingMode": "basic",

View file

@ -53,7 +53,7 @@ record_hit() {
# Do not use a bare /(^|/)android(/|$)/ pattern: LXST ships
# LXST/Platforms/android for mobile hosts. That path must remain allowed in
# APKs and must not false-fail Docker scans of site-packages.
COMMON_DENY_RE='(^|/)\.git(/|$)|(^|/)node_modules(/|$)|(^|/)\.pnpm-store(/|$)|(^|/)\.venv(/|$)|(^|/)vendor/offline(/|$)|(^|/)vendor/lxmfy/tests(/|$)|(^|/)vendor/lxmfy/docs(/|$)|(^|/)vendor/lxmfy/docker(/|$)|(^|/)\.github(/|$)|(^|/)docs/agents(/|$)|(^|/)screenshots(/|$)|(^|/)\.pytest_cache(/|$)|(^|/)mutants(/|$)|(^|/)coverage(/|$)'
COMMON_DENY_RE='(^|/)\.git(/|$)|(^|/)node_modules(/|$)|(^|/)\.pnpm-store(/|$)|(^|/)\.venv(/|$)|(^|/)vendor/offline(/|$)|(^|/)vendor/lxmfy/tests(/|$)|(^|/)vendor/lxmfy/docs(/|$)|(^|/)vendor/lxmfy/docker(/|$)|(^|/)vendor/rns_filesync/tests(/|$)|(^|/)vendor/rns_filesync/docs(/|$)|(^|/)vendor/rns_filesync/docker(/|$)|(^|/)vendor/rns_filesync/packaging(/|$)|(^|/)vendor/rns_filesync/sideband(/|$)|(^|/)vendor/rns_filesync/man(/|$)|(^|/)vendor/rns_filesync/scripts(/|$)|(^|/)\.github(/|$)|(^|/)docs/agents(/|$)|(^|/)screenshots(/|$)|(^|/)\.pytest_cache(/|$)|(^|/)mutants(/|$)|(^|/)coverage(/|$)'
BYTECODE_DENY_RE='(^|/)__pycache__(/|$)'

View file

@ -183,6 +183,9 @@ def mock_app(db, tmp_path, temp_db):
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)

View file

@ -24,6 +24,15 @@ _MUTATING_SAMPLES = (
("PATCH", "/api/v1/server/security", {"web_ui_ip_allowlist": ""}),
("POST", "/api/v1/app/tutorial/seen", {}),
("POST", "/api/v1/app/changelog/seen", {"version": "999.999.999"}),
("POST", "/api/v1/filesync/start", {}),
("POST", "/api/v1/filesync/stop", {}),
("POST", "/api/v1/filesync/announce", {}),
("POST", "/api/v1/filesync/connect", {"identity_hash": "aa" * 16}),
("POST", "/api/v1/filesync/disconnect", {"peer_id": "bb" * 16}),
("POST", "/api/v1/filesync/browse", {"peer_id": "bb" * 16}),
("POST", "/api/v1/filesync/download", {"peer_id": "bb" * 16, "path": "a.txt"}),
("POST", "/api/v1/filesync/acl", {"enforce": False}),
("PATCH", "/api/v1/filesync/settings", {"monitor": True}),
)
@ -42,6 +51,28 @@ def _make_aio_app(mock_app, use_https: bool = False):
return aio_app
def _stub_filesync_handler(mock_app):
handler = mock_app.rns_filesync_handler
if handler is None:
return
handler.reticulum = object()
handler.start.return_value = {"ok": True, "running": True}
handler.stop.return_value = {"ok": True, "running": False}
handler.announce_now.return_value = {"ok": True}
handler.connect_peer.return_value = {"ok": True, "peer_id": "aa" * 16}
handler.disconnect_peer.return_value = {"ok": True, "peer_id": "bb" * 16}
handler.browse_peer.return_value = {"ok": True, "files": []}
handler.download_file.return_value = {"ok": True, "path": "a.txt"}
handler.update_acl.return_value = {"ok": True, "enforce": False, "rules": {}}
handler.update_settings.return_value = {
"ok": True,
"sync_directory": "/tmp",
"monitor": True,
"announce_interval": 300,
"running": False,
}
@pytest.mark.asyncio
async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
with eect_scenario("auth.csrf.mutating_without_token") as (_s, _seed, rng):
@ -65,6 +96,7 @@ async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
async def test_eect_mutating_with_csrf_accepted(mock_app, monkeypatch):
with eect_scenario("auth.csrf.mutating_with_token") as (_s, _seed, rng):
monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
_stub_filesync_handler(mock_app)
aio_app = _make_aio_app(mock_app)
samples = list(_MUTATING_SAMPLES)
rng.shuffle(samples)

View file

@ -45,6 +45,7 @@ def mock_rns(tmp_path):
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),

View file

@ -84,6 +84,10 @@ from tests.backend.http_api_response_schemas import (
RETICULUM_INSTANCE_SCHEMA,
RNCP_STATUS_SCHEMA,
RNCP_TRANSFER_SCHEMA,
FILESYNC_ACL_SCHEMA,
FILESYNC_FILES_SCHEMA,
FILESYNC_PEERS_SCHEMA,
FILESYNC_STATUS_SCHEMA,
RNPATH_RATES_SCHEMA,
RNPATH_TABLE_SCHEMA,
RNPATH_TRACE_SCHEMA,
@ -449,6 +453,10 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
allow_statuses=(200, 404),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/filesync/status", FILESYNC_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/filesync/peers", FILESYNC_PEERS_SCHEMA),
HttpJsonContract("GET", "/api/v1/filesync/files", FILESYNC_FILES_SCHEMA),
HttpJsonContract("GET", "/api/v1/filesync/acl", FILESYNC_ACL_SCHEMA),
HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
HttpJsonContract(

View file

@ -528,6 +528,48 @@ RNCP_STATUS_SCHEMA: dict = {
"additionalProperties": True,
}
FILESYNC_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["running", "sync_directory"],
"properties": {
"running": _BOOLEAN,
"sync_directory": _STRING,
"identity_hash": {"type": ["string", "null"]},
"destination_hash": {"type": ["string", "null"]},
"peers": _INTEGER,
"files": _INTEGER,
"whitelist": _BOOLEAN,
"monitor": _BOOLEAN,
"announce_interval": _INTEGER,
"config_directory": _STRING,
},
"additionalProperties": True,
}
FILESYNC_PEERS_SCHEMA: dict = {
"type": "object",
"required": ["peers"],
"properties": {"peers": _ARRAY},
"additionalProperties": True,
}
FILESYNC_FILES_SCHEMA: dict = {
"type": "object",
"required": ["files"],
"properties": {"files": _ARRAY},
"additionalProperties": True,
}
FILESYNC_ACL_SCHEMA: dict = {
"type": "object",
"required": ["enforce", "rules"],
"properties": {
"enforce": _BOOLEAN,
"rules": _OBJECT,
},
"additionalProperties": True,
}
RNCP_TRANSFER_SCHEMA: dict = {
"type": "object",
"required": ["transfer"],

View file

@ -65,6 +65,28 @@ def test_get_package_version_resolves_lxmfy_when_metadata_missing():
assert v[0].isdigit()
def test_get_package_version_resolves_rns_filesync_when_metadata_missing():
def _missing(*_a, **_k):
raise importlib.metadata.PackageNotFoundError
real_import = __import__
def _import_module(name, package=None):
if name == "rns_filesync":
return real_import(name)
return real_import(name, package=package)
with (
patch("importlib.metadata.version", side_effect=_missing),
patch("importlib.metadata.distribution", side_effect=_missing),
patch("importlib.metadata.packages_distributions", return_value={}),
patch("importlib.import_module", side_effect=_import_module),
):
v = ReticulumMeshChat.get_package_version("rns-filesync")
assert v != "unknown"
assert v[0].isdigit()
@pytest.mark.parametrize(
"package",
(
@ -76,6 +98,8 @@ def test_get_package_version_resolves_lxmfy_when_metadata_missing():
"ply",
"bcrypt",
"lxmfy",
"rns-filesync",
"rns_filesync",
),
)
def test_app_info_dependency_keys_resolve_in_dev_env(package: str):

View file

@ -0,0 +1,132 @@
# SPDX-License-Identifier: 0BSD
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
@pytest.fixture
def mock_identity():
return SimpleNamespace(hash=b"\xaa" * 16)
@pytest.fixture
def handler(mock_identity, tmp_path):
storage = tmp_path / "identity"
storage.mkdir()
return RnsFilesyncHandler(
reticulum_instance=MagicMock(name="reticulum"),
identity=mock_identity,
storage_dir=str(storage),
)
def test_default_status_not_running(handler, mock_identity):
status = handler.get_status()
assert status["running"] is False
assert status["destination_hash"] is None
assert status["identity_hash"] == mock_identity.hash.hex()
assert status["sync_directory"].endswith("filesync/sync")
assert "filesync" in status["config_directory"]
def test_update_settings_persists_sync_directory(handler):
result = handler.update_settings(sync_directory="/tmp/filesync-alt", monitor=False)
assert result["ok"] is True
assert result["monitor"] is False
status = handler.get_status()
assert status["sync_directory"] == "/tmp/filesync-alt"
assert status["monitor"] is False
def test_acl_grant_and_persist(handler, tmp_path):
peer = "bb" * 16
result = handler.update_acl(
identity_hash=peer,
perms=["read", "write"],
enforce=True,
)
assert result["ok"] is True
assert result["enforce"] is True
assert peer in result["rules"]["read"]
assert peer in result["rules"]["write"]
acl_path = tmp_path / "identity" / "filesync" / "acl.txt"
assert acl_path.is_file()
text = acl_path.read_text(encoding="utf-8")
assert f"r:{peer}" in text
assert f"w:{peer}" in text
again = handler.get_acl()
assert again["enforce"] is True
assert peer in again["rules"]["read"]
def test_connect_requires_running(handler):
result = handler.connect_peer("cc" * 16)
assert result["ok"] is False
assert "not running" in result["error"]
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
def test_start_stop_and_teardown(mock_service_cls, handler):
service = MagicMock()
service.start.return_value = "dd" * 16
service.get_status.return_value = {
"running": True,
"sync_directory": handler._sync_directory,
"identity_hash": "aa" * 16,
"destination_hash": "dd" * 16,
"peers": 0,
"files": 0,
"whitelist": False,
"monitor": True,
}
service.list_peers.return_value = [{"peer_id": "ee" * 16, "status": 1}]
service.list_files.return_value = [{"path": "a.txt", "size": 1}]
service.connect_peer.return_value = {"ok": True, "peer_id": "ee" * 16}
service.browse_peer.return_value = [{"path": "remote.txt", "size": 2}]
service.download_file.return_value = {"ok": True, "path": "remote.txt"}
mock_service_cls.return_value = service
started = handler.start(monitor=True, announce_interval=120)
assert started["ok"] is True
assert started["destination_hash"] == "dd" * 16
mock_service_cls.assert_called_once()
kwargs = mock_service_cls.call_args.kwargs
assert kwargs["own_reticulum"] is False
assert kwargs["reticulum"] is handler.reticulum
service.start.assert_called_once()
assert handler.list_peers()[0]["peer_id"] == "ee" * 16
assert handler.list_files()[0]["path"] == "a.txt"
assert handler.connect_peer("ee" * 16)["ok"] is True
assert handler.browse_peer("ee" * 16)["ok"] is True
assert handler.download_file("ee" * 16, "remote.txt")["ok"] is True
assert handler.announce_now()["ok"] is True
assert handler.disconnect_peer("ee" * 16)["ok"] is True
stopped = handler.stop()
assert stopped["ok"] is True
assert stopped["running"] is False
service.stop.assert_called()
assert handler.service is None
service.stop.reset_mock()
handler.service = service
handler.teardown()
service.stop.assert_called()
assert handler.service is None
def test_settings_reject_sync_dir_change_while_running(handler):
handler.service = MagicMock()
handler.service.get_status.return_value = {"running": True}
result = handler.update_settings(sync_directory="/tmp/other")
assert result["ok"] is False
assert "stop filesync" in result["error"]

View file

@ -0,0 +1,336 @@
# SPDX-License-Identifier: 0BSD
"""Adversarial and Hypothesis fuzz coverage for RNS FileSync handler."""
from __future__ import annotations
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
from rns_filesync.paths import PathJailError, normalize_relpath
from rns_filesync.permissions import PermissionStore
_TRAVERSAL_PAYLOADS = (
"../etc/passwd",
"..\\windows\\system32",
"a/../../b",
"/etc/passwd",
"C:\\Windows\\System32",
"a\x00b",
"",
".",
"..",
"//evil",
"\\\\evil",
"foo/../../../etc/shadow",
".rns-filesync.db",
"dir/.rns-xfer-temp",
)
_BAD_HASHES = (
"",
" ",
"zz",
"not-hex",
"../aabb",
"a" * 15,
"g" * 32,
"\x00" * 16,
"' OR '1'='1",
"../../identity",
"а" * 32,
)
@pytest.fixture
def handler(tmp_path):
storage = tmp_path / "identity_a"
storage.mkdir()
identity = SimpleNamespace(hash=b"\xaa" * 16)
return RnsFilesyncHandler(
reticulum_instance=MagicMock(name="reticulum"),
identity=identity,
storage_dir=str(storage),
)
def test_path_traversal_payloads_rejected_by_normalize():
for payload in _TRAVERSAL_PAYLOADS:
with pytest.raises(PathJailError):
normalize_relpath(payload)
def test_download_rejects_path_traversal(handler):
handler.service = MagicMock()
handler.service.download_file.side_effect = lambda peer, path: {
"ok": False,
"error": f"path jail: {path}",
}
for payload in _TRAVERSAL_PAYLOADS:
result = handler.download_file("bb" * 16, payload)
assert result["ok"] is False
assert "error" in result
def test_download_and_browse_require_running(handler):
assert handler.download_file("bb" * 16, "a.txt")["ok"] is False
assert handler.browse_peer("bb" * 16)["ok"] is False
assert handler.connect_peer("bb" * 16)["ok"] is False
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
def test_connect_rejects_or_handles_bad_hashes(handler, bad_hash):
handler.service = MagicMock()
handler.service.connect_peer.return_value = {"ok": False, "error": "bad peer"}
result = handler.connect_peer(bad_hash)
assert isinstance(result, dict)
assert "ok" in result
if not str(bad_hash).strip():
assert result["ok"] is False
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
def test_acl_grant_handles_bad_hashes(handler, bad_hash):
result = handler.update_acl(
identity_hash=bad_hash,
perms=["read"],
enforce=True,
)
assert isinstance(result, dict)
assert "ok" in result
def test_acl_enforce_denies_stranger_by_default_rules(handler):
peer = "cc" * 16
stranger = "dd" * 16
handler.update_acl(identity_hash=peer, perms=["read"], enforce=True)
acl = handler.get_acl()
assert acl["enforce"] is True
perms = PermissionStore()
for rule_perm, targets in acl["rules"].items():
for target in targets:
short = {"read": "r", "write": "w", "delete": "d"}[rule_perm]
perms.add_rule(f"{short}:{target}")
assert perms.check(peer, "read") is True
assert perms.check(stranger, "read") is False
assert perms.check(stranger, "write") is False
def test_acl_rules_text_replace_does_not_leak_old_rules(handler):
peer_a = "11" * 16
peer_b = "22" * 16
handler.update_acl(identity_hash=peer_a, perms=["read", "write"], enforce=True)
handler.update_acl(
rules_text=f"r:{peer_b}",
replace=True,
enforce=True,
)
acl = handler.get_acl()
assert peer_a not in acl["rules"].get("read", [])
assert peer_b in acl["rules"].get("read", [])
assert peer_a not in acl["rules"].get("write", [])
def test_oversized_rules_text_does_not_raise(handler):
huge = ("r:" + ("ab" * 16) + "\n") * 5000
result = handler.update_acl(rules_text=huge, replace=True, enforce=True)
assert result["ok"] is True
assert isinstance(result["rules"], dict)
def test_teardown_clears_service_and_isolates_storage(tmp_path):
storage_a = tmp_path / "a"
storage_b = tmp_path / "b"
storage_a.mkdir()
storage_b.mkdir()
identity_a = SimpleNamespace(hash=b"\xaa" * 16)
identity_b = SimpleNamespace(hash=b"\xbb" * 16)
ha = RnsFilesyncHandler(MagicMock(), identity_a, str(storage_a))
hb = RnsFilesyncHandler(MagicMock(), identity_b, str(storage_b))
peer = "ee" * 16
ha.update_acl(identity_hash=peer, perms=["read"], enforce=True)
hb.update_acl(identity_hash=peer, perms=["write"], enforce=True)
svc = MagicMock()
svc.get_status.return_value = {"running": True}
ha.service = svc
ha.teardown()
assert ha.service is None
svc.stop.assert_called()
assert "read" in ha.get_acl()["rules"]
assert peer in ha.get_acl()["rules"]["read"]
assert "write" in hb.get_acl()["rules"]
assert peer in hb.get_acl()["rules"]["write"]
assert peer not in ha.get_acl()["rules"].get("write", [])
assert (storage_a / "filesync" / "acl.txt").is_file()
assert (storage_b / "filesync" / "acl.txt").is_file()
assert storage_a.resolve() != storage_b.resolve()
def test_concurrent_acl_mutations_stable(handler):
peer = "ff" * 16
errors: list[BaseException] = []
def worker(idx: int):
try:
for i in range(40):
handler.update_acl(
identity_hash=peer,
perms=["read"] if (idx + i) % 2 == 0 else ["write"],
enforce=True,
)
acl = handler.get_acl()
assert isinstance(acl["rules"], dict)
assert acl["enforce"] is True
except BaseException as exc:
errors.append(exc)
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors
final = handler.get_acl()
assert final["enforce"] is True
@settings(
max_examples=40,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
path=st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "S", "Z", "Cc"),
),
min_size=0,
max_size=400,
),
)
def test_download_path_fuzz_never_raises(handler, path):
handler.service = MagicMock()
handler.service.download_file.side_effect = lambda peer, p: {
"ok": False,
"error": "denied",
"path": p,
}
result = handler.download_file("aa" * 16, path)
assert isinstance(result, dict)
assert "ok" in result
@settings(
max_examples=40,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
identity_hash=st.one_of(
st.text(min_size=0, max_size=80),
st.binary(min_size=0, max_size=40).map(lambda b: b.hex()),
st.sampled_from(list(_BAD_HASHES)),
),
perms=st.lists(
st.sampled_from(["read", "write", "delete", "admin", "nope", ""]),
max_size=6,
),
enforce=st.booleans(),
rules_text=st.one_of(st.none(), st.text(max_size=500)),
replace=st.booleans(),
)
def test_acl_update_fuzz_never_raises(
handler,
identity_hash,
perms,
enforce,
rules_text,
replace,
):
result = handler.update_acl(
identity_hash=identity_hash,
perms=perms,
enforce=enforce,
rules_text=rules_text,
replace=replace,
)
assert isinstance(result, dict)
assert "ok" in result
@settings(
max_examples=30,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
sync_directory=st.one_of(
st.none(),
st.text(min_size=0, max_size=200),
st.sampled_from(["", " ", "../escape", "/tmp/x", "~/.ssh"]),
),
monitor=st.one_of(st.none(), st.booleans()),
announce_interval=st.one_of(
st.none(),
st.integers(min_value=-10, max_value=10_000),
st.text(max_size=20),
),
)
def test_settings_fuzz_never_raises(handler, sync_directory, monitor, announce_interval):
result = handler.update_settings(
sync_directory=sync_directory,
monitor=monitor,
announce_interval=announce_interval,
)
assert isinstance(result, dict)
assert "ok" in result
@settings(
max_examples=25,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(peer_id=st.text(min_size=0, max_size=100))
def test_disconnect_fuzz_never_raises(handler, peer_id):
handler.service = MagicMock()
result = handler.disconnect_peer(peer_id)
assert isinstance(result, dict)
assert "ok" in result
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
def test_start_with_malicious_interval_rejected(mock_service_cls, handler):
result = handler.start(announce_interval=1)
assert result["ok"] is False
mock_service_cls.assert_not_called()
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
def test_start_wires_callbacks_and_reuses_host_reticulum(mock_service_cls, handler):
service = MagicMock()
service.start.return_value = "ab" * 16
service.get_status.return_value = {
"running": True,
"sync_directory": handler._sync_directory,
"identity_hash": "aa" * 16,
"destination_hash": "ab" * 16,
"peers": 0,
"files": 0,
"whitelist": False,
"monitor": True,
}
mock_service_cls.return_value = service
result = handler.start()
assert result["ok"] is True
assert mock_service_cls.call_args.kwargs["own_reticulum"] is False
assert service.on_error is not None
assert service.on_sync_progress is not None

View file

@ -11,6 +11,7 @@ describe("NetworkVisualiserToolbar", () => {
edgeCount: 8,
onlineInterfaceCount: 2,
offlineInterfaceCount: 1,
preferredRenderer: "auto",
engineMode: "wasm",
fps: 58,
...props,
@ -27,23 +28,33 @@ describe("NetworkVisualiserToolbar", () => {
},
});
it("shows WASM engine label and FPS", () => {
it("shows engine select and FPS", () => {
const wrapper = mountToolbar();
expect(wrapper.text()).toContain("visualiser.engine_wasm");
const select = wrapper.find("#visualiser-engine-select");
expect(select.exists()).toBe(true);
expect(select.element.value).toBe("auto");
expect(wrapper.text()).toContain("58");
expect(wrapper.text()).toContain("visualiser.fps");
expect(wrapper.text()).toContain("visualiser.renderer_option_webgl");
expect(wrapper.text()).toContain("visualiser.renderer_option_vis");
});
it("shows JS fallback engine label", () => {
const wrapper = mountToolbar({ engineMode: "fallback", fps: 0 });
expect(wrapper.text()).toContain("visualiser.engine_fallback");
expect(wrapper.text()).toContain("--");
it("emits preferred renderer when engine select changes", async () => {
const wrapper = mountToolbar({ preferredRenderer: "auto", engineMode: "webgl" });
const select = wrapper.find("#visualiser-engine-select");
await select.setValue("webgl");
expect(wrapper.emitted("update:preferredRenderer")?.[0]).toEqual(["webgl"]);
await select.setValue("vis");
expect(wrapper.emitted("update:preferredRenderer")?.[1]).toEqual(["vis"]);
});
it("shows WebGL engine label", () => {
const wrapper = mountToolbar({ engineMode: "webgl", fps: 60 });
expect(wrapper.text()).toContain("visualiser.engine_webgl");
expect(wrapper.text()).toContain("60");
it("styles select from active engine mode", () => {
const webgl = mountToolbar({ engineMode: "webgl", preferredRenderer: "webgl", fps: 60 });
expect(webgl.find("#visualiser-engine-select").classes().join(" ")).toContain("text-sky-600");
const fallback = mountToolbar({ engineMode: "fallback", preferredRenderer: "vis", fps: 0 });
expect(fallback.find("#visualiser-engine-select").classes().join(" ")).toContain("text-amber-600");
expect(fallback.text()).toContain("--");
});
it("uses MDI magnify for search and refresh for update button", () => {

View file

@ -0,0 +1,136 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import RnsFilesyncPage from "@/components/filesync/RnsFilesyncPage.vue";
import ToastUtils from "@/js/ToastUtils";
vi.mock("@/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@/js/registries/wsEventRegistry.js", () => ({
onWsEvent: vi.fn(),
offWsEvent: vi.fn(),
}));
describe("RnsFilesyncPage.vue", () => {
let apiMock;
beforeEach(() => {
apiMock = {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
};
window.api = apiMock;
apiMock.get.mockImplementation((url) => {
if (url === "/api/v1/filesync/status") {
return Promise.resolve({
data: {
running: false,
sync_directory: "/tmp/sync",
peers: 0,
files: 0,
monitor: true,
announce_interval: 300,
},
});
}
if (url === "/api/v1/filesync/peers") {
return Promise.resolve({ data: { peers: [] } });
}
if (url === "/api/v1/filesync/files") {
return Promise.resolve({ data: { files: [] } });
}
if (url === "/api/v1/filesync/acl") {
return Promise.resolve({ data: { enforce: false, rules: {} } });
}
return Promise.resolve({ data: {} });
});
apiMock.post.mockResolvedValue({ data: { ok: true } });
apiMock.patch.mockResolvedValue({ data: { ok: true } });
});
afterEach(() => {
delete window.api;
vi.clearAllMocks();
});
const mountPage = () =>
mount(RnsFilesyncPage, {
global: {
mocks: {
$t: (key) => key,
},
stubs: {
MaterialDesignIcon: {
template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
props: ["iconName"],
},
ToolsPageHeader: {
template: "<div class='header-stub'>{{ title }}</div>",
props: ["title", "description", "eyebrow", "icon", "accent"],
},
},
},
});
it("renders and loads status", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(apiMock.get).toHaveBeenCalledWith("/api/v1/filesync/status"));
expect(wrapper.text()).toContain("rns_filesync.title");
expect(wrapper.vm.syncDirectory).toBe("/tmp/sync");
});
it("starts filesync and toasts success", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
await wrapper.vm.startService();
expect(apiMock.post).toHaveBeenCalledWith(
"/api/v1/filesync/start",
expect.objectContaining({
sync_directory: "/tmp/sync",
monitor: true,
}),
);
expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.started");
});
it("shows error toast when connect fails", async () => {
apiMock.post.mockRejectedValueOnce(new Error("path timeout"));
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
wrapper.vm.status.running = true;
wrapper.vm.connectHash = "ab".repeat(16);
await wrapper.vm.connectPeer();
expect(ToastUtils.error).toHaveBeenCalled();
});
it("browses and downloads a remote file", async () => {
apiMock.post.mockImplementation((url) => {
if (url === "/api/v1/filesync/browse") {
return Promise.resolve({
data: { ok: true, files: [{ path: "notes.txt", size: 12 }] },
});
}
return Promise.resolve({ data: { ok: true } });
});
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
wrapper.vm.status.running = true;
wrapper.vm.browsePeerId = "cd".repeat(16);
await wrapper.vm.browsePeer();
expect(wrapper.vm.remoteFiles).toEqual([{ path: "notes.txt", size: 12 }]);
await wrapper.vm.downloadFile("notes.txt");
expect(apiMock.post).toHaveBeenCalledWith("/api/v1/filesync/download", {
peer_id: "cd".repeat(16),
path: "notes.txt",
});
expect(ToastUtils.info).toHaveBeenCalledWith("rns_filesync.download_started");
});
});

View file

@ -12,6 +12,7 @@ describe("ToolsPage.vue", () => {
{ path: "/ping", name: "ping", component: { template: "div" } },
{ path: "/rnprobe", name: "rnprobe", component: { template: "div" } },
{ path: "/rncp", name: "rncp", component: { template: "div" } },
{ path: "/rns-filesync", name: "rns-filesync", component: { template: "div" } },
{ path: "/rnsh", name: "rnsh", component: { template: "div" } },
{ path: "/rnstatus", name: "rnstatus", component: { template: "div" } },
{ path: "/rnpath", name: "rnpath", component: { template: "div" } },

View file

@ -174,11 +174,16 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
expect(gradle).toContain("vendor/lxmfy/lxmfy");
expect(gradle).toContain("syncLxmfyPython");
expect(gradle).toContain("src/main/python/lxmfy");
expect(gradle).toMatch(
/dependsOn\(tasks\.named\("syncMeshchatPython"\),\s*tasks\.named\("syncLxmfyPython"\)\)/
);
const initPy = readSource("vendor/lxmfy/lxmfy/__init__.py");
expect(initPy.length).toBeGreaterThan(0);
expect(gradle).toContain("syncRnsFilesyncPython");
expect(gradle).toContain("vendor/rns_filesync/rns_filesync");
expect(gradle).toContain("src/main/python/rns_filesync");
expect(gradle).toMatch(/syncMeshchatPython/);
expect(gradle).toMatch(/syncLxmfyPython/);
expect(gradle).toMatch(/syncRnsFilesyncPython/);
const lxmfyInit = readSource("vendor/lxmfy/lxmfy/__init__.py");
expect(lxmfyInit.length).toBeGreaterThan(0);
const filesyncInit = readSource("vendor/rns_filesync/rns_filesync/__init__.py");
expect(filesyncInit.length).toBeGreaterThan(0);
});
it("Android wrapper clears stale storage lock before main()", () => {

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT = resolve(__dirname, "../..");
function readSource(relativePath) {
return readFileSync(resolve(ROOT, relativePath), "utf8");
}
describe("filesync packaging contracts", () => {
it("docker ignore excludes rns_filesync non-runtime trees", () => {
const dockerignore = readSource(".dockerignore");
expect(dockerignore).toContain("vendor/rns_filesync/tests/");
expect(dockerignore).toContain("vendor/rns_filesync/docker/");
expect(dockerignore).toContain("vendor/rns_filesync/packaging/");
expect(dockerignore).toContain("vendor/rns_filesync/sideband/");
});
it("package verify denylist covers rns_filesync bloat paths", () => {
const script = readSource("scripts/ci/verify-package-contents.sh");
expect(script).toContain("vendor/rns_filesync/tests");
expect(script).toContain("vendor/rns_filesync/docker");
expect(script).toContain("vendor/rns_filesync/packaging");
});
it("tools registry exposes filesync without comingSoon", () => {
const tools = readSource("meshchatx/src/frontend/js/registries/coreToolsEntries.js");
expect(tools).toContain('name: "rns-filesync"');
expect(tools).toContain('route: { name: "rns-filesync" }');
const block = tools.slice(tools.indexOf('name: "rns-filesync"'), tools.indexOf('name: "debug-logs"'));
expect(block).not.toContain("comingSoon");
});
});

7
vendor/README.txt vendored
View file

@ -6,3 +6,10 @@ lxmfy/
Declared version (pyproject): see vendor/lxmfy/pyproject.toml
Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README
commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed.
rns_filesync/
Upstream: https://github.com/Quad4-Software/RNS-Filesync
Bundled revision: 12161f3f47d4c7421990e5790aa3d41e08fd623a
Declared version (pyproject): see vendor/rns_filesync/pyproject.toml
Update: clone default branch, replace vendor/rns_filesync (omit .git), align vendor/README
commit above, regenerate THIRD_PARTY_NOTICES if needed.

13
vendor/rns_filesync/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
dist/
build/
.pytest_cache/
.coverage
htmlcov/
.ruff_cache/
.hypothesis/
*.tmp

9
vendor/rns_filesync/LICENSE vendored Normal file
View file

@ -0,0 +1,9 @@
Copyright 2025-2026 Sudo-Ivan / Quad4.io
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

102
vendor/rns_filesync/Makefile vendored Normal file
View file

@ -0,0 +1,102 @@
EDITOR ?= nano
PYTHON ?= python3
PIP ?= pip
PREFIX ?= /usr/local
DESTDIR ?=
RNID_ID ?= e46112d44649266d71fe2193e00a4710
RNID_KEY ?= $(HOME)/.rngit/client_identity
RNS_REMOTE ?= rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync
TAG ?= v1.0.0
VERSION := $(shell $(PYTHON) -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])" 2>/dev/null || $(PYTHON) -c "from rns_filesync._meta import __version__; print(__version__)")
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_DIRTY := $(shell git diff --quiet 2>/dev/null && echo 0 || echo 1)
.PHONY: all clean meta build wheel sdist sign upload release release-rns tag retag \
test test-live test-services test-bwrap install install-user man uninstall version help
all: build
help:
@echo "Targets: meta build wheel sdist test test-services install install-user man clean release"
@echo "Version: $(VERSION) commit: $(GIT_COMMIT) built: $(BUILD_DATE)"
version: meta
@$(PYTHON) -c "from rns_filesync._meta import version_string; print(version_string())"
meta:
VERSION="$(VERSION)" BUILD_DATE="$(BUILD_DATE)" GIT_COMMIT="$(GIT_COMMIT)" GIT_DIRTY="$(GIT_DIRTY)" \
$(PYTHON) scripts/bake_meta.py
clean:
rm -rf dist/ build/ *.egg-info .pytest_cache .coverage htmlcov
find . -type d -name __pycache__ -prune -exec rm -rf {} +
build: meta wheel sdist
wheel: meta
$(PYTHON) -m build --wheel
sdist: meta
$(PYTHON) -m build --sdist
sign: build
for f in dist/*.tar.gz dist/*.whl; do \
rnid -f -i $(RNID_KEY) -s "$$f" -w "$$f.rsg"; \
done
upload: sign
twine upload dist/*.whl dist/*.tar.gz
release: tag release-rns
release-rns: sign
mkdir -p dist
RELEASE_TAG=$(TAG) EDITOR="$(EDITOR)" \
rngit release -i $(RNID_KEY) $(RNS_REMOTE) create $(TAG):./dist
tag:
@if git rev-parse -q --verify "refs/tags/$(TAG)" >/dev/null 2>&1; then \
echo "Tag $(TAG) already exists. Use: make retag TAG=$(TAG)"; \
echo "Or republish rngit only: make release-rns TAG=$(TAG)"; \
exit 1; \
fi
git tag -s $(TAG) -m "$(TAG)"
git push origin $(TAG)
retag:
git tag -d $(TAG) 2>/dev/null || true
-git push origin :refs/tags/$(TAG) 2>/dev/null || true
git tag -s $(TAG) -m "$(TAG)"
git push origin $(TAG)
test:
$(PYTHON) -m pytest -m "not exploratory and not live"
test-live:
$(PYTHON) -m pytest -m live
test-services:
./packaging/docker-test/run-all.sh
test-bwrap:
./packaging/docker-test/test-bwrap.sh
install: meta
$(PIP) install --break-system-packages .
$(MAKE) man
install-user: meta
$(PIP) install --user .
mkdir -p $(HOME)/.local/share/man/man1
cp man/man1/rns-filesync.1 $(HOME)/.local/share/man/man1/
man:
mkdir -p $(DESTDIR)$(PREFIX)/share/man/man1
cp man/man1/rns-filesync.1 $(DESTDIR)$(PREFIX)/share/man/man1/
uninstall:
$(PIP) uninstall -y rns-filesync || true
rm -f $(DESTDIR)$(PREFIX)/share/man/man1/rns-filesync.1
rm -f $(HOME)/.local/share/man/man1/rns-filesync.1

273
vendor/rns_filesync/README.md vendored Normal file
View file

@ -0,0 +1,273 @@
# RNS FileSync
Decentralized peer-to-peer file synchronization over the Reticulum Network Stack.
This package is designed as an embeddable library for host programs such as
MeshChatX and Sideband, with a thin CLI for standalone use. There is no TUI.
For usability and easy usage this follows rngit permissions and configuration.
## Features
- Peer-to-peer sync with no central server
- Delta sync for changed blocks
- Path-jailed writes and rngit-style ACL (permission:target)
- Embed-safe: reuses an existing RNS.Reticulum instance when present
- Works over any Reticulum medium (radio, LoRa, WiFi, internet)
- Daemon mode via --no-repl for init systems and containers
## Install
Over Reticulum with pip-rns (no internet required once you have a path):
```bash
pip-rns install rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync --pipx
```
From a release wheel over Reticulum (no source build):
```bash
pip-rns install --from-release rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync --ref v1.0.0 --pipx
```
From GitHub with pipx:
```bash
pipx install git+https://github.com/Quad4-Software/RNS-Filesync
```
From a local wheel:
```bash
make wheel
pipx install dist/rns_filesync-*.whl
# or: pip install dist/rns_filesync-*.whl
```
From a local checkout (development):
```bash
pip install -e ".[dev]"
# or: make install-user
```
Requires rns>=1.3.9. Current package version: **1.0.0**.
```bash
rns-filesync -v
# rns-filesync 1.0.0 | built 2026-07-19T12:00:00Z | commit abc1234
```
## Config
FileSync uses an rngit-like config directory (default `~/.rns_filesync/`):
```bash
rns-filesync -d ~/shared
# creates ~/.rns_filesync/config on first run
```
| Flag | Meaning |
|------|---------|
| `--config DIR` | FileSync config directory (default `~/.rns_filesync`) |
| `--rnsconfig DIR` | Reticulum config directory (default `~/.reticulum`) |
| `-d DIR` | Sync directory (overrides `[filesync] directory`) |
Example `~/.rns_filesync/config`:
```ini
[filesync]
announce_interval = 300
# directory = ~/shared
identity = rns_filesync
# peers = 9710b86ba12c42d1d8f30f74fe509286
# blocked_identities = d31aeea49873006f13b3415520666a4e
[aliases]
# alice = d09285e660cfe27cee6d9a0beb58b7e0
[access]
# sync = r:all, w:9710b86ba12c42d1d8f30f74fe509286, d:9710b86ba12c42d1d8f30f74fe509286
[logging]
loglevel = 4
```
Identities are stored under `~/.rns_filesync/identities/`.
## Permissions
Same rule form as rngit: `permission:target`.
| Perm | Meaning |
|------|---------|
| `r` | read |
| `w` | write |
| `d` | delete |
| `rw` | read/write |
| `rwd` | read/write/delete |
| `adm` | admin (all) |
Targets: identity hash, alias, `all`, or `none`.
When any ACL rule is loaded, access is **deny by default**. With no rules configured, the peer stays open (library/embed default).
Sidecar `.allowed` files (first found wins alongside config `[access]`):
- `<sync_dir>.allowed`
- `<parent>/<name>.allowed`
- `<sync_dir>/.allowed`
See `permissions.example`.
```bash
rns-filesync -d ~/shared --allow r:all --allow w:9710b86ba12c42d1d8f30f74fe509286
```
## CLI
```bash
rns-filesync -d ~/shared
rns-filesync -d ~/shared -p <peer_identity_hash>
rns-filesync --config ~/.rns_filesync --rnsconfig ~/.reticulum -d ~/shared
rns-filesync -v
rns-filesync --version
```
`-v` / `--version` prints version, bake build date, and git commit when available.
`-p` expects a peer **identity hash**. A destination hash is accepted as fallback
after the peer has announced.
Interactive commands: `status`, `peers`, `files`, `connect`, `disconnect`,
`browse`, `download`, `announce`, `quit`.
Logging verbosity uses `--verbose` (repeatable). Quiet mode is `-q`.
## Man page
```bash
make install-user # installs man page to ~/.local/share/man/man1/
man rns-filesync
```
## Library embed (MeshChatX-style)
```python
from rns_filesync import FileSyncService
from rns_filesync.permissions import PermissionStore
perms = PermissionStore()
perms.add_rule("r:all")
perms.add_rule("w:9710b86ba12c42d1d8f30f74fe509286")
service = FileSyncService(
identity=host_identity,
sync_directory="/path/to/sync",
reticulum=existing_reticulum,
permissions=perms,
)
dest_hash = service.start(monitor=True)
service.connect_peer(peer_identity_hash)
service.stop()
```
Keep aspect `rns_filesync.filesync` for wire compatibility.
## Sideband plugin
Install `rns-filesync` into the same Python environment Sideband uses, then copy
the drop-in plugins from `sideband/` into Sideband's plugins directory and
enable service/command plugins in Sideband settings.
```bash
# After installing rns-filesync (pip / pipx / pip-rns):
cp sideband/rns_filesync_service.py ~/.config/sideband/plugins/
cp sideband/rns_filesync_command.py ~/.config/sideband/plugins/
```
Set Sideband's plugin path to that directory, enable **service plugins** and
**command plugins**, then restart Sideband.
The service plugin reuses Sideband's identity and Reticulum instance. Sync
directory, peers, and ACL come from `~/.rns_filesync/config`. If no directory
is set there, it defaults to `~/.config/sideband/filesync`.
Remote LXMF commands (from a peer allowed to run Sideband commands):
```text
filesync status
filesync peers
filesync files
filesync announce
filesync connect <identity_hash>
filesync disconnect <peer_id>
```
## Daemon and packaging
Init units (non-root) live under packaging/ for systemd (system and user),
OpenRC, dinit, and runit. See packaging/README.md.
```bash
# system (dedicated rns-filesync user, hardened)
sudo systemctl enable --now rns-filesync
# or per-user
systemctl --user enable --now rns-filesync
```
Daemon entrypoint:
```bash
rns-filesync -d ~/shared --no-repl -q
```
Bubblewrap sandbox example (full command in packaging/README.md):
```bash
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox"
mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
exec bwrap \
--die-with-parent \
--new-session \
--proc /proc \
--dev /dev \
--ro-bind / / \
--tmpfs /tmp \
--bind "$DATA" "$DATA" \
--uid "$(id -u)" --gid "$(id -g)" \
rns-filesync \
--config "$DATA/config" \
--rnsconfig "$DATA/reticulum" \
-d "$DATA/sync" \
--no-repl \
-q
```
## Docker
Rootless multi-stage image and wheel builder under docker/. See docker/README.md.
```bash
docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
```
## Tests
```bash
make test
pytest -m "not live and not exploratory"
pytest -m e2e
pytest -m live
```
Markers: `unit`, `acceptance`, `smoke`, `e2e`, `fuzz`, `live`, `exploratory`.
Useful Make targets (aligned with pip-rns): `meta`, `wheel`, `sdist`, `test`,
`install-user`, `man`, `sign`, `release-rns`, `clean`, `test-services`.
## License
BSD-2-Clause. See LICENSE.

12
vendor/rns_filesync/README_RU.md vendored Normal file
View file

@ -0,0 +1,12 @@
# RNS FileSync (RU)
English README is the source of truth: [README.md](README.md).
Кратко: библиотека и CLI для синхронизации каталогов через Reticulum (`rns>=1.3.9`).
Конфиг и ACL как у rngit: `~/.rns_filesync/config`, правила `r:hash` / `w:all`, файлы `.allowed`.
```bash
pip install -e ".[dev]"
rns-filesync -d ~/shared -p <identity_hash>
rns-filesync --config ~/.rns_filesync --rnsconfig ~/.reticulum -d ~/shared
```

46
vendor/rns_filesync/docker/Dockerfile vendored Normal file
View file

@ -0,0 +1,46 @@
# Multi-stage rootless runtime image for rns-filesync.
# Build: docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
# Run instructions: see docker/README.md
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-alpine AS builder
WORKDIR /build
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
COPY pyproject.toml README.md LICENSE ./
COPY rns_filesync ./rns_filesync
COPY scripts ./scripts
COPY man ./man
RUN python -m pip install --no-cache-dir --upgrade pip build \
&& python scripts/bake_meta.py \
&& python -m build --wheel \
&& python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir dist/*.whl \
&& /opt/venv/bin/pip uninstall -y pip setuptools wheel \
&& find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
FROM python:${PYTHON_VERSION}-alpine AS runtime
RUN apk upgrade --no-cache \
&& adduser -D -u 1000 -h /home/filesync filesync \
&& mkdir -p /config /data /reticulum \
&& chown -R filesync:filesync /config /data /reticulum /home/filesync
COPY --from=builder /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH \
HOME=/home/filesync \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER filesync
WORKDIR /home/filesync
VOLUME ["/config", "/data", "/reticulum"]
ENTRYPOINT ["rns-filesync"]
CMD ["--config", "/config", "--rnsconfig", "/reticulum", "-d", "/data", "--no-repl", "-q"]

View file

@ -0,0 +1,29 @@
# Wheel builder image. Writes .whl artifacts to /output.
# Build and extract:
# docker build -f docker/Dockerfile.build -t rns-filesync-build .
# docker run --rm -v "$PWD/dist:/output" rns-filesync-build
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-alpine
WORKDIR /build
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev git \
&& adduser -D -u 1000 builder \
&& mkdir -p /output \
&& chown builder:builder /output /build
COPY --chown=builder:builder pyproject.toml README.md LICENSE ./
COPY --chown=builder:builder rns_filesync ./rns_filesync
COPY --chown=builder:builder scripts ./scripts
COPY --chown=builder:builder man ./man
USER builder
RUN python -m pip install --user --no-cache-dir --upgrade pip build \
&& python scripts/bake_meta.py \
&& python -m build --wheel --outdir /output \
&& ls -la /output
CMD ["sh", "-c", "cp -a /output/. /dist/ 2>/dev/null || true; ls -la /output"]

84
vendor/rns_filesync/docker/README.md vendored Normal file
View file

@ -0,0 +1,84 @@
# Docker images for RNS FileSync
Rootless multi-stage runtime and a separate wheel builder.
## Runtime image (rootless)
Build:
```bash
docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
```
Prepare host dirs owned by uid 1000:
```bash
mkdir -p ./fs-config ./fs-data ./fs-reticulum
```
Put FileSync config under ./fs-config (config file named config),
Reticulum config under ./fs-reticulum, and the sync tree under ./fs-data.
Run (rootless-friendly, drop capabilities):
```bash
docker run --rm -d \
--name rns-filesync \
--user=1000:1000 \
--read-only \
--tmpfs /tmp:rw,size=64m \
--security-opt no-new-privileges \
--cap-drop ALL \
-v "$PWD/fs-config:/config:rw" \
-v "$PWD/fs-data:/data:rw" \
-v "$PWD/fs-reticulum:/reticulum:rw" \
rns-filesync:1.0.0
```
Override peers or flags:
```bash
docker run --rm --user=1000:1000 \
-v "$PWD/fs-config:/config" \
-v "$PWD/fs-data:/data" \
-v "$PWD/fs-reticulum:/reticulum" \
rns-filesync:1.0.0 \
--config /config --rnsconfig /reticulum -d /data --no-repl -p PEER_HASH
```
Check version:
```bash
docker run --rm rns-filesync:1.0.0 -v
```
## Wheel builder
Build wheels inside the image and copy them out:
```bash
mkdir -p dist
docker build -f docker/Dockerfile.build -t rns-filesync-build .
docker run --rm -v "$PWD/dist:/output" rns-filesync-build \
sh -c 'cp -a /output/. /output/ 2>/dev/null; ls -la /output'
```
If the image already wrote wheels under /output during build, extract with:
```bash
cid=$(docker create rns-filesync-build)
docker cp "$cid":/output/. ./dist/
docker rm "$cid"
```
Install a built wheel:
```bash
pipx install dist/rns_filesync-*.whl
```
## Notes
- The runtime user is filesync (uid 1000). Match host volume ownership.
- Network is required for Reticulum interfaces you enable in /reticulum/config.
- Prefer --no-repl for containers and service managers.

View file

@ -0,0 +1,128 @@
.\" Man page for rns-filesync
.\" vim: ft=man
.TH RNS-FILESYNC 1 "2026-07-19" "1.0.0" "User Commands"
.SH NAME
rns-filesync \- peer-to-peer directory sync over the Reticulum Network Stack
.SH SYNOPSIS
.B rns-filesync
[\fB-v\fR|\fB--version\fR]
[\fB--config\fR \fIDIR\fR]
[\fB--rnsconfig\fR \fIDIR\fR]
[\fB-d\fR \fIDIR\fR]
[\fB-i\fR \fINAME\fR]
[\fB-p\fR \fIHASH\fR]...
[\fB-n\fR]
[\fB-a\fR \fISECONDS\fR]
[\fB--allowed\fR \fIFILE\fR]
[\fB--allow\fR \fIRULE\fR]...
[\fB--perms\fR \fIPERMS\fR]
[\fB--verbose\fR]...
[\fB-q\fR]
[\fB--no-repl\fR]
.SH DESCRIPTION
.B rns-filesync
synchronizes a directory with peers on the Reticulum network.
It is an embeddable library (MeshChatX, Sideband) with a thin CLI.
There is no TUI.
.PP
Configuration and ACL follow an rngit-like layout under
.I ~/.rns_filesync/
by default.
.SH OPTIONS
.TP
.BR \-v ", " \-\-version
Print version, build date, and git commit (when baked), then exit.
.TP
.BI \-\-config " DIR"
FileSync config directory (default:
.IR ~/.rns_filesync ).
.TP
.BI \-\-rnsconfig " DIR"
Reticulum config directory (default:
.IR ~/.reticulum ).
.TP
.BR \-d ", " \-\-directory " " \fIDIR\fR
Directory to synchronize (overrides
.B [filesync] directory
in config).
.TP
.BR \-i ", " \-\-identity " " \fINAME\fR
Identity name stored under the FileSync config directory.
.TP
.BR \-p ", " \-\-peer " " \fIHASH\fR
Peer identity hash to connect on start (repeatable).
A destination hash is accepted as fallback after the peer has announced.
.TP
.BR \-n ", " \-\-no\-monitor
Disable filesystem monitoring.
.TP
.BR \-a ", " \-\-announce\-interval " " \fISECONDS\fR
Announce interval in seconds.
.TP
.BI \-\-allowed " FILE"
Path to an
.B .allowed
ACL file (rngit-style
.I permission:target
lines).
.TP
.BI \-\-allow " RULE"
ACL rule such as
.I r:all
or
.IR w:HASH
(repeatable).
.TP
.BI \-\-perms " PERMS"
Legacy shorthand for bare
.B \-\-allow
hashes (
.IR r ,
.IR w ,
.IR d ,
.IR rw ,
.IR rwd ).
.TP
.B \-\-verbose
Increase Reticulum log verbosity (repeatable).
.TP
.BR \-q ", " \-\-quiet
Reduce logging.
.TP
.B \-\-no\-repl
Run without the interactive command prompt.
.SH INTERACTIVE COMMANDS
When the REPL is enabled:
.BR status ,
.BR peers ,
.BR files ,
.BR connect ,
.BR disconnect ,
.BR browse ,
.BR download ,
.BR announce ,
.BR quit .
.SH FILES
.TP
.I ~/.rns_filesync/config
Main FileSync configuration (ConfigObj).
.TP
.I ~/.rns_filesync/identities/
Stored identities.
.TP
.I <sync_dir>.allowed
Optional sidecar ACL next to the sync directory.
.SH EXAMPLES
.nf
rns-filesync -d ~/shared
rns-filesync -d ~/shared -p 9710b86ba12c42d1d8f30f74fe509286
rns-filesync -v
.fi
.SH SEE ALSO
.BR rnsd (1),
.BR rnstatus (1)
.PP
Documentation and Sideband plugins:
.I https://github.com/Quad4-Software/RNS-Filesync
.SH AUTHOR
Quad4 Software

126
vendor/rns_filesync/packaging/README.md vendored Normal file
View file

@ -0,0 +1,126 @@
# Init and packaging files for RNS FileSync
All system units run as non-root user rns-filesync with state under
/var/lib/rns-filesync. Prefer the systemd user unit when you do not want a
system account.
## Layout
- systemd/rns-filesync.service - hardened system unit
- systemd/rns-filesync.user.service - per-user unit (systemctl --user)
- openrc/rns-filesync - OpenRC script
- dinit/rns-filesync - dinit service
- runit/rns-filesync/run - runit run script
- sysusers.d/rns-filesync.conf - system user
- tmpfiles.d/rns-filesync.conf - state directories
- rns-filesync.env.example - optional env file
## systemd (system)
```bash
sudo install -m 644 packaging/sysusers.d/rns-filesync.conf /usr/lib/sysusers.d/
sudo install -m 644 packaging/tmpfiles.d/rns-filesync.conf /usr/lib/tmpfiles.d/
sudo systemd-sysusers
sudo systemd-tmpfiles --create
sudo install -m 644 packaging/systemd/rns-filesync.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rns-filesync
```
Ensure /usr/bin/rns-filesync exists (package install or symlink).
## systemd (user)
```bash
mkdir -p ~/.config/systemd/user ~/Filesync
cp packaging/systemd/rns-filesync.user.service ~/.config/systemd/user/rns-filesync.service
systemctl --user daemon-reload
systemctl --user enable --now rns-filesync
```
Adjust ExecStart paths if rns-filesync is not in ~/.local/bin.
## OpenRC
```bash
sudo install -m 755 packaging/openrc/rns-filesync /etc/init.d/rns-filesync
sudo rc-update add rns-filesync default
sudo rc-service rns-filesync start
```
Create user rns-filesync first if your distro does not use sysusers.
## dinit
```bash
sudo install -m 644 packaging/dinit/rns-filesync /etc/dinit.d/rns-filesync
sudo dinitctl enable rns-filesync
```
## runit
```bash
sudo mkdir -p /etc/sv/rns-filesync
sudo install -m 755 packaging/runit/rns-filesync/run /etc/sv/rns-filesync/run
sudo ln -s /etc/sv/rns-filesync /var/service/
```
## Security notes
System units use NoNewPrivileges, ProtectSystem=strict, empty capability set,
and ReadWritePaths limited to /var/lib/rns-filesync. Sync ACL still comes from
~/.rns_filesync style config under that state directory.
## Sandboxing
For an extra filesystem boundary on a host install (pip, pipx, or package),
run under Bubblewrap. Network stays shared so Reticulum interfaces keep working.
Do not add --unshare-net unless you intend to isolate networking.
```bash
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox"
mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
exec bwrap \
--die-with-parent \
--new-session \
--proc /proc \
--dev /dev \
--ro-bind / / \
--tmpfs /tmp \
--bind "$DATA" "$DATA" \
--uid "$(id -u)" --gid "$(id -g)" \
rns-filesync \
--config "$DATA/config" \
--rnsconfig "$DATA/reticulum" \
-d "$DATA/sync" \
--no-repl \
-q
```
Notes:
- Only "$DATA" is writable. The rest of the root filesystem is read-only.
- Put config, sync tree, and Reticulum config under "$DATA" so the process
does not need write access to your home directory.
- If rns-filesync lives in a venv outside "$DATA", --ro-bind / / still allows
reading it. Bind that path read-write only if the venv must be mutated.
- For USB radios, device nodes under /dev are available via --dev /dev.
Tighten further if your policy requires it.
- Docker and Podman are a separate isolation model. See docker/README.md.
## Service file tests (Docker)
Run packaging unit tests against appropriate OS images:
| Init | Image |
|------|-------|
| systemd | debian:bookworm-slim (+ systemd) |
| OpenRC | alpine:3.21 |
| dinit | debian:bookworm-slim (service file + command) |
| runit | Void Linux (Alpine fallback) |
```bash
./packaging/docker-test/run-all.sh
# or: make test-services
```

View file

@ -0,0 +1,9 @@
# dinit service for rns-filesync (non-root).
# Install to /etc/dinit.d/rns-filesync
# Create user rns-filesync and state dirs under /var/lib/rns-filesync first.
type = process
command = /usr/bin/env HOME=/var/lib/rns-filesync /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q
restart = true
smooth-recovery = true
runs-as = rns-filesync

View file

@ -0,0 +1,11 @@
# Debian for dinit service-file validation and non-root command execution.
# Full dinit as PID1 is not required to verify the shipped service definition.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 python3-pip ca-certificates procps \
&& rm -rf /var/lib/apt/lists/*
CMD ["/bin/sh"]

View file

@ -0,0 +1,9 @@
# Alpine + OpenRC: validate and start via openrc-run style command path.
FROM alpine:3.21
RUN apk add --no-cache \
openrc python3 py3-pip procps shadow \
&& mkdir -p /run/openrc \
&& touch /run/openrc/softlevel
CMD ["/bin/sh"]

View file

@ -0,0 +1,8 @@
# Void Linux + runit.
FROM ghcr.io/void-linux/void-glibc:latest
RUN xbps-install -Syu \
&& xbps-install -Sy python3 python3-pip runit procps-ng shadow \
&& xbps-remove -yO || true
CMD ["/bin/sh"]

View file

@ -0,0 +1,8 @@
# Alpine fallback when Void image cannot be pulled.
FROM alpine:3.21
RUN apk add --no-cache \
runit python3 py3-pip procps shadow \
runit-openrc 2>/dev/null || apk add --no-cache runit python3 py3-pip procps shadow
CMD ["/bin/sh"]

View file

@ -0,0 +1,12 @@
# Debian Bookworm + systemd: verify unit and start via systemd.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
systemd systemd-sysv python3 python3-pip python3-venv \
ca-certificates procps \
&& rm -rf /var/lib/apt/lists/*
STOPSIGNAL SIGRTMIN+3
CMD ["/lib/systemd/systemd"]

View file

@ -0,0 +1,22 @@
#!/bin/sh
# Assert daemon is alive as rns-filesync for a few seconds.
set -eu
pid="$1"
user="${2:-rns-filesync}"
i=0
while [ "$i" -lt 8 ]; do
if ! kill -0 "$pid" 2>/dev/null; then
echo "FAIL: process $pid died"
exit 1
fi
owner="$(ps -o user= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
if [ -n "$owner" ] && [ "$owner" != "$user" ]; then
echo "FAIL: pid $pid owner is '$owner' expected '$user'"
exit 1
fi
i=$((i + 1))
sleep 1
done
echo "OK: pid $pid running as $user for ${i}s"
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true

View file

@ -0,0 +1,88 @@
#!/bin/sh
# Shared setup for packaging service tests inside containers.
set -eu
pip_install() {
if python3 -m pip install --help 2>/dev/null | grep -q break-system-packages; then
python3 -m pip install --break-system-packages "$@"
else
python3 -m pip install "$@"
fi
}
echo "==> install rns-filesync from wheel"
pip_install /wheels/*.whl
BIN="$(command -v rns-filesync)"
case "$BIN" in
/usr/bin/rns-filesync) ;;
*) ln -sfn "$BIN" /usr/bin/rns-filesync ;;
esac
test -x /usr/bin/rns-filesync
/usr/bin/rns-filesync -v
echo "==> create non-root user and state dirs"
if ! id rns-filesync >/dev/null 2>&1; then
if command -v useradd >/dev/null 2>&1; then
useradd --system --home-dir /var/lib/rns-filesync --create-home \
--shell /usr/sbin/nologin rns-filesync 2>/dev/null \
|| useradd -r -d /var/lib/rns-filesync -m -s /sbin/nologin rns-filesync
elif command -v adduser >/dev/null 2>&1; then
adduser -S -D -h /var/lib/rns-filesync -s /sbin/nologin rns-filesync 2>/dev/null \
|| adduser --system --home /var/lib/rns-filesync --shell /usr/sbin/nologin rns-filesync
else
echo "FAIL: no useradd/adduser"
exit 1
fi
fi
# Ensure group exists for chown -g
if ! getent group rns-filesync >/dev/null 2>&1; then
if command -v groupadd >/dev/null 2>&1; then
groupadd --system rns-filesync 2>/dev/null || groupadd rns-filesync
elif command -v addgroup >/dev/null 2>&1; then
addgroup -S rns-filesync 2>/dev/null || addgroup rns-filesync
fi
fi
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
cat >/var/lib/rns-filesync/config/config <<'EOF'
[filesync]
announce_interval = 300
directory = /var/lib/rns-filesync/sync
identity = rns_filesync
[access]
sync = r:all
[logging]
loglevel = 6
EOF
cat >/var/lib/rns-filesync/reticulum/config <<'EOF'
[reticulum]
enable_transport = Yes
share_instance = No
shared_instance_port = 37428
instance_name = filesync_svc_test
panic_on_interface_error = No
[logging]
loglevel = 6
[interfaces]
[[Loopback UDP]]
type = UDPInterface
enabled = yes
listen_ip = 127.0.0.1
listen_port = 42500
forward_ip = 127.0.0.1
forward_port = 42501
EOF
chown -R rns-filesync:rns-filesync /var/lib/rns-filesync
echo "==> shared setup done"

View file

@ -0,0 +1,84 @@
#!/bin/sh
# Build wheels and run packaging service tests in appropriate OS images.
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
IMG_RUNIT="${IMG_RUNIT:-ghcr.io/void-linux/void-glibc-full:20240526R1}"
echo "==> bake wheel"
mkdir -p dist
make wheel
WHEEL="$(ls -1 dist/rns_filesync-*.whl | head -n1)"
test -n "$WHEEL"
echo "wheel=$WHEEL"
run_named() {
name="$1"
dockerfile="$2"
base_hint="$3"
testscript="$4"
echo ""
echo "======== TEST $name ($base_hint) ========"
docker build -f "packaging/docker-test/$dockerfile" \
-t "rns-filesync-svc-$name" \
packaging/docker-test
docker run --rm \
-v "$ROOT/packaging:/packaging:ro" \
-v "$ROOT/packaging/docker-test:/test:ro" \
-v "$ROOT/dist:/wheels:ro" \
"rns-filesync-svc-$name" \
/bin/sh /test/"$testscript"
}
echo ""
echo "======== TEST systemd (debian bookworm + systemd) ========"
docker build -f packaging/docker-test/Dockerfile.systemd \
-t rns-filesync-svc-systemd packaging/docker-test
cid="$(docker run -d --privileged --cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
-v "$ROOT/packaging:/packaging:ro" \
-v "$ROOT/packaging/docker-test:/test:ro" \
-v "$ROOT/dist:/wheels:ro" \
rns-filesync-svc-systemd)"
cleanup_systemd() {
docker rm -f "$cid" >/dev/null 2>&1 || true
}
trap cleanup_systemd EXIT
i=0
while [ "$i" -lt 40 ]; do
if docker exec "$cid" systemctl list-units >/dev/null 2>&1; then
break
fi
i=$((i + 1))
sleep 1
done
docker exec "$cid" /bin/sh /test/test-systemd.sh
cleanup_systemd
trap - EXIT
echo "PASS: systemd outer"
run_named openrc Dockerfile.openrc "alpine:3.21 + openrc" test-openrc.sh
run_named dinit Dockerfile.dinit "debian:bookworm (dinit service file)" test-dinit.sh
echo ""
echo "======== TEST runit ========"
if docker pull "$IMG_RUNIT" >/dev/null 2>&1 \
&& docker build -f packaging/docker-test/Dockerfile.runit \
-t rns-filesync-svc-runit packaging/docker-test; then
echo "using Void Linux runit image"
else
echo "using Alpine runit fallback"
docker build -f packaging/docker-test/Dockerfile.runit-alpine \
-t rns-filesync-svc-runit packaging/docker-test
fi
docker run --rm \
-v "$ROOT/packaging:/packaging:ro" \
-v "$ROOT/packaging/docker-test:/test:ro" \
-v "$ROOT/dist:/wheels:ro" \
rns-filesync-svc-runit \
/bin/sh /test/test-runit.sh
echo ""
echo "ALL SERVICE TESTS PASSED"

View file

@ -0,0 +1,118 @@
#!/bin/sh
# Exercise the documented Bubblewrap sandbox command on the host.
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
if [ -x "$ROOT/.venv/bin/rns-filesync" ]; then
RNS_BIN="$ROOT/.venv/bin/rns-filesync"
elif command -v rns-filesync >/dev/null 2>&1; then
RNS_BIN="$(command -v rns-filesync)"
else
echo "FAIL: rns-filesync not found"
exit 1
fi
if ! command -v bwrap >/dev/null 2>&1; then
echo "FAIL: bwrap not installed (package bubblewrap)"
exit 1
fi
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox-test"
rm -rf "$DATA"
mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
cat > "$DATA/config/config" <<EOF
[filesync]
announce_interval = 300
directory = $DATA/sync
identity = rns_filesync
[access]
sync = r:all
[logging]
loglevel = 6
EOF
cat > "$DATA/reticulum/config" <<'EOF'
[reticulum]
enable_transport = Yes
share_instance = No
shared_instance_port = 37501
instance_name = filesync_bwrap_test
panic_on_interface_error = No
[logging]
loglevel = 6
[interfaces]
[[Loopback UDP]]
type = UDPInterface
enabled = yes
listen_ip = 127.0.0.1
listen_port = 42610
forward_ip = 127.0.0.1
forward_port = 42611
EOF
echo "==> version inside bwrap"
bwrap \
--die-with-parent \
--new-session \
--proc /proc \
--dev /dev \
--ro-bind / / \
--tmpfs /tmp \
--bind "$DATA" "$DATA" \
--uid "$(id -u)" --gid "$(id -g)" \
"$RNS_BIN" --version
echo "==> start sandboxed daemon (README command shape)"
bwrap \
--die-with-parent \
--new-session \
--proc /proc \
--dev /dev \
--ro-bind / / \
--tmpfs /tmp \
--bind "$DATA" "$DATA" \
--uid "$(id -u)" --gid "$(id -g)" \
"$RNS_BIN" \
--config "$DATA/config" \
--rnsconfig "$DATA/reticulum" \
-d "$DATA/sync" \
--no-repl \
-q &
BPID=$!
sleep 3
if ! kill -0 "$BPID" 2>/dev/null; then
echo "FAIL: bwrap daemon died"
wait "$BPID" || true
exit 1
fi
echo "OK: sandboxed daemon pid=$BPID alive"
echo sandbox-write-ok > "$DATA/sync/probe.txt"
test -f "$DATA/sync/probe.txt"
echo "OK: DATA sync dir is writable"
if bwrap \
--die-with-parent \
--new-session \
--proc /proc \
--dev /dev \
--ro-bind / / \
--tmpfs /tmp \
--bind "$DATA" "$DATA" \
--uid "$(id -u)" --gid "$(id -g)" \
/bin/sh -c 'echo should-fail > /etc/rns-filesync-bwrap-probe'; then
echo "FAIL: wrote outside DATA"
kill "$BPID" 2>/dev/null || true
exit 1
fi
echo "OK: root outside DATA is read-only"
kill "$BPID" 2>/dev/null || true
wait "$BPID" 2>/dev/null || true
rm -rf "$DATA"
echo "PASS: bwrap sandbox command"

View file

@ -0,0 +1,35 @@
#!/bin/sh
# Test dinit service file and the command it would run.
set -eu
. /test/common-setup.sh
svc=/packaging/dinit/rns-filesync
install -d /etc/dinit.d
install -m 644 "$svc" /etc/dinit.d/rns-filesync
echo "==> validate dinit service keys"
grep -Eq '^type = process$' /etc/dinit.d/rns-filesync
grep -Eq '^restart = true$' /etc/dinit.d/rns-filesync
grep -Eq '^runs-as = rns-filesync$' /etc/dinit.d/rns-filesync
grep -Eq '^command = .*/usr/bin/rns-filesync' /etc/dinit.d/rns-filesync
if command -v dinitcheck >/dev/null 2>&1; then
echo "==> dinitcheck"
dinitcheck /etc/dinit.d/rns-filesync || dinitcheck -d /etc/dinit.d
fi
echo "==> run service command as rns-filesync"
su -s /bin/sh -c \
'exec /usr/bin/env HOME=/var/lib/rns-filesync /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
rns-filesync &
pid=$!
sleep 1
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
if [ -z "$fs_pid" ]; then
echo "FAIL: rns-filesync not running as rns-filesync"
ps aux || true
exit 1
fi
/test/assert-running.sh "$fs_pid" rns-filesync
echo "PASS: dinit"

View file

@ -0,0 +1,35 @@
#!/bin/sh
# Test OpenRC service on Alpine.
set -eu
. /test/common-setup.sh
install -m 755 /packaging/openrc/rns-filesync /etc/init.d/rns-filesync
echo "==> shell syntax check"
sh -n /etc/init.d/rns-filesync
echo "==> openrc script metadata"
grep -q 'command="/usr/bin/rns-filesync"' /etc/init.d/rns-filesync
grep -q 'command_user="rns-filesync:rns-filesync"' /etc/init.d/rns-filesync
grep -q 'command_background=yes' /etc/init.d/rns-filesync
echo "==> mimic start_pre (checkpath dirs)"
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
echo "==> start daemon via openrc command + args as service user"
su -s /bin/sh -c \
'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
rns-filesync &
sleep 1
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
if [ -z "$fs_pid" ]; then
echo "FAIL: rns-filesync not running as rns-filesync"
ps aux || true
exit 1
fi
/test/assert-running.sh "$fs_pid" rns-filesync
echo "PASS: openrc"

View file

@ -0,0 +1,28 @@
#!/bin/sh
# Test runit run script on Void (or any runit-capable image).
set -eu
. /test/common-setup.sh
install -d /etc/sv/rns-filesync
install -m 755 /packaging/runit/rns-filesync/run /etc/sv/rns-filesync/run
echo "==> shell syntax check"
sh -n /etc/sv/rns-filesync/run
echo "==> start via runit run script"
# run script execs forever - start in background by replacing exec with run
# Invoke under a subshell that keeps it as supervised child.
/etc/sv/rns-filesync/run &
pid=$!
# The run script may re-exec as another pid via su/chpst. Find the filesync process.
sleep 2
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
if [ -z "$fs_pid" ]; then
echo "FAIL: rns-filesync not running after runit start"
ps aux || true
exit 1
fi
/test/assert-running.sh "$fs_pid" rns-filesync
kill "$pid" 2>/dev/null || true
echo "PASS: runit"

View file

@ -0,0 +1,51 @@
#!/bin/sh
# Test systemd unit inside a running systemd container.
set -eu
. /test/common-setup.sh
install -m 644 /packaging/sysusers.d/rns-filesync.conf /usr/lib/sysusers.d/ 2>/dev/null || true
install -m 644 /packaging/tmpfiles.d/rns-filesync.conf /usr/lib/tmpfiles.d/ 2>/dev/null || true
systemd-sysusers || true
systemd-tmpfiles --create || true
install -m 644 /packaging/systemd/rns-filesync.service /etc/systemd/system/rns-filesync.service
install -m 644 /packaging/systemd/rns-filesync.user.service /tmp/rns-filesync.user.service
echo "==> systemd-analyze verify (system unit)"
# Documentation=man: fails verify when man page is not installed in the image.
cp /etc/systemd/system/rns-filesync.service /tmp/rns-filesync.verify.service
sed -i '/^Documentation=/d' /tmp/rns-filesync.verify.service
systemd-analyze verify /tmp/rns-filesync.verify.service
echo "==> check user unit file parses"
grep -q 'ExecStart=' /tmp/rns-filesync.user.service
grep -q 'NoNewPrivileges=true' /tmp/rns-filesync.user.service
echo "==> systemctl enable and start"
systemctl daemon-reload
systemctl enable rns-filesync.service
systemctl start rns-filesync.service
sleep 4
systemctl --no-pager --full status rns-filesync.service || true
if ! systemctl is-active --quiet rns-filesync.service; then
echo "WARN: systemctl start did not reach active (common in nested cgroup docker)"
echo "==> fallback: start ExecStart as service user"
su -s /bin/sh -c \
'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
rns-filesync &
sleep 1
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
if [ -z "$fs_pid" ]; then
echo "FAIL: rns-filesync not running as rns-filesync"
ps aux || true
exit 1
fi
/test/assert-running.sh "$fs_pid" rns-filesync
else
echo "==> active via systemd"
sleep 2
systemctl is-active rns-filesync.service | grep -qx active
systemctl stop rns-filesync.service
fi
echo "PASS: systemd"

View file

@ -0,0 +1,24 @@
#!/sbin/openrc-run
description="RNS FileSync peer-to-peer directory sync"
command="/usr/bin/rns-filesync"
command_args="--config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q"
command_user="rns-filesync:rns-filesync"
command_background=yes
pidfile="/run/rns-filesync.pid"
directory="/var/lib/rns-filesync"
export HOME=/var/lib/rns-filesync
depend() {
need localmount
use net dns logger
after firewall
}
start_pre() {
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/config
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/sync
}

View file

@ -0,0 +1,5 @@
# Optional environment overrides for system service installs.
# Copy to /etc/default/rns-filesync or /etc/rns-filesync/env
# Example:
# RNS_LOGLEVEL=4

View file

@ -0,0 +1,26 @@
#!/bin/sh
exec 2>&1
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
export HOME=/var/lib/rns-filesync
cd /var/lib/rns-filesync || exit 1
if command -v chpst >/dev/null 2>&1; then
exec chpst -u rns-filesync:rns-filesync /usr/bin/rns-filesync \
--config /var/lib/rns-filesync/config \
--rnsconfig /var/lib/rns-filesync/reticulum \
-d /var/lib/rns-filesync/sync \
--no-repl \
-q
fi
if command -v setuidgid >/dev/null 2>&1; then
exec setuidgid rns-filesync /usr/bin/rns-filesync \
--config /var/lib/rns-filesync/config \
--rnsconfig /var/lib/rns-filesync/reticulum \
-d /var/lib/rns-filesync/sync \
--no-repl \
-q
fi
exec su -s /bin/sh rns-filesync -c \
'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q'

View file

@ -0,0 +1,47 @@
[Unit]
Description=RNS FileSync peer-to-peer directory sync
Documentation=man:rns-filesync(1)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=rns-filesync
Group=rns-filesync
Environment=HOME=/var/lib/rns-filesync
EnvironmentFile=-/etc/default/rns-filesync
EnvironmentFile=-/etc/rns-filesync/env
ExecStart=/usr/bin/rns-filesync \
--config /var/lib/rns-filesync/config \
--rnsconfig /var/lib/rns-filesync/reticulum \
-d /var/lib/rns-filesync/sync \
--no-repl \
-q
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillMode=mixed
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectHostname=true
ProtectClock=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
RestrictNamespaces=true
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
ReadWritePaths=/var/lib/rns-filesync
UMask=0077
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,23 @@
[Unit]
Description=RNS FileSync (user session)
Documentation=man:rns-filesync(1)
After=default.target
[Service]
Type=simple
ExecStart=%h/.local/bin/rns-filesync \
--config %h/.rns_filesync \
--rnsconfig %h/.reticulum \
-d %h/Filesync \
--no-repl \
-q
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
UMask=0077
[Install]
WantedBy=default.target

View file

@ -0,0 +1 @@
u rns-filesync - "RNS FileSync" /var/lib/rns-filesync -

View file

@ -0,0 +1,4 @@
d /var/lib/rns-filesync 0700 rns-filesync rns-filesync -
d /var/lib/rns-filesync/config 0700 rns-filesync rns-filesync -
d /var/lib/rns-filesync/reticulum 0700 rns-filesync rns-filesync -
d /var/lib/rns-filesync/sync 0700 rns-filesync rns-filesync -

35
vendor/rns_filesync/permissions.example vendored Normal file
View file

@ -0,0 +1,35 @@
# RNS FileSync access file (.allowed)
#
# Same rule format as rngit: permission:target (one rule per line).
# You can also put comma-separated rules on one line.
#
# Place this file as:
# <sync_dir>.allowed
# <parent>/<sync_dir_name>.allowed
# <sync_dir>/.allowed
#
# Permissions:
# r = read
# w = write
# d = delete
# rw = read/write
# rwd = read/write/delete
# adm = admin (all)
#
# Targets: identity hash, alias from config, all, or none.
#
# Default is deny until rules are added.
# Public read-only
# r:all
# Full access for one peer
# rwd:b559704616f042e0b7b6ba46ada4c670
# Read/write for one peer, read for everyone
r:all
w:b559704616f042e0b7b6ba46ada4c670
d:b559704616f042e0b7b6ba46ada4c670
# Or combined:
# r:all, rw:20d1f3debb50dde3df2cff74413b0f0d

47
vendor/rns_filesync/pyproject.toml vendored Normal file
View file

@ -0,0 +1,47 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "rns-filesync"
version = "1.0.0"
description = "Peer-to-peer file synchronization over the Reticulum Network Stack"
readme = "README.md"
license = { text = "BSD-2-Clause" }
requires-python = ">=3.10"
dependencies = [
"rns>=1.3.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-timeout>=2.3",
"hypothesis>=6.100",
"coverage>=7.0",
]
[project.scripts]
rns-filesync = "rns_filesync.cli:main"
[tool.setuptools.packages.find]
include = ["rns_filesync*"]
[tool.pytest.ini_options]
markers = [
"unit: fast isolated unit tests",
"acceptance: behavior and policy acceptance tests",
"smoke: basic import and CLI smoke tests",
"e2e: end-to-end sync over isolated Reticulum",
"fuzz: property and fuzz tests",
"live: tests against a real Reticulum config",
"exploratory: longer stress and edge-case exploration",
]
testpaths = ["tests"]
addopts = "-q --strict-markers"
timeout = 120
filterwarnings = ["ignore::DeprecationWarning"]
[tool.coverage.run]
source = ["rns_filesync"]
omit = ["rns_filesync/cli.py"]

1
vendor/rns_filesync/requirements.txt vendored Normal file
View file

@ -0,0 +1 @@
rns>=1.3.9

7
vendor/rns_filesync/rns_filesync.py vendored Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""Compatibility shim for python rns_filesync.py."""
from rns_filesync.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,19 @@
"""RNS FileSync: peer-to-peer directory sync over Reticulum."""
from rns_filesync._meta import (
BUILD_DATE,
GIT_COMMIT,
__version__,
version_info,
version_string,
)
from rns_filesync.service import FileSyncService
__all__ = [
"FileSyncService",
"__version__",
"BUILD_DATE",
"GIT_COMMIT",
"version_info",
"version_string",
]

View file

@ -0,0 +1,6 @@
"""Module entrypoint for python -m rns_filesync."""
from rns_filesync.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,35 @@
"""Package version and build metadata.
Values are overwritten by make meta / make build when building from git.
Defaults keep editable installs and tests working without a Makefile bake step.
"""
from __future__ import annotations
__version__ = '1.0.0'
BUILD_DATE = '2026-07-19T13:14:52Z'
GIT_COMMIT = '12161f3'
GIT_DIRTY = '1'
def version_string() -> str:
"""Single-line version for CLI -v / --version."""
parts = [f"rns-filesync {__version__}"]
if BUILD_DATE and BUILD_DATE != "unknown":
parts.append(f"built {BUILD_DATE}")
if GIT_COMMIT and GIT_COMMIT != "unknown":
dirty = "+" if GIT_DIRTY in {"1", "true", "yes", "dirty"} else ""
parts.append(f"commit {GIT_COMMIT}{dirty}")
return " | ".join(parts)
def version_info() -> dict[str, str]:
return {
"version": __version__,
"build_date": BUILD_DATE,
"git_commit": GIT_COMMIT,
"git_dirty": GIT_DIRTY,
}

339
vendor/rns_filesync/rns_filesync/cli.py vendored Normal file
View file

@ -0,0 +1,339 @@
"""Command-line interface for RNS FileSync."""
from __future__ import annotations
import argparse
import os
import sys
import time
import RNS
from rns_filesync._meta import version_string
from rns_filesync.config import (
allowed_sidecar_paths,
config_get,
load_config,
parse_csv_hashes,
)
from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT, APP_NAME
from rns_filesync.permissions import PermissionStore
from rns_filesync.service import FileSyncService
def load_or_create_identity(identity_name: str, identity_dir: str):
os.makedirs(identity_dir, exist_ok=True)
identity_path = os.path.join(identity_dir, identity_name)
if os.path.isfile(identity_path):
identity = RNS.Identity.from_file(identity_path)
RNS.log(f"Loaded identity {identity_name}", RNS.LOG_INFO)
else:
identity = RNS.Identity()
identity.to_file(identity_path)
RNS.log(f"Created identity {identity_name}", RNS.LOG_INFO)
return identity
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="RNS FileSync - peer-to-peer file sync over Reticulum",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=version_string(),
help="print version, build date, and commit then exit",
)
parser.add_argument(
"--config",
default=None,
help="FileSync config directory (default: ~/.rns_filesync)",
)
parser.add_argument(
"--rnsconfig",
default=None,
help="Reticulum config directory (default: ~/.reticulum)",
)
parser.add_argument(
"-d",
"--directory",
default=None,
help="directory to synchronize (overrides config)",
)
parser.add_argument(
"-i",
"--identity",
default=None,
help="identity name (stored under FileSync config dir)",
)
parser.add_argument(
"-p",
"--peer",
action="append",
dest="peers",
help="peer identity hash (destination hash accepted as fallback)",
)
parser.add_argument(
"-n",
"--no-monitor",
action="store_true",
help="disable file monitoring",
)
parser.add_argument(
"-a",
"--announce-interval",
type=int,
default=None,
help="announce interval in seconds",
)
parser.add_argument(
"--allowed",
default=None,
help="path to .allowed ACL file (rngit-style permission:target lines)",
)
parser.add_argument(
"--permissions-file",
default=None,
help="alias for --allowed (legacy name)",
)
parser.add_argument(
"--allow",
action="append",
dest="allowed_peers",
help="ACL rule (r:hash) or bare identity hash used with --perms",
)
parser.add_argument(
"--perms",
default="rwd",
help="legacy shorthand for bare --allow hashes (r, w, d, rw, rwd)",
)
parser.add_argument(
"--verbose",
action="count",
default=0,
help="increase logging (use --verbose, repeat for more)",
)
parser.add_argument("-q", "--quiet", action="store_true", help="reduce logging")
parser.add_argument(
"--no-repl",
action="store_true",
help="run without interactive command prompt",
)
return parser
def _set_loglevel(verbose: int, quiet: bool, config_level: int | None) -> None:
if quiet:
RNS.loglevel = RNS.LOG_ERROR
return
if verbose == 1:
RNS.loglevel = RNS.LOG_VERBOSE
elif verbose == 2:
RNS.loglevel = RNS.LOG_DEBUG
elif verbose >= 3:
RNS.loglevel = RNS.LOG_EXTREME
elif config_level is not None:
RNS.loglevel = int(config_level)
else:
RNS.loglevel = RNS.LOG_INFO
def _print_help() -> None:
print(
"commands: status peers connect <hash> disconnect <id> browse <id> "
"download <id> <path> announce files quit",
)
def run_repl(service: FileSyncService) -> None:
_print_help()
while True:
try:
line = input("filesync> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
parts = line.split()
cmd = parts[0].lower()
if cmd in {"quit", "exit", "q"}:
break
if cmd in {"help", "?"}:
_print_help()
elif cmd == "status":
print(service.get_status())
elif cmd == "peers":
for peer in service.list_peers():
print(peer)
elif cmd == "files":
for item in service.list_files():
print(f"{item['path']}\t{item['size']}\t{item['hash']}")
elif cmd == "announce":
service.announce_now()
print("announced")
elif cmd == "connect" and len(parts) >= 2:
print(service.connect_peer(parts[1]))
elif cmd == "disconnect" and len(parts) >= 2:
service.disconnect_peer(parts[1])
print("disconnected")
elif cmd == "browse" and len(parts) >= 2:
for item in service.browse_peer(parts[1]):
print(item)
elif cmd == "download" and len(parts) >= 3:
print(service.download_file(parts[1], parts[2]))
else:
print("unknown command")
_print_help()
def build_permissions(
*,
config,
sync_directory: str,
allowed_path: str | None,
allow_args: list[str] | None,
perms_shorthand: str,
) -> PermissionStore:
store = PermissionStore()
aliases = config.get("aliases") or {}
if isinstance(aliases, dict):
store.set_aliases(
{str(k): str(v) for k, v in aliases.items() if not str(k).startswith("#")},
)
blocked = config_get(config, "filesync", "blocked_identities", None)
store.set_blocked(parse_csv_hashes(blocked))
access = config.get("access") or {}
if isinstance(access, dict):
for key, value in access.items():
if str(key).startswith("#"):
continue
store.load_access_value(value)
for path in allowed_sidecar_paths(sync_directory):
if os.path.isfile(path):
loaded = store.load_file(path)
if loaded:
RNS.log(f"Loaded {loaded} ACL rules from {path}", RNS.LOG_INFO)
if allowed_path:
store.load_file(allowed_path)
if allow_args:
for item in allow_args:
item = item.strip()
if ":" in item:
store.add_rule(item)
else:
# Bare hash: apply --perms shorthand as rngit-style rule(s).
shorthand = perms_shorthand.strip().lower()
if shorthand in {"r", "w", "d", "rw", "rwd", "adm", "admin"}:
store.add_rule(f"{shorthand}:{item}")
else:
# Comma list of long names
store.grant(item, [p.strip() for p in shorthand.split(",")])
return store
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
config_dir, config = load_config(args.config)
config_level = config_get(config, "logging", "loglevel", None)
try:
config_level = int(config_level) if config_level is not None else None
except (TypeError, ValueError):
config_level = None
_set_loglevel(args.verbose, args.quiet, config_level)
directory = args.directory or config_get(config, "filesync", "directory", None)
if not directory:
parser.error(
"sync directory required via -d/--directory or config [filesync] directory",
)
directory = os.path.realpath(os.path.expanduser(str(directory)))
identity_name = args.identity or config_get(
config,
"filesync",
"identity",
APP_NAME,
)
announce_interval = args.announce_interval
if announce_interval is None:
raw = config_get(
config,
"filesync",
"announce_interval",
ANNOUNCE_INTERVAL_DEFAULT,
)
try:
announce_interval = int(raw)
except (TypeError, ValueError):
announce_interval = ANNOUNCE_INTERVAL_DEFAULT
peers = list(args.peers or [])
peers.extend(parse_csv_hashes(config_get(config, "filesync", "peers", None)))
allowed_path = args.allowed or args.permissions_file
permissions = build_permissions(
config=config,
sync_directory=directory,
allowed_path=allowed_path,
allow_args=args.allowed_peers,
perms_shorthand=args.perms,
)
rnsconfig = args.rnsconfig
reticulum = RNS.Reticulum(rnsconfig)
identity = load_or_create_identity(
str(identity_name),
os.path.join(config_dir, "identities"),
)
service = FileSyncService(
identity=identity,
sync_directory=directory,
reticulum=reticulum,
configpath=rnsconfig,
permissions=permissions,
own_reticulum=True,
)
dest = service.start(
monitor=not args.no_monitor,
announce_interval=announce_interval,
)
print(f"config={config_dir}")
print(f"identity={service.get_status()['identity_hash']}")
print(f"destination={dest}")
print(f"directory={directory}")
if permissions.enabled:
print("acl=enforced (deny by default)")
else:
print("acl=open (no rules configured)")
if peers:
time.sleep(1.0)
for peer in peers:
result = service.connect_peer(peer)
print(f"connect {peer}: {result}")
try:
if args.no_repl:
while True:
time.sleep(1.0)
else:
run_repl(service)
finally:
service.stop()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,116 @@
"""Config loading for RNS FileSync (rngit-style ConfigObj)."""
from __future__ import annotations
import os
from typing import Any
from RNS.vendor.configobj import ConfigObj
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.rns_filesync")
DEFAULT_CONFIG = """# RNS FileSync config
# Similar layout to rngit (~/.rngit/config).
[filesync]
# Automatic announce interval in seconds.
announce_interval = 300
# Optional default sync directory (overridden by -d).
# directory = ~/shared
# Identity name stored under this config dir.
identity = rns_filesync
# Comma-separated peer identity hashes to connect on start.
# peers = 9710b86ba12c42d1d8f30f74fe509286
# Deny these identity hashes even if access rules would allow them.
# blocked_identities = d31aeea49873006f13b3415520666a4e
[aliases]
# alice = d09285e660cfe27cee6d9a0beb58b7e0
[access]
# Access rules for the sync directory (comma-separated).
# Format matches rngit: permission:target
#
# Permissions:
# r = read
# w = write
# d = delete
# rw = read/write
# rwd = read/write/delete
# adm = admin (all permissions)
#
# Targets: identity hash, alias name, all, or none.
#
# By default there are no permissions: peers cannot sync
# until you add rules here and/or a sidecar .allowed file.
#
# sync = r:all, w:9710b86ba12c42d1d8f30f74fe509286, d:9710b86ba12c42d1d8f30f74fe509286
[logging]
loglevel = 4
"""
def resolve_config_dir(config_dir: str | None = None) -> str:
if config_dir:
return os.path.realpath(os.path.expanduser(config_dir))
if os.path.isfile("/etc/rns_filesync/config"):
return "/etc/rns_filesync"
return DEFAULT_CONFIG_DIR
def ensure_config(config_dir: str | None = None) -> str:
"""Ensure config directory and default config file exist. Return config dir."""
path = resolve_config_dir(config_dir)
os.makedirs(path, exist_ok=True)
os.makedirs(os.path.join(path, "identities"), exist_ok=True)
config_path = os.path.join(path, "config")
if not os.path.isfile(config_path):
with open(config_path, "w", encoding="utf-8") as handle:
handle.write(DEFAULT_CONFIG)
return path
def load_config(config_dir: str | None = None) -> tuple[str, ConfigObj]:
"""Load ConfigObj from config dir, creating defaults if needed."""
path = ensure_config(config_dir)
config_path = os.path.join(path, "config")
config = ConfigObj(config_path)
return path, config
def config_get(config: ConfigObj, section: str, key: str, default: Any = None) -> Any:
try:
section_obj = config.get(section) or {}
if key in section_obj and section_obj[key] not in (None, ""):
return section_obj[key]
except Exception:
pass
return default
def parse_csv_hashes(value: str | list | None) -> list[str]:
if value is None:
return []
if isinstance(value, list):
parts = value
else:
parts = str(value).split(",")
return [p.strip().lower().replace(":", "") for p in parts if p and str(p).strip()]
def allowed_sidecar_paths(sync_directory: str) -> list[str]:
"""Candidate .allowed paths for a sync directory (rngit-style sidecars)."""
sync_directory = os.path.realpath(os.path.expanduser(sync_directory))
parent = os.path.dirname(sync_directory.rstrip(os.sep))
base = os.path.basename(sync_directory.rstrip(os.sep))
return [
os.path.join(sync_directory, ".allowed"),
os.path.join(parent, f"{base}.allowed"),
sync_directory.rstrip(os.sep) + ".allowed",
]

View file

@ -0,0 +1,15 @@
"""Shared constants for RNS FileSync."""
APP_NAME = "rns_filesync"
ASPECT = "filesync"
BLOCK_SIZE = 4096
HASH_CHUNK_SIZE = 65536
SCAN_INTERVAL = 5.0
ANNOUNCE_INTERVAL_DEFAULT = 300
PATH_TIMEOUT_DEFAULT = 30.0
LINK_TIMEOUT_DEFAULT = 15.0
RECONNECT_BASE_INTERVAL = 5.0
RECONNECT_MAX_INTERVAL = 60.0
HASH_DB_NAME = ".rns-filesync.db"
VALID_PERMISSIONS = frozenset({"read", "write", "delete"})
EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

View file

@ -0,0 +1,197 @@
"""Local file inventory, hashing, and persistence."""
from __future__ import annotations
import hashlib
import json
import os
import threading
from typing import Any
from rns_filesync.constants import BLOCK_SIZE, HASH_CHUNK_SIZE, HASH_DB_NAME
from rns_filesync.paths import PathJailError, normalize_relpath, relative_to_root
def hash_file(filepath: str) -> str | None:
"""Return SHA-256 hex digest of a file, or None on error."""
hasher = hashlib.sha256()
try:
with open(filepath, "rb") as handle:
while chunk := handle.read(HASH_CHUNK_SIZE):
hasher.update(chunk)
return hasher.hexdigest()
except OSError:
return None
def hash_blocks(filepath: str, block_size: int = BLOCK_SIZE) -> list[dict[str, Any]]:
"""Return per-block SHA-256 hashes for delta sync."""
blocks: list[dict[str, Any]] = []
try:
with open(filepath, "rb") as handle:
block_num = 0
while block := handle.read(block_size):
blocks.append(
{
"num": block_num,
"hash": hashlib.sha256(block).hexdigest(),
"size": len(block),
},
)
block_num += 1
except OSError:
return []
return blocks
def differing_block_nums(
local_blocks: list[dict[str, Any]],
peer_block_hashes: list[str],
) -> list[int]:
"""Return block numbers present locally that the peer does not have."""
peer_set = set(peer_block_hashes)
return [b["num"] for b in local_blocks if b["hash"] not in peer_set]
def decide_sync_action(
local_info: dict[str, Any] | None,
peer_info: dict[str, Any] | None,
) -> str:
"""Decide sync action for one path.
Returns one of: skip, request_full, request_delta, ignore.
"""
if peer_info is None:
return "ignore"
if local_info is None:
return "request_full"
if local_info.get("hash") == peer_info.get("hash"):
return "skip"
if local_info.get("size", 0) > 0:
return "request_delta"
return "request_full"
class Inventory:
"""Tracks file hashes under a sync directory."""
def __init__(self, sync_directory: str):
self.sync_directory = os.path.realpath(sync_directory)
self._lock = threading.RLock()
self._files: dict[str, dict[str, Any]] = {}
def load(self) -> int:
db_path = os.path.join(self.sync_directory, HASH_DB_NAME)
if not os.path.isfile(db_path):
with self._lock:
self._files = {}
return 0
try:
with open(db_path, encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
data = {}
except (OSError, json.JSONDecodeError):
data = {}
with self._lock:
self._files = data
return len(data)
def save(self) -> None:
db_path = os.path.join(self.sync_directory, HASH_DB_NAME)
tmp_path = db_path + ".tmp"
with self._lock:
payload = dict(self._files)
with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
os.replace(tmp_path, db_path)
def get(self, relpath: str) -> dict[str, Any] | None:
with self._lock:
info = self._files.get(relpath)
return dict(info) if info else None
def snapshot(self) -> dict[str, dict[str, Any]]:
with self._lock:
return {k: dict(v) for k, v in self._files.items()}
def set_file(self, relpath: str, info: dict[str, Any]) -> None:
with self._lock:
self._files[relpath] = dict(info)
def remove_file(self, relpath: str) -> None:
with self._lock:
self._files.pop(relpath, None)
def _hash_if_needed(
self,
abspath: str,
relpath: str,
size: int,
mtime: float,
) -> dict[str, Any] | None:
with self._lock:
cached = self._files.get(relpath)
if (
cached
and cached.get("size") == size
and abs(float(cached.get("mtime", 0)) - mtime) < 1e-6
and cached.get("hash")
):
return {
"hash": cached["hash"],
"size": size,
"mtime": mtime,
}
digest = hash_file(abspath)
if not digest:
return None
return {"hash": digest, "size": size, "mtime": mtime}
def scan(self) -> dict[str, dict[str, Any]]:
"""Scan sync directory and return current file map."""
found: dict[str, dict[str, Any]] = {}
for root, _dirs, files in os.walk(self.sync_directory):
for filename in files:
if filename == HASH_DB_NAME or filename.startswith(".rns-filesync"):
continue
abspath = os.path.join(root, filename)
try:
relpath = relative_to_root(self.sync_directory, abspath)
except PathJailError:
continue
try:
stat = os.stat(abspath)
except OSError:
continue
info = self._hash_if_needed(
abspath,
relpath,
stat.st_size,
stat.st_mtime,
)
if info:
found[relpath] = info
with self._lock:
self._files = found
return self.snapshot()
def update_from_path(self, relpath: str) -> dict[str, Any] | None:
try:
safe = normalize_relpath(relpath)
abspath = os.path.join(self.sync_directory, safe)
if not os.path.isfile(abspath):
self.remove_file(safe)
return None
stat = os.stat(abspath)
info = {
"hash": hash_file(abspath),
"size": stat.st_size,
"mtime": stat.st_mtime,
}
if not info["hash"]:
return None
self.set_file(safe, info)
return info
except (OSError, PathJailError):
return None

View file

@ -0,0 +1,70 @@
"""Path jail helpers for sync directory confinement."""
from __future__ import annotations
import os
class PathJailError(ValueError):
"""Raised when a path escapes the sync root."""
def normalize_relpath(relpath: str) -> str:
"""Normalize a relative path for protocol use.
Raises:
PathJailError: If the path is empty, absolute, or contains null bytes.
"""
if not isinstance(relpath, str) or not relpath or "\x00" in relpath:
raise PathJailError("invalid relative path")
# Reject Windows drive / UNC style paths even on POSIX.
if relpath.startswith(("/", "\\")) or os.path.isabs(relpath):
raise PathJailError("absolute paths are not allowed")
if len(relpath) >= 2 and relpath[1] == ":":
raise PathJailError("absolute paths are not allowed")
if relpath.startswith("\\\\") or relpath.startswith("//"):
raise PathJailError("absolute paths are not allowed")
cleaned = os.path.normpath(relpath.replace("\\", "/"))
if cleaned in (".", ""):
raise PathJailError("empty relative path")
parts = cleaned.split("/")
if any(part in ("", "..") for part in parts):
raise PathJailError("path escape attempt")
if cleaned.startswith("../") or cleaned == "..":
raise PathJailError("path escape attempt")
# Disallow hidden protocol/control filenames under sync root.
if any(
part == ".rns-filesync.db" or part.startswith(".rns-xfer-") for part in parts
):
raise PathJailError("reserved path")
return cleaned
def resolve_under_root(root: str, relpath: str) -> str:
"""Resolve relpath under root and ensure it stays jailed.
Returns:
Absolute real path under root.
Raises:
PathJailError: If resolution escapes root.
"""
root_real = os.path.realpath(root)
safe_rel = normalize_relpath(relpath)
candidate = os.path.realpath(os.path.join(root_real, safe_rel))
if candidate != root_real and not candidate.startswith(root_real + os.sep):
raise PathJailError("path escapes sync root")
return candidate
def relative_to_root(root: str, abspath: str) -> str:
"""Return a jailed relative path from an absolute path under root."""
root_real = os.path.realpath(root)
path_real = os.path.realpath(abspath)
if path_real != root_real and not path_real.startswith(root_real + os.sep):
raise PathJailError("path escapes sync root")
rel = os.path.relpath(path_real, root_real)
return normalize_relpath(rel)

View file

@ -0,0 +1,149 @@
"""Peer link lifecycle helpers."""
from __future__ import annotations
import time
from collections.abc import Callable
import RNS
from rns_filesync.constants import (
APP_NAME,
ASPECT,
LINK_TIMEOUT_DEFAULT,
PATH_TIMEOUT_DEFAULT,
)
def parse_hash(value: str | bytes) -> bytes:
if isinstance(value, bytes):
if len(value) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
return value
raise ValueError("invalid identity/destination hash length")
cleaned = value.lower().replace(":", "").strip()
raw = bytes.fromhex(cleaned)
expected = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
if len(raw) != expected:
raise ValueError(f"hash must be {expected} bytes")
return raw
def hex_hash(value: bytes) -> str:
return RNS.hexrep(value, delimit=False)
def resolve_peer_identity(peer_hash: bytes):
"""Resolve an identity from identity hash or destination hash."""
identity = RNS.Identity.recall(peer_hash, from_identity_hash=True)
if identity is not None:
return identity, "identity"
identity = RNS.Identity.recall(peer_hash, from_identity_hash=False)
if identity is not None:
return identity, "destination"
# Same-process local destinations may not yet be in known_destinations.
for destination in list(RNS.Transport.destinations):
try:
if destination.identity is None:
continue
if destination.identity.hash == peer_hash:
identity = RNS.Identity(create_keys=False)
identity.load_public_key(destination.identity.get_public_key())
return identity, "local"
if destination.hash == peer_hash:
identity = RNS.Identity(create_keys=False)
identity.load_public_key(destination.identity.get_public_key())
return identity, "local_destination"
except Exception:
continue
return None, None
def is_local_destination(destination_hash: bytes) -> bool:
"""Return True when the destination is registered on this Reticulum instance."""
for destination in list(RNS.Transport.destinations):
try:
if destination.hash == destination_hash:
return True
except Exception:
continue
destinations_map = getattr(RNS.Transport, "destinations_map", None)
if destinations_map is not None and destination_hash in destinations_map:
return True
return False
def wait_for_path(
destination_hash: bytes,
timeout: float = PATH_TIMEOUT_DEFAULT,
) -> bool:
if RNS.Transport.has_path(destination_hash) or is_local_destination(
destination_hash,
):
return True
RNS.Transport.request_path(destination_hash)
deadline = time.time() + timeout
while time.time() < deadline:
if RNS.Transport.has_path(destination_hash) or is_local_destination(
destination_hash,
):
return True
time.sleep(0.2)
return RNS.Transport.has_path(destination_hash) or is_local_destination(
destination_hash,
)
def create_outbound_destination(peer_identity):
return RNS.Destination(
peer_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
APP_NAME,
ASPECT,
)
def establish_link(
destination,
*,
established_callback: Callable | None = None,
closed_callback: Callable | None = None,
timeout: float = LINK_TIMEOUT_DEFAULT,
):
link = RNS.Link(
destination,
established_callback=established_callback,
closed_callback=closed_callback,
)
deadline = time.time() + timeout
while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED):
if time.time() > deadline:
try:
link.teardown()
except Exception:
pass
return None
time.sleep(0.05)
if link.status != RNS.Link.ACTIVE:
return None
return link
def peer_id_from_link(link) -> str | None:
try:
remote = link.get_remote_identity()
if remote is not None:
return hex_hash(remote.hash)
except Exception:
pass
return None
def destination_id_from_link(link) -> str | None:
try:
return hex_hash(link.destination.hash)
except Exception:
return None

View file

@ -0,0 +1,261 @@
"""rngit-style access control for FileSync.
Rules use permission:target (for example r:all, w:<hash>).
When ACL enforcement is active, access is denied by default.
"""
from __future__ import annotations
import os
import threading
from collections.abc import Iterable
from rns_filesync.constants import VALID_PERMISSIONS
TGT_ALL = "all"
TGT_NONE = "none"
PERM_MAP = {
"r": ("read",),
"read": ("read",),
"w": ("write",),
"write": ("write",),
"d": ("delete",),
"delete": ("delete",),
"rw": ("read", "write"),
"readwrite": ("read", "write"),
"rwd": ("read", "write", "delete"),
"adm": ("read", "write", "delete"),
"admin": ("read", "write", "delete"),
}
TGT_ALL_ALIASES = frozenset({"all", "a", "everyone", "*"})
TGT_NONE_ALIASES = frozenset({"none", "n", "nobody"})
def _normalize_hash(value: bytes | str) -> str:
if isinstance(value, bytes):
return value.hex()
return value.lower().replace(":", "").strip()
class PermissionStore:
"""Thread-safe ACL using rngit-style permission:target rules."""
def __init__(
self,
entries: dict[str, list[str]] | None = None,
*,
enforce: bool | None = None,
):
self._lock = threading.RLock()
self._aliases: dict[str, str] = {}
self._blocked: set[str] = set()
# permission -> set of targets (hash hex, ALL, NONE)
self._rules: dict[str, set[str]] = {
"read": set(),
"write": set(),
"delete": set(),
}
self._admin: set[str] = set()
self._enforce = False if enforce is None else bool(enforce)
if entries:
for identity_hash, perms in entries.items():
self.grant(identity_hash, perms)
if enforce is None:
self._enforce = True
@property
def enabled(self) -> bool:
"""True when ACL enforcement is active (deny by default)."""
with self._lock:
return self._enforce
def set_alias(self, name: str, identity_hash: str) -> None:
with self._lock:
self._aliases[name.strip().lower()] = _normalize_hash(identity_hash)
def set_aliases(self, aliases: dict[str, str]) -> None:
for name, identity_hash in aliases.items():
self.set_alias(str(name), str(identity_hash))
def block(self, identity_hash: str | bytes) -> None:
with self._lock:
self._blocked.add(_normalize_hash(identity_hash))
def set_blocked(self, hashes: Iterable[str | bytes]) -> None:
for value in hashes:
self.block(value)
def _resolve_target(self, target: str) -> str | None:
target = target.strip()
if not target:
return None
lower = target.lower()
if lower in self._aliases:
return self._aliases[lower]
if lower in TGT_ALL_ALIASES:
return TGT_ALL
if lower in TGT_NONE_ALIASES:
return TGT_NONE
cleaned = _normalize_hash(target)
if len(cleaned) == 32:
try:
bytes.fromhex(cleaned)
except ValueError:
return None
return cleaned
return None
def parse_rule(self, rule: str) -> tuple[tuple[str, ...], str] | None:
"""Parse perm:target into permission names and resolved target."""
rule = rule.strip()
if not rule or rule.startswith("#"):
return None
if ":" not in rule:
return None
perm_s, target_s = rule.split(":", 1)
perms = PERM_MAP.get(perm_s.strip().lower())
if not perms:
return None
with self._lock:
target = self._resolve_target(target_s)
if target is None:
return None
return perms, target
def add_rule(self, rule: str) -> bool:
parsed = self.parse_rule(rule)
if not parsed:
return False
perms, target = parsed
perm_key = rule.split(":", 1)[0].strip().lower()
with self._lock:
self._enforce = True
if perm_key in ("adm", "admin"):
self._admin.add(target)
for perm in perms:
self._rules[perm].add(target)
return True
def add_rules(self, rules: Iterable[str]) -> int:
loaded = 0
for rule in rules:
if self.add_rule(rule):
loaded += 1
return loaded
def grant(self, identity_hash: str, perms: Iterable[str]) -> list[str]:
"""Legacy helper: grant named permissions to one identity hash."""
valid = []
for perm in perms:
p = str(perm).strip().lower()
if p in VALID_PERMISSIONS and p not in valid:
valid.append(p)
if not valid:
return []
key = _normalize_hash(identity_hash)
if key in TGT_ALL_ALIASES or key == "*":
key = TGT_ALL
with self._lock:
self._enforce = True
for perm in valid:
self._rules[perm].add(key)
return valid
def set_permissions(self, identity_hash: str, perms: Iterable[str]) -> list[str]:
return self.grant(identity_hash, perms)
def load_allowed_text(self, text: str) -> int:
loaded = 0
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if ":" in line:
for part in [p.strip() for p in line.split(",") if p.strip()]:
if self.add_rule(part):
loaded += 1
elif self._load_legacy_line(line):
loaded += 1
return loaded
def _load_legacy_line(self, line: str) -> bool:
parts = line.split()
if len(parts) < 2:
return False
identity_hash = parts[0]
perms = [p.strip() for p in parts[1].split(",")]
return bool(self.grant(identity_hash, perms))
def load_file(self, path: str) -> int:
if not os.path.isfile(path):
return 0
if os.access(path, os.X_OK):
# Executable .allowed: run and parse stdout (rngit behavior).
import subprocess
try:
result = subprocess.run(
[path],
capture_output=True,
text=True,
check=False,
timeout=30,
)
return self.load_allowed_text(result.stdout or "")
except Exception:
return 0
with open(path, encoding="utf-8") as handle:
return self.load_allowed_text(handle.read())
def load_access_value(self, value: str | list | None) -> int:
if value is None:
return 0
if isinstance(value, list):
text = ", ".join(str(v) for v in value)
else:
text = str(value)
return self.load_allowed_text(text)
def check(self, identity_hash: bytes | str, permission: str) -> bool:
permission = permission.lower()
if permission not in VALID_PERMISSIONS:
return False
key = _normalize_hash(identity_hash)
with self._lock:
if key in self._blocked:
return False
if not self._enforce:
return True
targets = self._rules.get(permission, set())
if TGT_NONE in targets:
return False
if TGT_ALL in targets or key in targets:
return True
if TGT_ALL in self._admin or key in self._admin:
return True
return False
def can_connect(self, identity_hash: bytes | str) -> bool:
key = _normalize_hash(identity_hash)
with self._lock:
if key in self._blocked:
return False
if not self._enforce:
return True
if TGT_ALL in self._admin or key in self._admin:
return True
for targets in self._rules.values():
if TGT_NONE in targets:
continue
if TGT_ALL in targets or key in targets:
return True
return False
def get(self, identity_hash: bytes | str) -> list[str]:
return [p for p in ("read", "write", "delete") if self.check(identity_hash, p)]
def as_dict(self) -> dict[str, list[str]]:
with self._lock:
return {perm: sorted(targets) for perm, targets in self._rules.items()}

View file

@ -0,0 +1,108 @@
"""Control-plane message encode and decode."""
from __future__ import annotations
from typing import Any
from RNS.vendor import umsgpack
MSG_FILE_LIST = "file_list"
MSG_FILE_LIST_REQUEST = "file_list_request"
MSG_FILE_REQUEST = "file_request"
MSG_DELTA_REQUEST = "delta_request"
MSG_EMPTY_FILE = "empty_file"
MSG_FILE_UPDATE = "file_update"
MSG_FILE_DELETION = "file_deletion"
KNOWN_TYPES = frozenset(
{
MSG_FILE_LIST,
MSG_FILE_LIST_REQUEST,
MSG_FILE_REQUEST,
MSG_DELTA_REQUEST,
MSG_EMPTY_FILE,
MSG_FILE_UPDATE,
MSG_FILE_DELETION,
},
)
class ProtocolError(ValueError):
"""Raised for invalid protocol messages."""
def encode_message(payload: dict[str, Any]) -> bytes:
if not isinstance(payload, dict) or "type" not in payload:
raise ProtocolError("message must be a dict with type")
return umsgpack.packb(payload)
def decode_message(raw: bytes) -> dict[str, Any]:
data = umsgpack.unpackb(raw)
if not isinstance(data, dict):
raise ProtocolError("message must unpack to dict")
msg_type = data.get("type")
if not isinstance(msg_type, str) or msg_type not in KNOWN_TYPES:
raise ProtocolError(f"unknown message type: {msg_type!r}")
return data
def make_file_list(files: dict[str, dict[str, Any]], browser: bool = False) -> bytes:
return encode_message(
{
"type": MSG_FILE_LIST,
"files": files,
"browser": bool(browser),
},
)
def make_file_list_request(browser: bool = False) -> bytes:
return encode_message({"type": MSG_FILE_LIST_REQUEST, "browser": bool(browser)})
def make_file_request(path: str) -> bytes:
return encode_message({"type": MSG_FILE_REQUEST, "path": path})
def make_delta_request(path: str, local_blocks: list[str]) -> bytes:
return encode_message(
{
"type": MSG_DELTA_REQUEST,
"path": path,
"local_blocks": list(local_blocks),
},
)
def make_empty_file(path: str, file_hash: str | None) -> bytes:
return encode_message(
{
"type": MSG_EMPTY_FILE,
"path": path,
"hash": file_hash,
},
)
def make_file_update(path: str, info: dict[str, Any]) -> bytes:
return encode_message(
{
"type": MSG_FILE_UPDATE,
"path": path,
"info": info,
},
)
def make_file_deletion(path: str) -> bytes:
return encode_message({"type": MSG_FILE_DELETION, "path": path})
def decode_metadata_value(value: Any) -> Any:
if isinstance(value, bytes):
try:
return value.decode("utf-8")
except UnicodeDecodeError:
return value.hex()
return value

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more