Peering: inbound delivery semantics — placement, re-encryption, quota/audit interplay #104
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#104
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What happens after #102 accepts a stream: where the file lands and how it enters the recipient's world. This is where peering must compose with everything we already built — the design goal is that a received file is indistinguishable from one the recipient uploaded themselves.
Proposal: write through the FULL decorated storage stack with a synthesized recipient context (
auth.User{Username: recipient}— custody providers only need the username), into/home/<recipient>/Inbox/<peerName>/<filename>(auto-created). That single decision buys, for free:peer-receivewith source peer + sender-declared origin),Name collisions: never overwrite — suffix
name (2).ext(like desktop conventions).Open questions:
perUserHomesoff — every user sees the whole tree):/home/<user>/Inboxdoesn't exist as a concept. Options: require perUserHomes for peering, or deliver to a shared/Inbox/<recipient>/— recommend requiring perUserHomes in v1 and documenting it. ➤ confirm.group): delivery to/spaces/<group>/Inbox/? Deferred per #100's scope question — this issue implements users-only unless that changes.newest inbox mtimeprobe, no mailer. ➤ Nikola.senderto the message so the recipient sees "from zeus321@Omega", or keep it instance-level only in v1? Metadata minimalism vs UX. ➤ Manuel.Groundwork for this issue, recorded so it is not rediscovered. #123 is wired and green on
main;CompleteTransfercurrently refuses withSTATUS_REFUSEDbecause noDelivereris registered, which is the gap this issue closes.The contract that already exists
TransfercarriesPeer,TransferID,Recipient,FileName,Size,ContentHash,Sender,AgeIdentity,AgeRecipient,Capability,CreatedAt.Note the signature has no reader. That is deliberate and it works: the implementation holds its own
peering.Blobs(the sameDirBlobs{Root: cfg.Peering.StagingDir}theBlobHandlerwrites to) and callsOpen(tr.Peer, tr.TransferID). Wiring passes the same value to both. No signature change needed.What the scout found
storage.FileInfo={Name, Path, Size, ModTime, IsDir, ContentType, ETag}.storage.DrivercarriesDelete,Mkdir(parents must exist — so/home/<r>/Inboxand/home/<r>/Inbox/<peer>must be created in order, not in one call),Rename,Copy. TheWritesignature was cut off by aheadpipe and still needs confirming before coding.internal/storage/encrypt/pq.go:40-42already has the identity parser that handles both flavours — hybrid first, X25519 fallback. That is exactly whattr.AgeIdentityneeds, and peering mints hybrids, so reuse it rather than callingage.ParseHybridIdentitydirectly.internal/storage/encrypt/crypter.go:55already doesage.Decrypt(r, identities...). Same pattern applies here.storage.ErrReadOnly/ErrHeld/ErrRetentionexist and will surface naturally through the decorated stack — they should be mapped to aCompleteResponsedetail rather than swallowed.Plan
tr.AgeIdentity, streaming — never buffer, the #5 OOM lesson applies to WAN uploads verbatim.tr.ContentHashagainst the plaintext while it decrypts (aTeeReaderinto a sha256). Doing it as a separate pass would decrypt twice. Mismatch →STATUS_HASH_MISMATCH, discard the blob, deliver nothing.unscopedStore's base) into/home/<recipient>/Inbox/<peer>/<file_name>, so quota, audit, trash, retention and holds apply with no new code, and re-encryption to the recipient's own key happens automatically because the encrypt decorator is in the chain.Blobs.Discardon both success and terminal failure;Registry.DropTransferon success only (already called byCompleteTransfer).Open
Driver.Write's exact signature before coding.Inboxpath is invented by us, not by the user, sofile_nameis already validated as a single segment byPrepareTransfer— but the peer name also becomes a path component here and is only validated at registration. Worth re-checkingvalidPeerNamecovers path-hostile input.Scaffold
.forgejo/workflows/driver-scout.ymlis still in the repo — itsheadpipe returned 141 so the self-delete never ran. Remove it with the next workflow.Implemented — closing
internal/peering/deliver.go, merged and shipped in v0.4.2.FileDelivereris registered incmd/cairnd, soCompleteTransfernow delivers instead of refusing.Placement and the decorated stack
Delivery writes through
unscopedStore— the full decorated stack minus the per-user scope decorator, which is exactly right because delivery addresses absolute/home/<recipient>/Inbox/<peer>/paths rather than the caller's virtual root.That single choice is what makes the rest of this issue's scope free: quota, audit, trash, retention, holds and re-encryption to the recipient's own key all apply with no new code, because they are decorators in that chain. Nothing here special-cases peering.
Mkdirrequires parents to exist, so/home/<r>,/home/<r>/Inboxand/home/<r>/Inbox/<peer>are created top-down; an already-existing directory is not an error worth failing a delivery over.Hash verification, and why it happens here
The plaintext hash is verified while the stream decrypts, via a
TeeReaderinto a sha256. Doing it as a separate pass would decrypt twice. This is also why verification lives in the deliverer rather than inCompleteTransfer— decrypting is delivery.The ordering problem this creates is handled explicitly: the hash cannot be known until the stream ends, and the stream is not buffered (the #5 OOM lesson applies verbatim to WAN uploads). So delivery writes to a dotted
.partfile and only renames it into place once the plaintext verifies. A corrupt transfer never appears under its real name, and the part file is deleted on mismatch — there is a test asserting nothing at all is left behind.Path safety
Peer, recipient and file name all become path components here.
file_namewas already validated atPrepareTransfer, but the peer name was not —validPeerNamepermits., so..was a registrable peer name that would have escaped the inbox. All three are re-validated at the point of use, with a test proving..is refused and storage is never touched.Collision policy
A second
meme.jpgbecomesmeme-1.jpg. This was the open question in my earlier brief; I took suffix rather than overwrite or refuse, because overwriting someone's file because a peer reused a name is not a defensible default, and refusing turns a normal situation into a support ticket. Falls back to the transfer id after 999 collisions rather than looping.8 tests including the end-to-end loopback, which moves a file sender → receiver in one process against the real
Service,BlobHandlerandFileDeliverer.