The prose of each docstring and comment in the core package now follows ASD-STE100 Simplified Technical English, with Canadian spelling: short sentences, simple verb tenses, no dashes or semicolons, and one word for one concept (docs/development/writing-style.md has the rules and the glossary). The meaning and each reason stay. The code does not change: the AST of each file, without its docstrings, is the same as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
206 lines
9.1 KiB
Python
206 lines
9.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The store for the rooms that this machine joined: the password of each room, and our access.
|
|
|
|
A room server has two passwords. The *admin password* is the password that Repeater admin
|
|
already remembers (:mod:`meshterm.core.admin_store`), and it lets you into a room as its
|
|
admin. The *room password* is the password that the owner gives to the members, and this
|
|
store keeps it. To join a room is to log in with one of the two passwords. Thus a room is
|
|
"joined" here exactly when MeshTerm has a password with which it can log in. When the user
|
|
opens the room again, MeshTerm logs in with that password silently, and does not ask.
|
|
|
|
This store never writes the password of an admin login, only the access that the login
|
|
got. The admin password is in one place, where Repeater admin can change it. Thus MeshTerm
|
|
never logs in to a room with an old copy of it. If the owner of a room cleared the room
|
|
password, the room is open to all, and its remembered password is the empty string. That
|
|
is a real answer, not an absence. Thus :meth:`RoomStore.password` returns ``None`` for
|
|
"nothing remembered".
|
|
|
|
This store is global machine state in a small JSON file (``<config_dir>/rooms.json``), the
|
|
same as the admin passwords. MeshTerm writes the file with owner-only permissions, because
|
|
it holds passwords in plaintext. The file is read into :class:`RoomMembership` records and
|
|
written back from them. Thus a field that no record knows is removed after the next write
|
|
(refer to :data:`RoomMembership.RETIRED`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
from .admin_store import admin_key
|
|
from .atomicwrite import write_atomically
|
|
from .models import Contact, LoginResult, RoomAccess, RoomLogin, utcnow
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RoomMembership:
|
|
"""What MeshTerm remembers about one room that it joined.
|
|
|
|
Attributes:
|
|
key: The :func:`~meshterm.core.admin_store.admin_key` under which the room is
|
|
stored. It is the same key as for its admin password, so that the two stores
|
|
agree about which room is which. (It is the key of the record in the file, not
|
|
one of its fields.)
|
|
password: The room password with which MeshTerm got in. ``""`` for a blank login
|
|
that the room accepted (an open room, or a room that knew our node). ``None``
|
|
when no room password is known: MeshTerm got in as the admin of the room, and
|
|
the admin password is in the admin store.
|
|
access: The access that the room gave at our last login: a
|
|
:class:`~meshterm.core.models.RoomAccess` value.
|
|
label: The name of the room, to show.
|
|
last_login: The ISO-8601 timestamp of the last accepted login.
|
|
"""
|
|
|
|
key: str
|
|
password: str | None
|
|
access: str
|
|
label: str
|
|
last_login: str
|
|
|
|
#: The fields that this record held before. It must never hold them again with a
|
|
#: different meaning (refer to :data:`meshterm.core.preferences.RETIRED` for the
|
|
#: reason that a name is never used again).
|
|
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
|
|
|
@classmethod
|
|
def read(cls, key: str, raw: object) -> RoomMembership | None:
|
|
"""Build a record from one file entry, or ``None`` when MeshTerm cannot use it."""
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
password = raw.get("password")
|
|
access = raw.get("access")
|
|
if access not in {a.value for a in RoomAccess}:
|
|
return None
|
|
return cls(
|
|
key=key,
|
|
password=None if password is None else str(password),
|
|
access=str(access),
|
|
label=str(raw.get("label") or ""),
|
|
last_login=str(raw.get("last_login") or ""),
|
|
)
|
|
|
|
def to_json(self) -> dict:
|
|
"""The record as written: its own fields only, not the other fields of the read entry."""
|
|
return {
|
|
"password": self.password,
|
|
"access": self.access,
|
|
"label": self.label,
|
|
"last_login": self.last_login,
|
|
}
|
|
|
|
|
|
class RoomStore:
|
|
"""Reads and writes the rooms that this machine joined."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
"""Open the store on the location of a JSON file.
|
|
|
|
Args:
|
|
path: Path to the JSON state file (made only at the first join).
|
|
"""
|
|
self._path = path
|
|
|
|
def _load(self) -> dict[str, RoomMembership]:
|
|
"""Read all the memberships by room (empty if the file is missing or corrupt)."""
|
|
try:
|
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
records = {}
|
|
for key, raw in data.items():
|
|
record = RoomMembership.read(str(key), raw)
|
|
if record is not None:
|
|
records[record.key] = record
|
|
return records
|
|
|
|
def membership(self, room: Contact) -> RoomMembership | None:
|
|
"""What MeshTerm remembers about ``room``, or ``None`` if it never joined the room."""
|
|
return self._load().get(admin_key(room))
|
|
|
|
def password(self, room: Contact) -> str | None:
|
|
"""The room password for a login to ``room``, or ``None`` when none is remembered.
|
|
|
|
``""`` is a remembered password: the room is open. An admin membership remembers
|
|
no password here (its password is in the admin store).
|
|
"""
|
|
record = self.membership(room)
|
|
return record.password if record is not None else None
|
|
|
|
def access(self, room: Contact) -> RoomAccess | None:
|
|
"""The access from the last login to ``room``, or ``None`` if it was never joined."""
|
|
record = self.membership(room)
|
|
return RoomAccess(record.access) if record is not None else None
|
|
|
|
def record(self, room: Contact, password: str, login: RoomLogin) -> None:
|
|
"""Change what MeshTerm remembers about ``room``, from the result of a login to it.
|
|
|
|
For the room password, the function uses the credential policy of the admin store
|
|
(:meth:`~meshterm.core.admin_store.AdminStore.record`). The password is kept when
|
|
the room accepts it. It is forgotten when the node refuses it. It stays as it is
|
|
when no answer came back, because no answer is not a verdict. A room also gives no
|
|
answer to a wrong password. Thus a password that never worked is never written.
|
|
|
|
But "accepted" is not one single proof. A password is kept only for what it proved:
|
|
|
|
* **Admin** with a typed password: the admin store keeps the password (it is the
|
|
admin password of the room). This store keeps only the access, next to the room
|
|
password that it already remembered.
|
|
* **Member** with a typed password: that password is the room password.
|
|
* **Read-only** proves nothing about the password. A room that lets read-only
|
|
users in lets any password in. Thus it never replaces a password that is
|
|
already remembered, which can be the password of a member. It is kept only
|
|
where no password was remembered.
|
|
* **Blank** asks "do you know me?" and proves only that the room knows our node.
|
|
It also never replaces a remembered password. Else, if the room forgets a
|
|
member at its next restart, the member loses a password that works, and keeps
|
|
only a password that cannot work.
|
|
|
|
Args:
|
|
room: The room to which the login went.
|
|
password: The password that was tried.
|
|
login: How the login ended.
|
|
"""
|
|
records = self._load()
|
|
key = admin_key(room)
|
|
known = records.get(key)
|
|
if login.result is LoginResult.ACCEPTED and login.access is not None:
|
|
kept = known.password if known is not None else None
|
|
if login.access is RoomAccess.ADMIN and password:
|
|
remembered = kept
|
|
elif login.access is RoomAccess.MEMBER and password:
|
|
remembered = password
|
|
else:
|
|
remembered = kept if kept is not None else password
|
|
records[key] = RoomMembership(
|
|
key=key,
|
|
password=remembered,
|
|
access=login.access.value,
|
|
label=room.name,
|
|
last_login=utcnow().isoformat(),
|
|
)
|
|
self._write(records)
|
|
elif login.result is LoginResult.REFUSED and known is not None:
|
|
# Forget only the password that the node refused.
|
|
if known.password == password:
|
|
records.pop(key)
|
|
self._write(records)
|
|
|
|
def forget(self, room: Contact) -> None:
|
|
"""Remove all that MeshTerm remembers about ``room``.
|
|
|
|
Args:
|
|
room: The room to forget.
|
|
"""
|
|
records = self._load()
|
|
if records.pop(admin_key(room), None) is not None:
|
|
self._write(records)
|
|
|
|
def _write(self, records: dict[str, RoomMembership]) -> None:
|
|
"""Write ``records`` atomically, with owner-only permissions where the OS has them."""
|
|
data = {key: record.to_json() for key, record in records.items()}
|
|
write_atomically(self._path, json.dumps(data, indent=2), owner_only=True)
|