diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-01 09:46:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-01 09:46:39 +0200 |
| commit | 0d0898c656afb8c1faa9fa91ba525e8a3e6a34ee (patch) | |
| tree | a74f7d7e651b981f84421f38f53d88084a544138 /packages/meshbay-node/src | |
| parent | 5b0cd92012bb162f6290fbfb97938f41cad81b7a (diff) | |
| download | meshbay-0d0898c656afb8c1faa9fa91ba525e8a3e6a34ee.tar.gz | |
fix(node): how a hosted group admits people is the operator's, not the hub's
attach_group no longer copies join_policy and visibility from the hub's
answer: they come with the operator's request (the desktop creation form,
`group add --open`) and default to invite/private; the CLI says when the hub
lists the group otherwise. Every string written into node.toml is escaped
(toml_string) and read back through tomllib, so a group or folder name cannot
write lines of its own (F-17).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
6 files changed, 90 insertions, 27 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/groups.py b/packages/meshbay-node/src/meshbay_node/cli/groups.py index 3fab4b9..fca4800 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/groups.py +++ b/packages/meshbay-node/src/meshbay_node/cli/groups.py @@ -64,7 +64,7 @@ def group(args) -> None: sys.exit(1) if not args.target or not args.dir: print("usage: meshbay-node group add <name> --dir <path> " - "[--no-writable]") + "[--no-writable] [--open]") print() print("The group must already exist on the hub and be yours. This") print("only tells the node to host it, and picks its first") @@ -78,12 +78,19 @@ def group(args) -> None: # is not a working group. Every root added *later* is read-only by # default, which is the opposite rule and the right one there. writable = args.writable is not False + # How people join is the operator's to say here, never read from the hub. + join_policy = "open" if getattr(args, "open", False) else "invite" body = {"name": args.target, "shared_dir": args.dir, - "writable": writable} + "writable": writable, "join_policy": join_policy} out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body) print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") print(f" shared_dir {out['shared_dir']}" f" ({'read-write' if writable else 'read-only'})") + print(f" join_policy {join_policy}") + if out.get("hub_join_policy") == "open" and join_policy != "open": + print() + print("The hub lists this group as open; this node admits by invitation") + print("only. To host it open, remove it and add it again with --open.") print() print("Tell the daemon to re-read its config, then give the group a key:") print(" meshbay-node reload") diff --git a/packages/meshbay-node/src/meshbay_node/cli/parser.py b/packages/meshbay-node/src/meshbay_node/cli/parser.py index 29a9f63..cfc4704 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/parser.py +++ b/packages/meshbay-node/src/meshbay_node/cli/parser.py @@ -87,6 +87,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--no-removable", action="store_false", dest="removable", help="mark root as not removable (root set)") + parser.add_argument("--open", action="store_true", + help="group add: anyone the hub lists the group to may join " + "(default: by invitation only)") parser.add_argument("--name", default=None, help="root name (root add; defaults to directory basename)") parser.add_argument("--log-level", default="INFO", diff --git a/packages/meshbay-node/src/meshbay_node/ops/groups.py b/packages/meshbay-node/src/meshbay_node/ops/groups.py index 1d8003c..ac14c3a 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/groups.py +++ b/packages/meshbay-node/src/meshbay_node/ops/groups.py @@ -9,7 +9,7 @@ from meshbay_common.crypto import generate_gek, wrap_gek_aes from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_node.ops.core import OpError, _config, _group_ctx, _hub -from meshbay_node.ops.node_toml import _find_group_range +from meshbay_node.ops.node_toml import _find_group_range, toml_string log = logging.getLogger("meshbay_node.ops") @@ -142,17 +142,28 @@ async def list_groups(state: dict) -> dict: return {"groups": out, "operator_paired": has_operator, "settings": settings} +JOIN_POLICIES = ("invite", "open") + + async def attach_group(state: dict, name: str, shared_dir: str, - writable: bool = True) -> dict: + writable: bool = True, join_policy: str = "invite") -> dict: """ Write a new [[groups]] block into node.toml. The name-to-id lookup happens here because this process is the one logged into the hub. Nothing is created on the hub: the group already exists, this only tells the node to host it. + + `join_policy` is the operator's, given with this request, and `invite` + unless they say otherwise. The hub's own record of the group is not read + for it: a hub that could declare a group open would be handed its key by + anyone it sent. The hub's value is returned beside it, so a caller can say + when the two differ. """ if not name or not shared_dir: raise OpError("name and shared_dir are required") + if join_policy not in JOIN_POLICIES: + raise OpError(f"join_policy must be one of {', '.join(JOIN_POLICIES)}") config = _config(state) hub = _hub(state) try: @@ -182,12 +193,12 @@ async def attach_group(state: dict, name: str, shared_dir: str, raise OpError(f"Cannot create {path}: {e}") from e conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - join_policy = group.get("join_policy", "invite") + visibility = "public" if join_policy == "open" else "private" block = (f'\n[[groups]]\n' - f'id = "{group["id"]}"\n' - f'name = "{group["name"]}"\n' - f'visibility = "{group.get("visibility", "private")}"\n' - f'join_policy = "{join_policy}"\n') + f'id = {toml_string(group["id"])}\n' + f'name = {toml_string(group["name"])}\n' + f'visibility = {toml_string(visibility)}\n' + f'join_policy = {toml_string(join_policy)}\n') # No `upload_dir` here. `GroupConfig.__post_init__` still *reads* it, so an # existing node.toml keeps working — but what it does on read is force every # other root read-only and append that path as the one writable one, which @@ -198,7 +209,7 @@ async def attach_group(state: dict, name: str, shared_dir: str, block += (f'\n [[groups.roots]]\n' # Forward slashes: a Windows path in a TOML basic string is a # parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`. - f' path = "{path.as_posix()}"\n' + f' path = {toml_string(path.as_posix())}\n' f' writable = {"true" if writable else "false"}\n') try: with conf_path.open("a", encoding="utf-8", newline="\n") as f: @@ -208,7 +219,8 @@ async def attach_group(state: dict, name: str, shared_dir: str, result = {"group_id": group["id"], "name": group["name"], "shared_dir": str(path), "config": str(conf_path), - "writable": writable, + "writable": writable, "join_policy": join_policy, + "hub_join_policy": group.get("join_policy", "invite"), "note": "restart the node to pick it up"} return result diff --git a/packages/meshbay-node/src/meshbay_node/ops/node_toml.py b/packages/meshbay-node/src/meshbay_node/ops/node_toml.py index f711f26..2407722 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/node_toml.py +++ b/packages/meshbay-node/src/meshbay_node/ops/node_toml.py @@ -3,14 +3,50 @@ from __future__ import annotations import re +import tomllib from pathlib import Path from meshbay_node.ops.core import OpError +def toml_string(value: str) -> str: + """A TOML basic string holding `value` exactly, quotes included. + + Every string written into node.toml goes through here. A value with a quote + or a newline in it — a group name, a folder name, any of them chosen by + someone else — would otherwise end the string and write lines of its own. + """ + out = ['"'] + for ch in str(value): + if ch == '"': + out.append('\\"') + elif ch == "\\": + out.append("\\\\") + elif ord(ch) < 0x20 or ord(ch) == 0x7F: + out.append(f"\\u{ord(ch):04x}") + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def _string_value(line: str, key: str) -> str | None: + """The string `key` holds on this line, unescaped — or None. + + Read as TOML, not by pattern: a value written by `toml_string` may carry an + escaped quote or backslash, which a `"([^"]*)"` pattern would cut short. + """ + if not re.match(r"^\s*" + re.escape(key) + r"\s*=", line): + return None + try: + value = tomllib.loads(line.strip()).get(key) + except tomllib.TOMLDecodeError: + return None + return value if isinstance(value, str) else None + + def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None: """Line range of a [[groups]] block by id: (start, end_exclusive).""" - id_re = re.compile(r'^\s*id\s*=\s*"([^"]*)"') block_starts: list[int] = [] for i, line in enumerate(lines): if line.strip() == "[[groups]]": @@ -24,8 +60,7 @@ def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None boundary = k break for k in range(start + 1, boundary): - m = id_re.match(lines[k]) - if m and m.group(1) == group_id: + if _string_value(lines[k], "id") == group_id: return (start, boundary) return None @@ -61,8 +96,10 @@ def _update_node_toml(conf_path: Path, updates: dict) -> None: if isinstance(value, bool): return f"{key} = {'true' if value else 'false'}" if isinstance(value, list): - items = ", ".join(f'"{v}"' for v in value) + items = ", ".join(toml_string(v) for v in value) return f"{key} = [{items}]" + if isinstance(value, str): + return f"{key} = {toml_string(value)}" return f"{key} = {value}" remaining = dict(updates) @@ -115,7 +152,6 @@ def _remove_roots_block(conf_path: Path, group_id: str, raise OpError(f"Group {group_id[:8]} not found in {conf_path}") start, end = rng - path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"') roots_starts: list[int] = [] for i in range(start + 1, end): if lines[i].strip() == "[[groups.roots]]": @@ -124,10 +160,10 @@ def _remove_roots_block(conf_path: Path, group_id: str, for j, rs in enumerate(roots_starts): rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end for k in range(rs, rs_end): - m = path_re.match(lines[k]) - if m: + raw = _string_value(lines[k], "path") + if raw is not None: try: - p = str(Path(m.group(1)).expanduser().resolve()) + p = str(Path(raw).expanduser().resolve()) except OSError: continue if p == resolved_path: @@ -153,7 +189,6 @@ def _update_root_field(conf_path: Path, group_id: str, raise OpError(f"Group {group_id[:8]} not found in {conf_path}") start, end = rng - path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"') writable_re = re.compile(r'^\s*(writable|upload)\s*=') removable_re = re.compile(r'^\s*removable\s*=') roots_starts: list[int] = [] @@ -165,10 +200,10 @@ def _update_root_field(conf_path: Path, group_id: str, rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end found_path = False for k in range(rs, rs_end): - m = path_re.match(lines[k]) - if m: + raw = _string_value(lines[k], "path") + if raw is not None: try: - p = str(Path(m.group(1)).expanduser().resolve()) + p = str(Path(raw).expanduser().resolve()) except OSError: continue if p == resolved_path: diff --git a/packages/meshbay-node/src/meshbay_node/ops/roots.py b/packages/meshbay-node/src/meshbay_node/ops/roots.py index e3e2781..480fe63 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/roots.py +++ b/packages/meshbay-node/src/meshbay_node/ops/roots.py @@ -8,7 +8,12 @@ from pathlib import Path from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_node.ops.core import OpError, _config, _group_ctx, _roster -from meshbay_node.ops.node_toml import _insert_roots_block, _remove_roots_block, _update_root_field +from meshbay_node.ops.node_toml import ( + _insert_roots_block, + _remove_roots_block, + _update_root_field, + toml_string, +) from meshbay_node.roots import RootError, RootSet, off_disk log = logging.getLogger("meshbay_node.ops") @@ -46,11 +51,11 @@ async def add_root(state: dict, group_id: str, path: str, *, raise OpError(f"Cannot create {added.path}: {e}") from e conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - root_block = f' [[groups.roots]]\n path = "{added.path.as_posix()}"' + root_block = f' [[groups.roots]]\n path = {toml_string(added.path.as_posix())}' if name: - root_block += f'\n name = "{added.name}"' + root_block += f'\n name = {toml_string(added.name)}' if kind != "generic": - root_block += f'\n kind = "{added.kind}"' + root_block += f'\n kind = {toml_string(added.kind)}' if writable: root_block += '\n writable = true' if removable: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index bbc4649..f810903 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -179,6 +179,7 @@ def create_ui_app(state: dict) -> FastAPI: (payload.get("name") or "").strip(), (payload.get("shared_dir") or "").strip(), writable=bool(payload.get("writable", True)), + join_policy=str(payload.get("join_policy") or "invite"), )) reload_fn = state.get("reload_fn") if reload_fn: |