aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-13 15:40:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-13 15:40:34 +0200
commitd917bb61e42336c38782b22da604d7ca923d484a (patch)
tree3ad99072f02d621ca4a54f4fcce65a2c530c4b79 /packages/meshbay-node/src/meshbay_node/indexer/indexer.py
parentc5fff4ce8366b08669c0c8b6d30b99b94b9fefca (diff)
downloadmeshbay-d917bb61e42336c38782b22da604d7ca923d484a.tar.gz
fix(node): an uploaded file records who sent it
`_register_uploader` walked the index for the entry it had just written, at a moment when no such entry can exist: the file was a `.part` until the rename on the line above, which is not indexable, and the watchdog that will index it debounces for two seconds and then hashes. The walk matched nothing, silently, so every uploaded file in every group was owned by nobody — and `file_delete` refuses a caller with no admin authority when the entry records no uploader, so a member could not delete what they had just sent. MESHBAY_DESIGN.md §5.4 grants that to any non-revoked device of the uploading account. The record is now written when the last chunk lands (`indexer.record_upload`) and the entry is stamped from it in `_hash_or_cached`, the one funnel every entry passes through — initial scan, watchdog, reconcile and replug alike. It lives in the index cache rather than on the entry alone, because the index is rebuilt from disk at every start and an owner the node forgets on restart is a right quietly taken away. It is validated against a live `stat()`, so whatever later occupies that path inherits nothing; and `_rescan_root`'s carry-over no longer copies over it, or memory would beat the durable record. §5.4 also claimed ownership was *provable* — a transcript the uploader signs, stored with the entry. No such signature has ever existed; `meshbay:upload:v1` in the code is the groupbox purpose that seals the envelope. The section now states what the code does, and the transcript is an open item in §15.3. `test_upload_attribution.py` drives the real handler and a real indexer across that seam. Against the previous source its two positive cases fail on the property, not on a missing method — an upload, then a rebuild from disk, then a different file at the same path inheriting nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer/indexer.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py57
1 files changed, 54 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 33e7210..852c061 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -436,7 +436,7 @@ class DirectoryIndexer:
cached = await self._cache.lookup(
str(file_path), st.st_size, st.st_mtime, expected_hv)
if cached is not None:
- return IndexEntry(
+ return await self._attribute(IndexEntry(
id=cached.hash,
name=file_path.name,
path=_virtual_dir(root, file_path),
@@ -444,7 +444,7 @@ class DirectoryIndexer:
type=cached.type,
added_at=cached.added_at,
hash_version=cached.hash_version,
- )
+ ), file_path, st)
loop = asyncio.get_event_loop()
entry = await loop.run_in_executor(self._executor, _scan_file, root, file_path)
@@ -452,8 +452,51 @@ class DirectoryIndexer:
await self._cache.put(str(file_path), st.st_size, st.st_mtime,
entry.id, entry.type, entry.added_at,
entry.hash_version)
+ return await self._attribute(entry, file_path, st)
+
+ async def _attribute(self, entry: IndexEntry | None, file_path: Path,
+ st) -> IndexEntry | None:
+ """Stamp an entry with whoever sent the file, if a member did.
+
+ Here, rather than beside each `add_entry`, because this is the one
+ funnel every entry passes through: the initial scan, the watchdog,
+ reconcile and a replug all build theirs from `_hash_or_cached`.
+
+ The attribution used to be written at the end of the *upload* instead,
+ by walking the index for an entry that by construction did not exist
+ yet — the watchdog has not fired, and the `.part` the file was until the
+ rename is not indexable. It matched nothing, silently, so every uploaded
+ file was owned by nobody and `file_delete` refused everyone but the
+ operator, where MESHBAY_DESIGN.md §5.4 grants it to any non-revoked
+ device of the uploading account.
+ """
+ if entry is None or self._cache is None:
+ return entry
+ who = await self._cache.uploader(str(file_path), st.st_size, st.st_mtime)
+ if who is not None:
+ entry.uploader_id, entry.uploader_pk = who
return entry
+ async def record_upload(self, file_path: Path, user_id: str,
+ pk_ed25519: str) -> None:
+ """Remember who sent this file, for the entry that does not exist yet.
+
+ Called by the transport once the last chunk has landed and the file is
+ at its final name. Durable rather than in-memory: the index is rebuilt
+ from disk at every start, and an owner the node forgets on restart is an
+ owner who cannot delete their own file tomorrow.
+ """
+ if self._cache is None or not user_id:
+ return
+ try:
+ st = file_path.stat()
+ except OSError:
+ # Gone between the rename and here. Nothing to attribute, and
+ # nothing for anyone to delete either.
+ return
+ await self._cache.record_upload(
+ str(file_path), st.st_size, st.st_mtime, user_id, pk_ed25519 or "")
+
def _report_collisions(self) -> None:
"""
Names that are the same file on a case-insensitive filesystem.
@@ -749,7 +792,15 @@ class DirectoryIndexer:
if old is None:
continue
for field in self._ENRICHED_FIELDS:
- setattr(entry, field, getattr(old, field))
+ # What the rescan itself established wins. `uploader_id` and
+ # `uploader_pk` now come off the durable record (`_attribute`),
+ # and the entry being replaced is memory this process happens
+ # to still hold — so copying over them would let a stale blank
+ # beat the thing that survives a restart. Every other field is
+ # None on a freshly scanned entry, so for those this is exactly
+ # the carry-over it has always been.
+ if getattr(entry, field) is None:
+ setattr(entry, field, getattr(old, field))
# It came back intact, so it is not one of the entries the daemon
# needs to enrich again.
self.rescanned_ids.discard(entry.id)