From cbe3412864df753651df0ffb86e41c55992cf227 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 10 Sep 2026 15:31:29 +0800 Subject: [PATCH 1/6] Finalize immutable message hashes before delivery --- README.md | 47 +++- cmd/fmsg-backfill/main.go | 137 ++++++++++ cmd/fmsgd/deflate.go | 155 +----------- cmd/fmsgd/host.go | 20 +- cmd/fmsgd/sender.go | 118 ++++++--- cmd/fmsgd/store.go | 60 ++++- dd.sql | 107 +++++++- pkg/fmsg/README.md | 11 + pkg/fmsg/deflate.go | 157 ++++++++++++ pkg/fmsg/fmsg.go | 1 + pkg/fmsg/prepare.go | 240 ++++++++++++++++++ pkg/fmsg/prepare_test.go | 69 ++++++ pkg/message/store.go | 344 ++++++++++++++++++++++++++ pkg/message/store_integration_test.go | 194 +++++++++++++++ 14 files changed, 1464 insertions(+), 196 deletions(-) create mode 100644 cmd/fmsg-backfill/main.go create mode 100644 pkg/fmsg/deflate.go create mode 100644 pkg/fmsg/prepare.go create mode 100644 pkg/fmsg/prepare_test.go create mode 100644 pkg/message/store.go create mode 100644 pkg/message/store_integration_test.go diff --git a/README.md b/README.md index 9802aae..baa632a 100644 --- a/README.md +++ b/README.md @@ -139,4 +139,49 @@ PGDATABASE=fmsgd sudo systemctl daemon-reload sudo systemctl enable fmsgd sudo systemctl start fmsgd -``` \ No newline at end of file +``` +## Immutable message finalization and upgrades + +`fmsg-webapi` finalizes local messages with `pkg/message`: the timestamp, SHA-256, +exact header, and durable wire payloads are committed together, including local-only +messages and reactions. The hash covers the encoded wire header and expanded body +and attachment bytes. Compression and common media type encoding are chosen before +hashing. Add-to exchanges retain independent hashes and reuse the finalized payload. +The daemon reuses these representations for federation and challenge responses; +it refuses a representation that differs from an established hash. + +The `wire_message` JSONB columns are versioned internal snapshots containing payload +paths; they are not API objects. `.fmsg-wire-*` directories beside message content +must be retained with the message database and data directory. Both services need +access to the shared files (normally the same service user/group). The API keeps its +expanded downloadable content separately. New received messages also preserve their +wire payloads before expanding the downloadable copies. + +Upgrade the daemon, API and schema together while message writes and federation are +paused: install compatible binaries, rerun `dd.sql`, backfill, then resume services. +The schema refuses a newly committed sent message without a 32-byte hash. It is not +compatible with an older API that stamps only `time_sent`. Existing hashes are never +replaced by the migration. + +Build the maintenance command with `go build -o fmsg-backfill ./cmd/fmsg-backfill`. +It uses the same standard `PG*` connection variables as the daemon and must have +access to the stored file paths. First inspect, then apply: + +```sh +./fmsg-backfill -domain example.com +./fmsg-backfill -domain example.com -apply +``` + +The default invocation lists pending local messages and batches without writing. +`-apply` preserves timestamps and finalizes parents before children; it can be rerun. +Missing files, inconsistent already-hashed children, or legacy representations that +cannot reproduce an existing hash are reported with a nonzero exit status. Resolve +these records before resuming dependent delivery; hashes are not silently rewritten. +A process crash before commit may leave an unreferenced `.fmsg-wire-*` directory; +only remove such directories after checking both snapshot columns for references. + +PostgreSQL tests use an isolated temporary schema in the supplied test database: + +```sh +FMSG_TEST_DATABASE_URL=postgres://postgres@localhost/fmsg_test?sslmode=disable go test ./... +``` diff --git a/cmd/fmsg-backfill/main.go b/cmd/fmsg-backfill/main.go new file mode 100644 index 0000000..a8ee6dd --- /dev/null +++ b/cmd/fmsg-backfill/main.go @@ -0,0 +1,137 @@ +// fmsg-backfill assigns missing identities without changing sent timestamps. +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "log" + "os" + + _ "github.com/lib/pq" + "github.com/markmnl/fmsgd/pkg/message" +) + +func main() { + if err := run(); err != nil { + log.Print(err) + os.Exit(1) + } +} +func run() error { + domain := flag.String("domain", "", "local sending domain (required)") + apply := flag.Bool("apply", false, "write hashes; default only lists pending messages") + flag.Parse() + if *domain == "" { + return fmt.Errorf("-domain is required") + } + db, err := sql.Open("postgres", "") + if err != nil { + return err + } + defer db.Close() + ctx := context.Background() + rows, err := db.QueryContext(ctx, `SELECT id FROM msg WHERE time_sent IS NOT NULL AND sha256 IS NULL AND lower(split_part(from_addr,'@',3))=lower($1) ORDER BY id`, *domain) + if err != nil { + return err + } + var pending []int64 + for rows.Next() { + var id int64 + if err = rows.Scan(&id); err != nil { + break + } + pending = append(pending, id) + } + if err == nil { + err = rows.Err() + } + rows.Close() + if err != nil { + return err + } + if !*apply { + for _, id := range pending { + fmt.Printf("message %d needs finalization\n", id) + } + } else { + for len(pending) > 0 { + var remaining []int64 + for _, id := range pending { + err = finalize(ctx, db, id, 0) + if err != nil { + remaining = append(remaining, id) + log.Printf("message %d: %v", id, err) + } else { + fmt.Printf("finalized message %d\n", id) + } + } + if len(remaining) == len(pending) { + return fmt.Errorf("%d messages could not be finalized; repair reported data and rerun", len(remaining)) + } + pending = remaining + } + } + rows, err = db.QueryContext(ctx, `SELECT b.msg_id,b.id FROM msg_add_to_batch b JOIN msg m ON m.id=b.msg_id WHERE m.time_sent IS NOT NULL AND b.sha256 IS NULL AND lower(split_part(b.add_to_from,'@',3))=lower($1) ORDER BY b.id`, *domain) + if err != nil { + return err + } + var batches [][2]int64 + for rows.Next() { + var b [2]int64 + if err = rows.Scan(&b[0], &b[1]); err != nil { + break + } + batches = append(batches, b) + } + if err == nil { + err = rows.Err() + } + rows.Close() + if err != nil { + return err + } + failed := 0 + for _, b := range batches { + if !*apply { + fmt.Printf("batch %d of message %d needs finalization\n", b[1], b[0]) + continue + } + if err = finalize(ctx, db, b[0], b[1]); err != nil { + log.Printf("batch %d: %v", b[1], err) + failed++ + } else { + fmt.Printf("finalized batch %d\n", b[1]) + } + } + if failed > 0 { + return fmt.Errorf("%d batches could not be finalized", failed) + } + return nil +} +func finalize(ctx context.Context, db *sql.DB, id, batch int64) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var files message.Files + committed := false + defer func() { + if !committed { + files.Cleanup() + } + }() + if batch == 0 { + _, err = message.Finalize(ctx, message.SQLTx{Tx: tx}, id, 0, &files) + } else { + _, err = message.FinalizeBatch(ctx, message.SQLTx{Tx: tx}, id, batch, &files) + } + if err != nil { + return err + } + err = tx.Commit() + committed = err == nil + return err +} diff --git a/cmd/fmsgd/deflate.go b/cmd/fmsgd/deflate.go index 68b85a7..eeb4564 100644 --- a/cmd/fmsgd/deflate.go +++ b/cmd/fmsgd/deflate.go @@ -1,157 +1,8 @@ package main -import ( - "bytes" - "compress/zlib" - "io" - "os" - "strings" -) +import "github.com/markmnl/fmsgd/pkg/fmsg" -// minDeflateSize is the minimum payload size in bytes before compression is -// attempted. -const minDeflateSize uint32 = 512 - -// incompressibleTypes lists media types (lowercased, without parameters) that -// are already compressed or otherwise unlikely to benefit from zlib-deflate. -var incompressibleTypes = map[string]bool{ - // images - "image/jpeg": true, "image/png": true, "image/gif": true, - "image/webp": true, "image/heic": true, "image/avif": true, - "image/apng": true, - // audio - "audio/aac": true, "audio/mpeg": true, "audio/ogg": true, - "audio/opus": true, "audio/webm": true, - // video - "video/h264": true, "video/h265": true, "video/h266": true, - "video/ogg": true, "video/vp8": true, "video/vp9": true, - "video/webm": true, - // archives / compressed containers - "application/gzip": true, "application/zip": true, - "application/epub+zip": true, - "application/octet-stream": true, - // zip-based office formats - "application/vnd.oasis.opendocument.presentation": true, - "application/vnd.oasis.opendocument.spreadsheet": true, - "application/vnd.oasis.opendocument.text": true, - "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, - "application/vnd.amazon.ebook": true, - // fonts (compressed) - "font/woff": true, "font/woff2": true, - // pdf (internally compressed) - "application/pdf": true, - // 3d models (compressed containers) - "model/3mf": true, "model/gltf-binary": true, - "model/vnd.usdz+zip": true, -} - -// shouldCompress reports whether compression should be attempted for a payload -// with the given media type and size. It returns false for payloads that are -// too small or use a media type known to be already compressed. -func shouldCompress(mediaType string, dataSize uint32) bool { - if dataSize < minDeflateSize { - return false - } - t := strings.ToLower(mediaType) - if i := strings.IndexByte(t, ';'); i >= 0 { - t = strings.TrimRight(t[:i], " ") - } - return !incompressibleTypes[t] -} - -// deflateSampleSize is the number of bytes sampled from the start of a file -// to estimate compressibility before committing to a full-file compression -// pass. Chosen large enough for zlib to find patterns but small enough to be -// fast even on very large files. const deflateSampleSize = 8192 -// probeSample compresses up to deflateSampleSize bytes from the start of src -// and reports whether the ratio looks promising (compressed < 80% of input). -// src is seeked back to the start on return. -func probeSample(src *os.File, srcSize uint32) (bool, error) { - sampleLen := int64(deflateSampleSize) - if int64(srcSize) < sampleLen { - sampleLen = int64(srcSize) - } - - var buf bytes.Buffer - zw := zlib.NewWriter(&buf) - if _, err := io.CopyN(zw, src, sampleLen); err != nil { - _ = zw.Close() - return false, err - } - if err := zw.Close(); err != nil { - return false, err - } - - if _, err := src.Seek(0, io.SeekStart); err != nil { - return false, err - } - - return int64(buf.Len()) < sampleLen*8/10, nil -} - -// tryCompress compresses the file at srcPath using zlib-deflate and writes the -// result to a temporary file. For files larger than deflateSampleSize it first -// compresses a prefix sample to estimate compressibility, avoiding a full pass -// over files that won't compress well. It returns worthwhile=true only when -// the compressed output is less than 80% of the original size (at least a 20% -// reduction). When not worthwhile the temporary file is removed. When -// worthwhile the caller is responsible for removing the file at dstPath. -func tryCompress(srcPath string, srcSize uint32) (dstPath string, compressedSize uint32, worthwhile bool, err error) { - src, err := os.Open(srcPath) - if err != nil { - return "", 0, false, err - } - defer src.Close() - - // For files larger than the sample size, probe a prefix first. - if srcSize > deflateSampleSize { - promising, err := probeSample(src, srcSize) - if err != nil { - return "", 0, false, err - } - if !promising { - return "", 0, false, nil - } - } - - dst, err := os.CreateTemp("", "fmsg-deflate-*") - if err != nil { - return "", 0, false, err - } - dstName := dst.Name() - - zw := zlib.NewWriter(dst) - if _, err := io.Copy(zw, src); err != nil { - _ = zw.Close() - _ = dst.Close() - _ = os.Remove(dstName) - return "", 0, false, err - } - if err := zw.Close(); err != nil { - _ = dst.Close() - _ = os.Remove(dstName) - return "", 0, false, err - } - if err := dst.Close(); err != nil { - _ = os.Remove(dstName) - return "", 0, false, err - } - - fi, err := os.Stat(dstName) - if err != nil { - _ = os.Remove(dstName) - return "", 0, false, err - } - - cSize := uint32(fi.Size()) - if cSize >= srcSize*8/10 { - _ = os.Remove(dstName) - return "", 0, false, nil - } - - return dstName, cSize, true, nil -} +var shouldCompress = fmsg.ShouldCompress +var tryCompress = fmsg.TryCompress diff --git a/cmd/fmsgd/host.go b/cmd/fmsgd/host.go index bc9ac1b..6223cc1 100644 --- a/cmd/fmsgd/host.go +++ b/cmd/fmsgd/host.go @@ -1582,6 +1582,13 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro acceptedTo := []FMsgAddress{} acceptedAddTo := []FMsgAddress{} var primaryFilepath string + var wireDir string + wireStored := false + defer func() { + if !wireStored && wireDir != "" { + _ = os.RemoveAll(wireDir) + } + }() for i, addr := range addrs { code, err := validateMsgRecvForAddr(h, &addr, dupHash) if err != nil { @@ -1617,6 +1624,17 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro } if primaryFilepath == "" { primaryFilepath = fp + if len(h.StoredWire) == 0 { + wire, dir, preserveErr := fmsg.Preserve(h, filepath.Dir(fp)) + if preserveErr != nil { + return preserveErr + } + wireDir = dir + h.StoredWire, err = fmsg.MarshalPrepared(wire) + if err != nil { + return err + } + } if err := persistAttachmentPayloads(h, filepath.Dir(primaryFilepath)); err != nil { log.Printf("ERROR: copying attachment payloads for message storage: %s", err) codes[i] = RejectCodeUserUndisclosed @@ -1638,7 +1656,7 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro stored := storeAcceptedMessage(h, codes, acceptedTo, acceptedAddTo, localOutcome, primaryFilepath) if stored { - cleanupOnReturn = false + wireStored = true } return rejectAccept(c, codes) diff --git a/cmd/fmsgd/sender.go b/cmd/fmsgd/sender.go index de302be..cd23046 100644 --- a/cmd/fmsgd/sender.go +++ b/cmd/fmsgd/sender.go @@ -364,23 +364,7 @@ func (d deflateState) applyTo(h *FMsgHeader) { // Media Type ID (SPEC §4) where the type string has one, so the wire carries // one byte instead of the string. Standards such as FMSG-005 require the ID // form. It reports whether anything changed. -func applyCommonTypes(h *FMsgHeader) bool { - changed := false - if id, ok := fmsg.GetCommonMediaTypeID(h.Type); ok && h.Flags&FlagCommonType == 0 { - h.Flags |= FlagCommonType - h.TypeID = id - changed = true - } - for i := range h.Attachments { - att := &h.Attachments[i] - if id, ok := fmsg.GetCommonMediaTypeID(att.Type); ok && att.Flags&1 == 0 { - att.Flags |= 1 // attachment flag bit 0: common type (SPEC §5) - att.TypeID = id - changed = true - } - } - return changed -} +func applyCommonTypes(h *FMsgHeader) bool { return fmsg.ApplyCommonTypes(h) } // encodeForWire builds a unit header in its transmitted form: build, apply // deflate, then, when commonTypes is set, common type IDs. The message hash @@ -512,22 +496,47 @@ func deliverMessage(target pendingTarget) { // and applyTo changes flags, size and expanded size. Hashing the // undeflated form recorded a sha256 the receiving host never computes, // so cross-host replies bounced with code 6 (parent not found). - d := computeDeflate(m, target.MsgID) - defer d.removeTempFiles() - - orig, useCommonTypes, err := encodeForWire(m.originalHeader, d, true, m.storedHash) - if err != nil { - log.Printf("ERROR: sender: building wire header for msg %d: %s", target.MsgID, err) - return - } - + var d deflateState + defer func() { d.removeTempFiles() }() + var orig *FMsgHeader + useCommonTypes := true sharedHash := m.storedHash - if len(sharedHash) == 0 { - sharedHash, err = orig.GetMessageHash() - if err != nil { - log.Printf("ERROR: sender: computing message hash for msg %d: %s", target.MsgID, err) + if m.wire != nil { + orig = m.wire.Clone() + } else { + // A message received only as an add-to has no original header, but + // its stored batches remain independently deliverable. + hasPreparedBatch := false + for _, b := range batches { + if len(b.Prepared) > 0 { + hasPreparedBatch = true + break + } + } + if !hasPreparedBatch { + d = computeDeflate(m, target.MsgID) + orig, useCommonTypes, err = encodeForWire(m.originalHeader, d, true, m.storedHash) + if err != nil { + log.Printf("ERROR: sender: building msg %d: %s", target.MsgID, err) + return + } + } + } + if orig != nil { + actual, hashErr := orig.GetMessageHash() + if hashErr != nil { + log.Printf("ERROR: sender: hash msg %d: %s", target.MsgID, hashErr) + return + } + if len(sharedHash) > 0 && !bytes.Equal(actual, sharedHash) { + log.Printf("ERROR: sender: immutable hash mismatch for msg %d", target.MsgID) return } + sharedHash = actual + } + if len(sharedHash) != 32 { + log.Printf("ERROR: sender: missing identity for msg %d", target.MsgID) + return } // Persist the shared hash (so replies/add-to referencing this message @@ -548,10 +557,15 @@ func deliverMessage(target pendingTarget) { } // Deliver the original message to its pending msg_to recipients. + parentPending, err := parentPendingForDomain(db, m.parentPid, target.Domain) + if err != nil { + log.Printf("ERROR: sender: checking pending parent of %d: %s", target.MsgID, err) + return + } if parentTerminal { log.Printf("ERROR: sender: msg %d references a terminal parent; not sending (SPEC §10.2)", target.MsgID) recordUnitInvalid(db, target, "msg_to", 0) - } else { + } else if orig != nil && !parentPending { deliverUnit(db, target, orig, "msg_to", 0) } @@ -565,7 +579,13 @@ func deliverMessage(target pendingTarget) { recordUnitInvalid(db, target, "msg_add_to", b.ID) continue } - h, _, err := encodeForWire(func() *FMsgHeader { return m.addToHeader(b, sharedHash) }, d, useCommonTypes, b.Hash) + var h *FMsgHeader + var err error + if len(b.Prepared) > 0 { + h, err = fmsg.UnmarshalPrepared(b.Prepared, b.Hash) + } else { + h, _, err = encodeForWire(func() *FMsgHeader { return m.addToHeader(b, sharedHash) }, d, useCommonTypes, b.Hash) + } if err != nil { log.Printf("ERROR: sender: building add-to wire header for batch %d of msg %d: %s", b.ID, target.MsgID, err) continue @@ -579,6 +599,10 @@ func deliverMessage(target pendingTarget) { log.Printf("ERROR: sender: computing batch hash for batch %d of msg %d: %s", b.ID, target.MsgID, err) continue } + if len(b.Hash) > 0 && !bytes.Equal(batchHash, b.Hash) { + log.Printf("ERROR: sender: immutable batch hash mismatch for %d", b.ID) + continue + } if err := ensureBatchHash(db, b.ID, batchHash); err != nil { log.Printf("ERROR: sender: %s", err) continue @@ -587,6 +611,36 @@ func deliverMessage(target pendingTarget) { } } +// Hashes exist before network delivery now. Keep a reply queued while this +// host still owes the target domain its parent (including a selected batch), +// instead of racing that delivery and receiving a terminal parent-not-found. +func parentPendingForDomain(db *sql.DB, hash []byte, domain string) (bool, error) { + if len(hash) == 0 { + return false, nil + } + var pending bool + err := db.QueryRow(` + WITH parent AS ( + SELECT m.id, NULL::bigint AS batch_id FROM msg m WHERE m.sha256=$1 + UNION ALL SELECT b.msg_id,b.id FROM msg_add_to_batch b WHERE b.sha256=$1 + ), deliveries AS ( + SELECT t.time_delivered AS delivered,t.response_code AS code + FROM parent p JOIN msg_to t ON t.msg_id=p.id + WHERE p.batch_id IS NULL AND lower(split_part(t.addr,'@',3))=lower($2) + UNION ALL + SELECT a.time_delivered,a.response_code FROM parent p JOIN msg_add_to a ON a.msg_id=p.id + WHERE (p.batch_id IS NULL OR a.batch_id=p.batch_id) AND lower(split_part(a.addr,'@',3))=lower($2) + UNION ALL + SELECT n.time_notified,n.response_code FROM parent p + JOIN msg_add_to_batch b ON b.msg_id=p.id JOIN msg_add_to_notify n ON n.batch_id=b.id + WHERE (p.batch_id IS NULL OR b.id=p.batch_id) AND lower(n.domain)=lower($2) + ) + SELECT EXISTS(SELECT 1 FROM deliveries WHERE delivered IS NULL AND (code IS NULL OR code=ANY($3))) + AND NOT EXISTS(SELECT 1 FROM deliveries WHERE delivered IS NOT NULL) + `, hash, domain, pq.Array(retryableResponseCodes)).Scan(&pending) + return pending, err +} + // recordUnitInvalid records code 1 (invalid) against one delivery unit's // pending recipients and notify row for a domain without transmitting // anything, for a unit the protocol forbids sending. Code 1 is not retryable, diff --git a/cmd/fmsgd/store.go b/cmd/fmsgd/store.go index dda2aaf..8692818 100644 --- a/cmd/fmsgd/store.go +++ b/cmd/fmsgd/store.go @@ -2,7 +2,9 @@ package main import ( "database/sql" + "fmt" + "github.com/markmnl/fmsgd/pkg/fmsg" "log" "strings" @@ -318,6 +320,9 @@ func getMsgByBatchHash(batchHash []byte) (*FMsgHeader, error) { } for i := range batches { if batches[i].ID == batchID { + if len(batches[i].Prepared) > 0 { + return fmsg.UnmarshalPrepared(batches[i].Prepared, batches[i].Hash) + } sharedHash, err := m.sharedHash() if err != nil { return nil, err @@ -375,7 +380,7 @@ func addToBatchRecorded(msgID int64, batchHash []byte) (bool, error) { func insertAddToBatch(tx *sql.Tx, msgID int64, addToFrom string, now float64, batchHash []byte) (int64, error) { var batchID int64 err := tx.QueryRow(`insert into msg_add_to_batch (msg_id, add_to_from, time_added, sha256) -values ($1, $2, $3, $4) returning id`, msgID, addToFrom, now, batchHash).Scan(&batchID) +values ($1, $2, $3, NULL) returning id`, msgID, addToFrom, now).Scan(&batchID) return batchID, err } @@ -445,7 +450,20 @@ on conflict (batch_id, addr) do nothing`, msgID, batchID, addr.ToString(), deliv return err } } - return nil + return sealReceivedBatch(tx, batchID, msg, batchHash) +} + +func sealReceivedBatch(tx *sql.Tx, id int64, h *FMsgHeader, hash []byte) error { + data := h.StoredWire + if len(data) == 0 { + var err error + data, err = fmsg.MarshalPrepared(h) + if err != nil { + return err + } + } + _, err := tx.Exec(`UPDATE msg_add_to_batch SET sha256=$2,wire_message=$3 WHERE id=$1`, id, hash, string(data)) + return err } // inboundRecipientRow maps one wire recipient of a received message to its @@ -523,11 +541,11 @@ returning id`, msg.Flags&FlagImportant != 0, msg.Flags&FlagDeflate != 0, msg.Flags&FlagTerminal != 0, - msg.Timestamp, + nil, // assembled as a draft, finalized below in this transaction msg.From.ToString(), msg.Topic, msg.Type, - msgHash, + nil, parentHash, int(msg.Size), msg.Filepath, @@ -583,6 +601,9 @@ values ($1, $2, $3, $4, $5)`) return err } } + if err := sealReceivedBatch(tx, batchID, msg, batchHash); err != nil { + return err + } } if len(msg.Attachments) > 0 { @@ -601,6 +622,13 @@ values ($1, $2, $3, $4, $5, $6, $7)`) } } + var snapshot any + if msg.Flags&FlagHasAddTo == 0 && len(msg.StoredWire) > 0 { + snapshot = string(msg.StoredWire) + } + if _, err := tx.Exec(`UPDATE msg SET time_sent=$2,sha256=$3,wire_message=$4 WHERE id=$1`, msgID, msg.Timestamp, msgHash, snapshot); err != nil { + return err + } if err := resolveMsgParentLinks(tx, msgID, msgHash, parentHash, requiresStoredParent(msg)); err != nil { return err } @@ -656,6 +684,7 @@ type msgFields struct { noReply, isImportant, isDeflate bool isTerminal bool // SPEC §3 bit 6: no message may reference this one via pid parentPid []byte // relational parent hash (stored pid column) + wire *FMsgHeader storedHash []byte // stored sha256; empty when not yet persisted from FMsgAddress to []FMsgAddress @@ -669,14 +698,22 @@ type msgFields struct { func loadMsgFields(tx *sql.Tx, msgID int64) (*msgFields, error) { var m msgFields var fromAddr string + var prepared []byte if err := tx.QueryRow(` - SELECT version, no_reply, is_important, is_deflate, is_terminal, psha256, sha256, from_addr, topic, type, time_sent, size, filepath + SELECT version, no_reply, is_important, is_deflate, is_terminal, psha256, sha256, from_addr, topic, type, time_sent, size, filepath, wire_message FROM msg WHERE id = $1 `, msgID).Scan(&m.version, &m.noReply, &m.isImportant, &m.isDeflate, &m.isTerminal, &m.parentPid, &m.storedHash, - &fromAddr, &m.topic, &m.typ, &m.timeSent, &m.size, &m.filepath); err != nil { + &fromAddr, &m.topic, &m.typ, &m.timeSent, &m.size, &m.filepath, &prepared); err != nil { return nil, fmt.Errorf("load msg %d: %w", msgID, err) } + if len(prepared) > 0 { + var err error + m.wire, err = fmsg.UnmarshalPrepared(prepared, m.storedHash) + if err != nil { + return nil, fmt.Errorf("load prepared msg %d: %w", msgID, err) + } + } from, err := parseAddress([]byte(fromAddr)) if err != nil { return nil, fmt.Errorf("invalid from address %s: %w", fromAddr, err) @@ -786,6 +823,9 @@ func isStoredMsgTerminal(db *sql.DB, hash []byte) (bool, error) { // originalHeader builds the message in its original (non-add-to) wire form, // whose pid (if any) references the relational parent. func (m *msgFields) originalHeader() *FMsgHeader { + if m.wire != nil { + return m.wire.Clone() + } flags := m.baseFlags() if len(m.parentPid) > 0 { flags |= FlagHasPid @@ -841,6 +881,7 @@ type addToBatch struct { From FMsgAddress TimeAdded float64 Recipients []FMsgAddress + Prepared []byte Hash []byte // batch message hash once persisted (SPEC §11); nil before first delivery } @@ -848,7 +889,7 @@ type addToBatch struct { // sender, timestamp and recipients, ordered by when it was added. func loadAddToBatches(tx *sql.Tx, msgID int64) ([]addToBatch, error) { rows, err := tx.Query(` - SELECT b.id, b.add_to_from, b.time_added, b.sha256, a.addr + SELECT b.id, b.add_to_from, b.time_added, b.sha256, b.wire_message, a.addr FROM msg_add_to_batch b LEFT JOIN msg_add_to a ON a.batch_id = b.id WHERE b.msg_id = $1 @@ -866,8 +907,9 @@ func loadAddToBatches(tx *sql.Tx, msgID int64) ([]addToBatch, error) { var fromStr string var timeAdded float64 var hash []byte + var prepared []byte var addr sql.NullString - if err := rows.Scan(&id, &fromStr, &timeAdded, &hash, &addr); err != nil { + if err := rows.Scan(&id, &fromStr, &timeAdded, &hash, &prepared, &addr); err != nil { return nil, fmt.Errorf("scan add-to batch row: %w", err) } idx, ok := byID[id] @@ -876,7 +918,7 @@ func loadAddToBatches(tx *sql.Tx, msgID int64) ([]addToBatch, error) { if err != nil { return nil, fmt.Errorf("invalid add_to_from address %s: %w", fromStr, err) } - batches = append(batches, addToBatch{ID: id, From: *from, TimeAdded: timeAdded, Hash: hash}) + batches = append(batches, addToBatch{ID: id, From: *from, TimeAdded: timeAdded, Hash: hash, Prepared: prepared}) idx = len(batches) - 1 byID[id] = idx } diff --git a/dd.sql b/dd.sql index 2116073..78ddbad 100644 --- a/dd.sql +++ b/dd.sql @@ -190,7 +190,7 @@ begin raise exception 'cannot clear sha256 for message %: it has replies', NEW.id; end if; - if OLD.sha256 is distinct from NEW.sha256 then + if OLD.sha256 is not null and OLD.sha256 is distinct from NEW.sha256 then raise exception 'cannot change sha256 for message %: it has replies', NEW.id; end if; end if; @@ -387,3 +387,108 @@ create constraint trigger trg_recipients_added after insert on msg_add_to_batch deferrable initially deferred for each row execute function notify_recipients_added(); + +-- Durable protocol representations, shared by the API finalizer and daemon. +-- NULL on legacy rows; received add-to variants belong to their batch only. +alter table msg add column if not exists wire_message jsonb; +alter table msg_add_to_batch add column if not exists wire_message jsonb; +create index if not exists msg_add_to_batch_sha256_idx on msg_add_to_batch (sha256) where sha256 is not null; +create index if not exists msg_pid_idx on msg (pid) where pid is not null; + +-- Preserve protocol identity; local relational pid links and delivery/read +-- metadata are bookkeeping and may still change. Legacy NULL hashes may be +-- filled once without changing the timestamp, including parents with replies. +create or replace function protect_msg_identity() returns trigger as $$ +begin + if OLD.time_sent is not null then + if NEW.time_sent is distinct from OLD.time_sent then + raise exception 'sent message timestamp is immutable'; + end if; + if OLD.sha256 is not null and + (NEW.sha256 is distinct from OLD.sha256 or + row(NEW.version,NEW.psha256,NEW.no_reply,NEW.is_important,NEW.is_terminal, + NEW.is_deflate,NEW.from_addr,NEW.topic,NEW.type,NEW.size,NEW.filepath) + is distinct from + row(OLD.version,OLD.psha256,OLD.no_reply,OLD.is_important,OLD.is_terminal, + OLD.is_deflate,OLD.from_addr,OLD.topic,OLD.type,OLD.size,OLD.filepath) or + (OLD.wire_header is not null and NEW.wire_header is distinct from OLD.wire_header) or + (OLD.wire_message is not null and NEW.wire_message is distinct from OLD.wire_message)) then + raise exception 'sent message content and hash are immutable'; + end if; + end if; + return NEW; +end; +$$ language plpgsql; +drop trigger if exists trg_msg_identity on msg; +create trigger trg_msg_identity before update on msg for each row execute function protect_msg_identity(); + +-- Validate at commit so receiving hosts can assemble rows and recipients in +-- one transaction. Existing unhashed rows are backfilled by fmsg-backfill. +create or replace function require_sent_msg_hash() returns trigger as $$ +begin + if TG_OP='UPDATE' then + if OLD.time_sent is not distinct from NEW.time_sent and OLD.sha256 is not distinct from NEW.sha256 then return null; end if; + end if; + if exists (select 1 from msg where id=NEW.id and time_sent is not null + and (sha256 is null or octet_length(sha256) <> 32)) then + raise exception 'sent message % requires a 32-byte sha256', NEW.id; + end if; + return null; +end; +$$ language plpgsql; +drop trigger if exists trg_msg_require_hash on msg; +create constraint trigger trg_msg_require_hash after insert or update on msg + deferrable initially deferred for each row execute function require_sent_msg_hash(); + +create or replace function protect_msg_parts() returns trigger as $$ +declare + message_id bigint; + frozen boolean; +begin + -- Delivery and read receipts do not change a protocol recipient. + if TG_OP='UPDATE' then + if TG_TABLE_NAME='msg_to' then + if row(NEW.id,NEW.msg_id,NEW.addr) is not distinct from row(OLD.id,OLD.msg_id,OLD.addr) then return NEW; end if; + end if; + if TG_TABLE_NAME='msg_add_to' then + if row(NEW.id,NEW.msg_id,NEW.batch_id,NEW.addr) is not distinct from row(OLD.id,OLD.msg_id,OLD.batch_id,OLD.addr) then return NEW; end if; + end if; + end if; + if TG_OP='DELETE' then message_id=OLD.msg_id; else message_id=NEW.msg_id; end if; + if TG_OP='UPDATE' and NEW.msg_id <> OLD.msg_id then raise exception 'cannot move message parts'; end if; + if TG_TABLE_NAME='msg_add_to' then + if TG_OP='DELETE' then + select sha256 is not null into frozen from msg_add_to_batch where id=OLD.batch_id for update; + else + if TG_OP='UPDATE' and NEW.batch_id <> OLD.batch_id then raise exception 'cannot move batch recipients'; end if; + select sha256 is not null into frozen from msg_add_to_batch where id=NEW.batch_id and msg_id=message_id for update; + if not found then raise exception 'batch does not belong to message'; end if; + end if; + else + select time_sent is not null and sha256 is not null into frozen from msg where id=message_id for update; + end if; + if frozen then raise exception 'finalized message parts are immutable'; end if; + if TG_OP='DELETE' then return OLD; end if; + return NEW; +end; +$$ language plpgsql; +-- AFTER INSERT allows an ON CONFLICT DO NOTHING receipt to remain a no-op. +drop trigger if exists trg_msg_to_content on msg_to; +create trigger trg_msg_to_content after insert or update or delete on msg_to for each row execute function protect_msg_parts(); +drop trigger if exists trg_msg_attachment_content on msg_attachment; +create trigger trg_msg_attachment_content after insert or update or delete on msg_attachment for each row execute function protect_msg_parts(); +drop trigger if exists trg_msg_add_to_content on msg_add_to; +create trigger trg_msg_add_to_content after insert or update or delete on msg_add_to for each row execute function protect_msg_parts(); + +create or replace function protect_batch_identity() returns trigger as $$ +begin + if OLD.sha256 is not null and + row(NEW.msg_id,NEW.add_to_from,NEW.time_added,NEW.sha256,NEW.wire_message) + is distinct from row(OLD.msg_id,OLD.add_to_from,OLD.time_added,OLD.sha256,OLD.wire_message) then + raise exception 'finalized add-to batch is immutable'; + end if; + return NEW; +end; +$$ language plpgsql; +drop trigger if exists trg_batch_identity on msg_add_to_batch; +create trigger trg_batch_identity before update on msg_add_to_batch for each row execute function protect_batch_identity(); diff --git a/pkg/fmsg/README.md b/pkg/fmsg/README.md index 3294d47..efd6f2e 100644 --- a/pkg/fmsg/README.md +++ b/pkg/fmsg/README.md @@ -55,3 +55,14 @@ hash, err := h.GetMessageHash() mtype, ok := fmsg.GetCommonMediaType(id) // ID → "text/plain" id, ok := fmsg.GetCommonMediaTypeID(mt) // "text/plain" → ID ``` + +### Finalize locally authored messages + +`Prepare(header)` chooses compression and common type encoding, copies all wire +payloads into a durable directory beside the body, and hashes the final form. +Input files contain expanded bytes. Keep the returned directory after committing +the message; remove it if the transaction fails. `MarshalPrepared` and +`UnmarshalPrepared` persist and verify that exact representation. `Preserve` copies +an incoming wire representation without changing its encoding. Database writers +can use `github.com/markmnl/fmsgd/pkg/message` to finalize messages and batches in +their own transaction. diff --git a/pkg/fmsg/deflate.go b/pkg/fmsg/deflate.go new file mode 100644 index 0000000..bd9f2ae --- /dev/null +++ b/pkg/fmsg/deflate.go @@ -0,0 +1,157 @@ +package fmsg + +import ( + "bytes" + "compress/zlib" + "io" + "os" + "strings" +) + +// minDeflateSize is the minimum payload size in bytes before compression is +// attempted. +const minDeflateSize uint32 = 512 + +// incompressibleTypes lists media types (lowercased, without parameters) that +// are already compressed or otherwise unlikely to benefit from zlib-deflate. +var incompressibleTypes = map[string]bool{ + // images + "image/jpeg": true, "image/png": true, "image/gif": true, + "image/webp": true, "image/heic": true, "image/avif": true, + "image/apng": true, + // audio + "audio/aac": true, "audio/mpeg": true, "audio/ogg": true, + "audio/opus": true, "audio/webm": true, + // video + "video/h264": true, "video/h265": true, "video/h266": true, + "video/ogg": true, "video/vp8": true, "video/vp9": true, + "video/webm": true, + // archives / compressed containers + "application/gzip": true, "application/zip": true, + "application/epub+zip": true, + "application/octet-stream": true, + // zip-based office formats + "application/vnd.oasis.opendocument.presentation": true, + "application/vnd.oasis.opendocument.spreadsheet": true, + "application/vnd.oasis.opendocument.text": true, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.amazon.ebook": true, + // fonts (compressed) + "font/woff": true, "font/woff2": true, + // pdf (internally compressed) + "application/pdf": true, + // 3d models (compressed containers) + "model/3mf": true, "model/gltf-binary": true, + "model/vnd.usdz+zip": true, +} + +// shouldCompress reports whether compression should be attempted for a payload +// with the given media type and size. It returns false for payloads that are +// too small or use a media type known to be already compressed. +func ShouldCompress(mediaType string, dataSize uint32) bool { + if dataSize < minDeflateSize { + return false + } + t := strings.ToLower(mediaType) + if i := strings.IndexByte(t, ';'); i >= 0 { + t = strings.TrimRight(t[:i], " ") + } + return !incompressibleTypes[t] +} + +// deflateSampleSize is the number of bytes sampled from the start of a file +// to estimate compressibility before committing to a full-file compression +// pass. Chosen large enough for zlib to find patterns but small enough to be +// fast even on very large files. +const deflateSampleSize = 8192 + +// probeSample compresses up to deflateSampleSize bytes from the start of src +// and reports whether the ratio looks promising (compressed < 80% of input). +// src is seeked back to the start on return. +func probeSample(src *os.File, srcSize uint32) (bool, error) { + sampleLen := int64(deflateSampleSize) + if int64(srcSize) < sampleLen { + sampleLen = int64(srcSize) + } + + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + if _, err := io.CopyN(zw, src, sampleLen); err != nil { + _ = zw.Close() + return false, err + } + if err := zw.Close(); err != nil { + return false, err + } + + if _, err := src.Seek(0, io.SeekStart); err != nil { + return false, err + } + + return int64(buf.Len()) < sampleLen*8/10, nil +} + +// tryCompress compresses the file at srcPath using zlib-deflate and writes the +// result to a temporary file. For files larger than deflateSampleSize it first +// compresses a prefix sample to estimate compressibility, avoiding a full pass +// over files that won't compress well. It returns worthwhile=true only when +// the compressed output is less than 80% of the original size (at least a 20% +// reduction). When not worthwhile the temporary file is removed. When +// worthwhile the caller is responsible for removing the file at dstPath. +func TryCompress(srcPath string, srcSize uint32) (dstPath string, compressedSize uint32, worthwhile bool, err error) { + src, err := os.Open(srcPath) + if err != nil { + return "", 0, false, err + } + defer src.Close() + + // For files larger than the sample size, probe a prefix first. + if srcSize > deflateSampleSize { + promising, err := probeSample(src, srcSize) + if err != nil { + return "", 0, false, err + } + if !promising { + return "", 0, false, nil + } + } + + dst, err := os.CreateTemp("", "fmsg-deflate-*") + if err != nil { + return "", 0, false, err + } + dstName := dst.Name() + + zw := zlib.NewWriter(dst) + if _, err := io.Copy(zw, src); err != nil { + _ = zw.Close() + _ = dst.Close() + _ = os.Remove(dstName) + return "", 0, false, err + } + if err := zw.Close(); err != nil { + _ = dst.Close() + _ = os.Remove(dstName) + return "", 0, false, err + } + if err := dst.Close(); err != nil { + _ = os.Remove(dstName) + return "", 0, false, err + } + + fi, err := os.Stat(dstName) + if err != nil { + _ = os.Remove(dstName) + return "", 0, false, err + } + + cSize := uint32(fi.Size()) + if cSize >= srcSize*8/10 { + _ = os.Remove(dstName) + return "", 0, false, nil + } + + return dstName, cSize, true, nil +} diff --git a/pkg/fmsg/fmsg.go b/pkg/fmsg/fmsg.go index 3a955a1..6508b3b 100644 --- a/pkg/fmsg/fmsg.go +++ b/pkg/fmsg/fmsg.go @@ -80,6 +80,7 @@ type Header struct { ChallengeCompleted bool // fmsgd server field: true if challenge was completed InitialResponseCode uint8 // fmsgd server field: protocol response code (11/64/65) Filepath string // path to message body data on disk + StoredWire []byte `json:"-"` // receive path: durable wire snapshot before expanding API payloads messageHash []byte } diff --git a/pkg/fmsg/prepare.go b/pkg/fmsg/prepare.go new file mode 100644 index 0000000..a545b51 --- /dev/null +++ b/pkg/fmsg/prepare.go @@ -0,0 +1,240 @@ +package fmsg + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +// Clone returns a header with independent slices and no cached hashes. +func (h *Header) Clone() *Header { + c := *h + c.Pid = append([]byte(nil), h.Pid...) + c.To = append([]Address(nil), h.To...) + c.AddTo = append([]Address(nil), h.AddTo...) + c.Attachments = append([]AttachmentHeader(nil), h.Attachments...) + c.HeaderHash, c.messageHash = nil, nil + return &c +} + +// ApplyCommonTypes chooses the compact encoding before a message is hashed. +func ApplyCommonTypes(h *Header) bool { + changed := false + if id, ok := GetCommonMediaTypeID(h.Type); ok && h.Flags&FlagCommonType == 0 { + h.Flags |= FlagCommonType + h.TypeID = id + changed = true + } + for i := range h.Attachments { + a := &h.Attachments[i] + if id, ok := GetCommonMediaTypeID(a.Type); ok && a.Flags&1 == 0 { + a.Flags |= 1 + a.TypeID = id + changed = true + } + } + return changed +} + +// Prepare freezes a locally authored message's wire representation. Input +// payloads are expanded bytes; HTTP content types (including application/zip) +// do not imply protocol zlib compression. Files are streamed into a durable +// directory beside the body, synced before returning, and reused by retries +// and add-to batches. The caller removes directory if its DB transaction fails. +func Prepare(input *Header) (h *Header, directory string, err error) { + h = input.Clone() + if h.Version != 1 || len(h.To) == 0 || len(h.To) > 255 || len(h.Attachments) > 255 || len(h.Topic) > 255 || len(h.Type) == 0 || len(h.Type) > 255 { + return nil, "", fmt.Errorf("invalid message header lengths or version") + } + if (h.Flags&FlagHasPid != 0) != (len(h.Pid) == 32) { + return nil, "", fmt.Errorf("reply requires a 32-byte parent hash") + } + directory, err = os.MkdirTemp(filepath.Dir(h.Filepath), ".fmsg-wire-") + if err != nil { + return nil, "", err + } + if err = os.Chmod(directory, 0750); err != nil { + _ = os.RemoveAll(directory) + return nil, "", err + } + defer func() { + if err != nil { + _ = os.RemoveAll(directory) + } + }() + h.Flags &^= FlagDeflate + h.ExpandedSize = 0 + h.Filepath, h.Size, h.ExpandedSize, err = preparePart(h.Filepath, h.Size, h.Type, filepath.Join(directory, "body")) + if err != nil { + return nil, directory, err + } + if h.ExpandedSize > 0 { + h.Flags |= FlagDeflate + } + for i := range h.Attachments { + a := &h.Attachments[i] + if len(a.Filename) == 0 || len(a.Filename) > 255 || len(a.Type) == 0 || len(a.Type) > 255 { + return nil, directory, fmt.Errorf("invalid attachment header") + } + a.Flags &^= 2 + a.Filepath, a.Size, a.ExpandedSize, err = preparePart(a.Filepath, a.Size, a.Type, filepath.Join(directory, fmt.Sprintf("attachment-%d", i))) + if err != nil { + return nil, directory, err + } + if a.ExpandedSize > 0 { + a.Flags |= 2 + } + } + ApplyCommonTypes(h) + _, err = h.GetMessageHash() + if err == nil { + err = syncDirectory(directory) + } + if err == nil { + err = syncDirectory(filepath.Dir(directory)) + } + return h, directory, err +} + +func syncDirectory(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +// Preserve retains an already verified incoming wire form without re-encoding +// its payloads. StoredWire is passed through the receiver's expansion step. +func Preserve(input *Header, parentDir string) (h *Header, directory string, err error) { + h = input.Clone() + directory, err = os.MkdirTemp(parentDir, ".fmsg-wire-") + if err != nil { + return nil, "", err + } + if err = os.Chmod(directory, 0750); err != nil { + _ = os.RemoveAll(directory) + return nil, "", err + } + defer func() { + if err != nil { + _ = os.RemoveAll(directory) + } + }() + copyPart := func(src, name string, size uint32) (string, error) { + in, e := os.Open(src) + if e != nil { + return "", e + } + defer in.Close() + path := filepath.Join(directory, name) + out, e := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640) + if e != nil { + return "", e + } + _, e = io.CopyN(out, in, int64(size)) + if e == nil { + e = out.Sync() + } + ce := out.Close() + if e == nil { + e = ce + } + return path, e + } + h.Filepath, err = copyPart(h.Filepath, "body", h.Size) + if err != nil { + return nil, directory, err + } + for i := range h.Attachments { + a := &h.Attachments[i] + a.Filepath, err = copyPart(a.Filepath, fmt.Sprintf("attachment-%d", i), a.Size) + if err != nil { + return nil, directory, err + } + } + if _, err = h.GetMessageHash(); err != nil { + return nil, directory, err + } + if err = syncDirectory(directory); err != nil { + return nil, directory, err + } + err = syncDirectory(parentDir) + return h, directory, err +} + +func preparePart(src string, size uint32, typ, dst string) (string, uint32, uint32, error) { + info, err := os.Stat(src) + if err != nil { + return "", 0, 0, err + } + if !info.Mode().IsRegular() || info.Size() != int64(size) { + return "", 0, 0, fmt.Errorf("payload length differs from metadata: %s", src) + } + expanded := uint32(0) + if ShouldCompress(typ, size) { + path, compressed, useful, err := TryCompress(src, size) + if err != nil { + return "", 0, 0, err + } + if useful { + defer os.Remove(path) + src, size, expanded = path, compressed, size + } + } + in, err := os.Open(src) + if err != nil { + return "", 0, 0, err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0640) + if err != nil { + return "", 0, 0, err + } + _, err = io.CopyN(out, in, int64(size)) + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err == nil { + err = closeErr + } + return dst, size, expanded, err +} + +// MarshalPrepared stores an internal, versioned snapshot, including durable +// payload paths. It is not an HTTP representation of a message. +func MarshalPrepared(h *Header) ([]byte, error) { + return json.Marshal(struct { + Version int + Header *Header + }{1, h.Clone()}) +} + +// UnmarshalPrepared verifies the snapshot against the persisted protocol hash. +// A missing or corrupt payload is an error, never a reason to re-encode it. +func UnmarshalPrepared(data, expected []byte) (*Header, error) { + var s struct { + Version int + Header *Header + } + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + if s.Version != 1 || s.Header == nil { + return nil, fmt.Errorf("unsupported prepared message") + } + h := s.Header.Clone() + hash, err := h.GetMessageHash() + if err != nil { + return nil, err + } + if len(expected) != 32 || !bytes.Equal(hash, expected) { + return nil, fmt.Errorf("prepared message hash mismatch") + } + return h, nil +} diff --git a/pkg/fmsg/prepare_test.go b/pkg/fmsg/prepare_test.go new file mode 100644 index 0000000..56b386e --- /dev/null +++ b/pkg/fmsg/prepare_test.go @@ -0,0 +1,69 @@ +package fmsg + +import ( + "bytes" + "crypto/sha256" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPreparedHashAndDurableRepresentation(t *testing.T) { + for _, body := range []string{"hello", strings.Repeat("compressible body ", 300)} { + t.Run(string(rune(len(body))), func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "body") + att := filepath.Join(dir, "attachment") + attachment := strings.Repeat("attachment content ", 100) + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(att, []byte(attachment), 0600); err != nil { + t.Fatal(err) + } + input := &Header{Version: 1, From: Address{User: "alice", Domain: "example.com"}, To: []Address{{User: "bob", Domain: "example.com"}}, Timestamp: 1234.125, Type: "text/plain;charset=UTF-8", Topic: "topic", Size: uint32(len(body)), Filepath: path, Attachments: []AttachmentHeader{{Filename: "note.txt", Type: "text/plain;charset=UTF-8", Size: uint32(len(attachment)), Filepath: att}}} + h, _, err := Prepare(input) + if err != nil { + t.Fatal(err) + } + manual := sha256.New() + manual.Write(h.Encode()) + manual.Write([]byte(body)) + manual.Write([]byte(attachment)) + got, err := h.GetMessageHash() + if err != nil || !bytes.Equal(got, manual.Sum(nil)) { + t.Fatal("wrong protocol hash", err) + } + if h.Flags&FlagCommonType == 0 || h.Attachments[0].Flags&2 == 0 { + t.Fatal("wire encoding was not finalized") + } + snapshot, err := MarshalPrepared(h) + if err != nil { + t.Fatal(err) + } + // Replacing the source files cannot change the immutable wire copy. + _ = os.WriteFile(path, []byte("edited draft"), 0600) + _ = os.Remove(att) + restored, err := UnmarshalPrepared(snapshot, got) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(restored.Encode(), h.Encode()) { + t.Fatal("wire header changed") + } + preserved, _, err := Preserve(restored, dir) + if err != nil { + t.Fatal(err) + } + preservedHash, err := preserved.GetMessageHash() + if err != nil || !bytes.Equal(preservedHash, got) { + t.Fatal("received identity changed", err) + } + _ = os.Remove(restored.Filepath) + if _, err = UnmarshalPrepared(snapshot, got); err == nil { + t.Fatal("missing immutable content accepted") + } + }) + } +} diff --git a/pkg/message/store.go b/pkg/message/store.go new file mode 100644 index 0000000..7d8e2e1 --- /dev/null +++ b/pkg/message/store.go @@ -0,0 +1,344 @@ +// Package message finalizes messages in the shared fmsg message store. +// Callers own the transaction and must commit before publishing a message. +package message + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "os" + "strings" + + "github.com/markmnl/fmsgd/pkg/fmsg" +) + +type Row interface{ Scan(...any) error } +type Rows interface { + Row + Next() bool + Err() error + Close() +} +type Tx interface { + QueryRow(context.Context, string, ...any) Row + Query(context.Context, string, ...any) (Rows, error) + Exec(context.Context, string, ...any) error +} + +// SQLTx adapts a database/sql transaction; pgx consumers supply a small adapter. +type SQLTx struct{ *sql.Tx } +type sqlRows struct{ *sql.Rows } + +func (r sqlRows) Close() { _ = r.Rows.Close() } +func (t SQLTx) QueryRow(c context.Context, q string, a ...any) Row { + return t.Tx.QueryRowContext(c, q, a...) +} +func (t SQLTx) Query(c context.Context, q string, a ...any) (Rows, error) { + r, e := t.Tx.QueryContext(c, q, a...) + if e != nil { + return nil, e + } + return sqlRows{r}, nil +} +func (t SQLTx) Exec(c context.Context, q string, a ...any) error { + _, e := t.Tx.ExecContext(c, q, a...) + return e +} + +// Files tracks newly prepared directories. Call Cleanup on rollback, including +// commit failure. Successful commits retain these files for future federation. +type Files []string + +func (f Files) Cleanup() { + for _, p := range f { + _ = os.RemoveAll(p) + } +} + +func Address(s string) (fmsg.Address, error) { + p := strings.Split(s, "@") + if len(p) != 3 || p[0] != "" || p[1] == "" || p[2] == "" || len(s) > 255 { + return fmsg.Address{}, fmt.Errorf("invalid address %q", s) + } + return fmsg.Address{User: p[1], Domain: p[2]}, nil +} + +type stored struct { + h *fmsg.Header + time *float64 + pid *int64 + hash, prepared []byte +} + +func load(ctx context.Context, tx Tx, id int64) (*stored, error) { + s := &stored{h: &fmsg.Header{}} + var from string + var noReply, important, terminal bool + err := tx.QueryRow(ctx, `SELECT version,pid,psha256,no_reply,is_important,is_terminal,time_sent,from_addr,topic,type,size,filepath,sha256,wire_message FROM msg WHERE id=$1 FOR UPDATE`, id).Scan(&s.h.Version, &s.pid, &s.h.Pid, &noReply, &important, &terminal, &s.time, &from, &s.h.Topic, &s.h.Type, &s.h.Size, &s.h.Filepath, &s.hash, &s.prepared) + if err != nil { + return nil, err + } + s.h.From, err = Address(from) + if err != nil { + return nil, err + } + if noReply { + s.h.Flags |= fmsg.FlagNoReply + } + if important { + s.h.Flags |= fmsg.FlagImportant + } + if terminal { + s.h.Flags |= fmsg.FlagTerminal + } + if s.time != nil { + s.h.Timestamp = *s.time + } + if s.pid != nil || len(s.h.Pid) > 0 { + s.h.Flags |= fmsg.FlagHasPid + } + r, err := tx.Query(ctx, `SELECT addr FROM msg_to WHERE msg_id=$1 ORDER BY id`, id) + if err != nil { + return nil, err + } + for r.Next() { + var a string + if err = r.Scan(&a); err != nil { + break + } + var addr fmsg.Address + addr, err = Address(a) + if err != nil { + break + } + s.h.To = append(s.h.To, addr) + } + if err == nil { + err = r.Err() + } + r.Close() + if err != nil { + return nil, err + } + r, err = tx.Query(ctx, `SELECT type,filename,filesize,filepath FROM msg_attachment WHERE msg_id=$1 ORDER BY position,filename`, id) + if err != nil { + return nil, err + } + for r.Next() { + var a fmsg.AttachmentHeader + if err = r.Scan(&a.Type, &a.Filename, &a.Size, &a.Filepath); err != nil { + break + } + s.h.Attachments = append(s.h.Attachments, a) + } + if err == nil { + err = r.Err() + } + r.Close() + return s, err +} + +// Finalize stamps and hashes a draft, or backfills an unhashed sent message at +// its original time. Parents must already have identities. Existing hashes +// are never replaced. The caller must check ownership/draft status separately. +func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Files) ([]byte, error) { + s, err := load(ctx, tx, id) + if err != nil { + return nil, err + } + if len(s.hash) > 0 { + return s.hash, nil + } + if s.time != nil { + timestamp = *s.time + } + s.h.Timestamp = timestamp + if s.pid != nil { + var parentHash []byte + if err = tx.QueryRow(ctx, `SELECT sha256 FROM msg WHERE id=$1 AND time_sent IS NOT NULL AND NOT is_terminal`, *s.pid).Scan(&parentHash); err != nil { + return nil, fmt.Errorf("parent unavailable: %w", err) + } + if len(parentHash) != 32 { + return nil, fmt.Errorf("parent %d needs hash backfill first", *s.pid) + } + if len(s.h.Pid) == 0 { + s.h.Pid = parentHash + } + } + if len(s.h.Pid) > 0 && len(s.h.Pid) != 32 { + return nil, fmt.Errorf("invalid parent hash") + } + if s.time != nil { + // A previously hashed child with a missing parent hash cannot be + // repaired without changing an established protocol identity. + var inconsistent bool + if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM msg WHERE pid=$1 AND sha256 IS NOT NULL AND psha256 IS NULL)`, id).Scan(&inconsistent); err != nil { + return nil, err + } + if inconsistent { + return nil, fmt.Errorf("message %d has hashed children without parent hashes; manual repair required", id) + } + } + h, dir, err := fmsg.Prepare(s.h) + if err != nil { + return nil, err + } + *files = append(*files, dir) + hash, err := h.GetMessageHash() + if err != nil { + return nil, err + } + snapshot, err := fmsg.MarshalPrepared(h) + if err != nil { + return nil, err + } + if err = tx.Exec(ctx, `UPDATE msg SET time_sent=$2,sha256=$3,psha256=$4,wire_header=$5,wire_message=$6 WHERE id=$1`, id, timestamp, hash, h.Pid, h.Encode(), string(snapshot)); err != nil { + return nil, err + } + if err = tx.Exec(ctx, `UPDATE msg SET psha256=$2 WHERE pid=$1 AND psha256 IS NULL AND sha256 IS NULL`, id, hash); err != nil { + return nil, err + } + rows, err := tx.Query(ctx, `SELECT id FROM msg_add_to_batch WHERE msg_id=$1 AND sha256 IS NULL ORDER BY id`, id) + if err != nil { + return nil, err + } + var batches []int64 + for rows.Next() { + var b int64 + if err = rows.Scan(&b); err != nil { + break + } + batches = append(batches, b) + } + if err == nil { + err = rows.Err() + } + rows.Close() + if err != nil { + return nil, err + } + for _, b := range batches { + if err = tx.Exec(ctx, `UPDATE msg_add_to_batch SET time_added=GREATEST(time_added,$2) WHERE id=$1 AND sha256 IS NULL`, b, timestamp); err != nil { + return nil, err + } + if _, err = FinalizeBatch(ctx, tx, id, b, files); err != nil { + return nil, err + } + } + return hash, nil +} + +// FinalizeBatch seals one add-to exchange. Its payload representation is copied +// from the original (or a received batch), never recompressed independently. +func FinalizeBatch(ctx context.Context, tx Tx, id, batchID int64, files *Files) ([]byte, error) { + s, err := load(ctx, tx, id) + if err != nil { + return nil, err + } + if s.time == nil { + return nil, nil + } // a draft's batches finalize with its send + if len(s.hash) != 32 { + return nil, fmt.Errorf("message %d needs hash backfill first", id) + } + var from string + var timestamp float64 + var existing []byte + if err = tx.QueryRow(ctx, `SELECT add_to_from,time_added,sha256 FROM msg_add_to_batch WHERE id=$1 AND msg_id=$2 FOR UPDATE`, batchID, id).Scan(&from, ×tamp, &existing); err != nil { + return nil, err + } + if len(existing) > 0 { + return existing, nil + } + var h *fmsg.Header + if len(s.prepared) > 0 { + h, err = fmsg.UnmarshalPrepared(s.prepared, s.hash) + } else { + // A host first receiving this message through add-to holds a batch's + // exact payload representation and the original hash carried as pid. + var snapshot, batchHash []byte + err = tx.QueryRow(ctx, `SELECT wire_message,sha256 FROM msg_add_to_batch WHERE msg_id=$1 AND wire_message IS NOT NULL ORDER BY id LIMIT 1`, id).Scan(&snapshot, &batchHash) + if err == nil { + h, err = fmsg.UnmarshalPrepared(snapshot, batchHash) + } else { + // Legacy local rows can be upgraded only if preparation reproduces + // their existing identity. Do not replace a published hash. + var dir string + h, dir, err = fmsg.Prepare(s.h) + if err == nil { + *files = append(*files, dir) + var got []byte + got, err = h.GetMessageHash() + if err == nil && !bytes.Equal(got, s.hash) { + h = h.Clone() + h.Flags &^= fmsg.FlagCommonType + for i := range h.Attachments { + h.Attachments[i].Flags &^= 1 + } + got, err = h.GetMessageHash() + if err == nil && !bytes.Equal(got, s.hash) { + err = fmt.Errorf("cannot reproduce legacy message %d; existing hash preserved", id) + } + } + if err == nil { + var data []byte + data, err = fmsg.MarshalPrepared(h) + if err == nil { + err = tx.Exec(ctx, `UPDATE msg SET wire_message=$2 WHERE id=$1`, id, string(data)) + } + } + } + } + } + if err != nil { + return nil, err + } + h = h.Clone() + h.Flags |= fmsg.FlagHasPid | fmsg.FlagHasAddTo + h.Pid = s.hash + h.Timestamp = timestamp + h.Topic = "" + h.AddTo = nil + addr, err := Address(from) + if err != nil { + return nil, err + } + h.AddToFrom = &addr + r, err := tx.Query(ctx, `SELECT addr FROM msg_add_to WHERE batch_id=$1 ORDER BY id`, batchID) + if err != nil { + return nil, err + } + for r.Next() { + var a string + if err = r.Scan(&a); err != nil { + break + } + var addr fmsg.Address + addr, err = Address(a) + if err != nil { + break + } + h.AddTo = append(h.AddTo, addr) + } + if err == nil { + err = r.Err() + } + r.Close() + if err != nil { + return nil, err + } + if len(h.AddTo) == 0 || len(h.AddTo) > 255 { + return nil, fmt.Errorf("invalid add-to recipient count") + } + hash, err := h.GetMessageHash() + if err != nil { + return nil, err + } + data, err := fmsg.MarshalPrepared(h) + if err != nil { + return nil, err + } + err = tx.Exec(ctx, `UPDATE msg_add_to_batch SET sha256=$2,wire_message=$3 WHERE id=$1`, batchID, hash, string(data)) + return hash, err +} diff --git a/pkg/message/store_integration_test.go b/pkg/message/store_integration_test.go new file mode 100644 index 0000000..8ceb4da --- /dev/null +++ b/pkg/message/store_integration_test.go @@ -0,0 +1,194 @@ +package message + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/markmnl/fmsgd/pkg/fmsg" +) + +func testStore(t *testing.T) (*sql.DB, string) { + t.Helper() + dsn := os.Getenv("FMSG_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set FMSG_TEST_DATABASE_URL to test PostgreSQL finalization") + } + admin, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + schema := fmt.Sprintf("hash_store_%d", time.Now().UnixNano()) + if _, err = admin.Exec("CREATE SCHEMA " + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec("DROP SCHEMA " + schema + " CASCADE"); admin.Close() }) + u, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + db, err := sql.Open("postgres", u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + dd, err := os.ReadFile("../../dd.sql") + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(string(dd)); err != nil { + t.Fatal(err) + } + return db, string(dd) +} +func insertDraft(t *testing.T, db *sql.DB, parent any, body string) int64 { + t.Helper() + path := filepath.Join(t.TempDir(), "data") + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + var id int64 + if err := db.QueryRow(`INSERT INTO msg(version,pid,from_addr,topic,type,size,filepath) VALUES(1,$1,'@alice@example.com','','text/plain;charset=UTF-8',$2,$3) RETURNING id`, parent, len(body), path).Scan(&id); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO msg_to(msg_id,addr) VALUES($1,'@bob@example.com')`, id); err != nil { + t.Fatal(err) + } + return id +} +func seal(t *testing.T, db *sql.DB, id int64) []byte { + t.Helper() + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + var files Files + hash, err := Finalize(context.Background(), SQLTx{tx}, id, 1234.5, &files) + if err != nil { + files.Cleanup() + t.Fatal(err) + } + if err = tx.Commit(); err != nil { + files.Cleanup() + t.Fatal(err) + } + return hash +} +func TestFinalizeBackfillAndImmutability(t *testing.T) { + db, dd := testStore(t) + // Simulate the old local-only writer, then migrate with existing replies. + if _, err := db.Exec(`DROP TRIGGER trg_msg_require_hash ON msg`); err != nil { + t.Fatal(err) + } + root := insertDraft(t, db, nil, "root") + if _, err := db.Exec(`UPDATE msg SET time_sent=100 WHERE id=$1`, root); err != nil { + t.Fatal(err) + } + child := insertDraft(t, db, root, "child") + if _, err := db.Exec(`UPDATE msg SET time_sent=101 WHERE id=$1`, child); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(dd); err != nil { + t.Fatal(err) + } + hash := seal(t, db, root) + childHash := seal(t, db, child) + if len(hash) != 32 || len(childHash) != 32 { + t.Fatal("missing identities") + } + if got := seal(t, db, root); !bytes.Equal(hash, got) { + t.Fatal("backfill is not idempotent") + } + var stamp float64 + var parent []byte + if err := db.QueryRow(`SELECT time_sent,psha256 FROM msg WHERE id=$1`, child).Scan(&stamp, &parent); err != nil { + t.Fatal(err) + } + if stamp != 101 || !bytes.Equal(parent, hash) { + t.Fatal("backfill altered timestamp or lost parent") + } + for _, query := range []string{ + `UPDATE msg SET time_sent=102 WHERE id=$1`, + `UPDATE msg SET topic='changed' WHERE id=$1`, + `UPDATE msg SET sha256=NULL WHERE id=$1`, + `UPDATE msg_to SET addr='@mallory@example.com' WHERE msg_id=$1`, + `INSERT INTO msg_to(msg_id,addr) VALUES($1,'@carol@example.com')`, + `DELETE FROM msg_to WHERE msg_id=$1`, + `INSERT INTO msg_attachment(msg_id,filename,filesize,filepath) VALUES($1,'new.txt',0,'')`, + } { + if _, err := db.Exec(query, root); err == nil { + t.Fatalf("immutable mutation allowed: %s", query) + } + } + if _, err := db.Exec(`UPDATE msg_to SET time_read=102,response_code=200 WHERE msg_id=$1`, root); err != nil { + t.Fatal("receipt mutation rejected", err) + } + if _, err := db.Exec(`INSERT INTO msg_to(msg_id,addr) VALUES($1,'@bob@example.com') ON CONFLICT DO NOTHING`, root); err != nil { + t.Fatal("duplicate receipt rejected", err) + } + newDraft := insertDraft(t, db, nil, "draft") + if _, err := db.Exec(`UPDATE msg SET time_sent=102 WHERE id=$1`, newDraft); err == nil { + t.Fatal("committed sent message without hash") + } +} +func TestFinalizeDraftBatchesAndRollback(t *testing.T) { + db, _ := testStore(t) + root := insertDraft(t, db, nil, "root with draft batch") + var batch int64 + if err := db.QueryRow(`INSERT INTO msg_add_to_batch(msg_id,add_to_from,time_added) VALUES($1,'@alice@example.com',1234.5) RETURNING id`, root).Scan(&batch); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO msg_add_to(msg_id,batch_id,addr) VALUES($1,$2,'@carol@example.com')`, root, batch); err != nil { + t.Fatal(err) + } + hash := seal(t, db, root) + var batchHash, snapshot []byte + if err := db.QueryRow(`SELECT sha256,wire_message FROM msg_add_to_batch WHERE id=$1`, batch).Scan(&batchHash, &snapshot); err != nil { + t.Fatal(err) + } + h, err := fmsg.UnmarshalPrepared(snapshot, batchHash) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(hash, batchHash) || !bytes.Equal(h.Pid, hash) || len(h.AddTo) != 1 { + t.Fatal("wrong batch identity") + } + if _, err = db.Exec(`UPDATE msg_add_to SET addr='@mallory@example.com' WHERE batch_id=$1`, batch); err == nil { + t.Fatal("batch recipients mutable") + } + if _, err = db.Exec(`UPDATE msg_add_to_batch SET time_added=9999 WHERE id=$1`, batch); err == nil { + t.Fatal("batch timestamp mutable") + } + // A failed commit removes its staged payloads and leaves the draft intact. + draft := insertDraft(t, db, nil, "rollback") + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + var files Files + if _, err = Finalize(context.Background(), SQLTx{tx}, draft, 1234.5, &files); err != nil { + t.Fatal(err) + } + _ = tx.Rollback() + files.Cleanup() + for _, path := range files { + if _, err = os.Stat(path); !os.IsNotExist(err) { + t.Fatal("orphaned wire files", err) + } + } + var sent *float64 + if err = db.QueryRow(`SELECT time_sent FROM msg WHERE id=$1`, draft).Scan(&sent); err != nil || sent != nil { + t.Fatal("rollback stamped message", err) + } +} From 7c25270536742217e685d9d80f93a05d86680346 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 10 Sep 2026 15:35:48 +0800 Subject: [PATCH 2/6] Retain compression metadata when revisiting received batches --- cmd/fmsgd/store.go | 17 +++++++++++++++++ pkg/message/store.go | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/fmsgd/store.go b/cmd/fmsgd/store.go index 8692818..f6f9ddb 100644 --- a/cmd/fmsgd/store.go +++ b/cmd/fmsgd/store.go @@ -951,6 +951,23 @@ func loadMsg(tx *sql.Tx, msgID int64) (*FMsgHeader, error) { } h := m.originalHeader() + if m.wire == nil { + // When the original arrived through add-to, its batch retains the + // exact wire payloads; the API files have already been expanded. + batches, err := loadAddToBatches(tx, msgID) + if err != nil { + return nil, err + } + for _, b := range batches { + if len(b.Prepared) > 0 { + h, err = fmsg.UnmarshalPrepared(b.Prepared, b.Hash) + if err != nil { + return nil, err + } + break + } + } + } if len(addTo) > 0 { // The wire pid of an add-to message references the shared message, not // that message's relational parent (SPEC §12). diff --git a/pkg/message/store.go b/pkg/message/store.go index 7d8e2e1..c14756e 100644 --- a/pkg/message/store.go +++ b/pkg/message/store.go @@ -193,7 +193,7 @@ func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Fi if err != nil { return nil, err } - if err = tx.Exec(ctx, `UPDATE msg SET time_sent=$2,sha256=$3,psha256=$4,wire_header=$5,wire_message=$6 WHERE id=$1`, id, timestamp, hash, h.Pid, h.Encode(), string(snapshot)); err != nil { + if err = tx.Exec(ctx, `UPDATE msg SET time_sent=$2,sha256=$3,psha256=$4,wire_header=$5,wire_message=$6,is_deflate=$7 WHERE id=$1`, id, timestamp, hash, h.Pid, h.Encode(), string(snapshot), h.Flags&fmsg.FlagDeflate != 0); err != nil { return nil, err } if err = tx.Exec(ctx, `UPDATE msg SET psha256=$2 WHERE pid=$1 AND psha256 IS NULL AND sha256 IS NULL`, id, hash); err != nil { From e2ac5c8656aa62ae2eefb9f9c6938a19894b7779 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 10 Sep 2026 15:42:11 +0800 Subject: [PATCH 3/6] Retain immutable payloads when commit outcome is uncertain --- cmd/fmsg-backfill/main.go | 4 +++- cmd/fmsgd/host.go | 9 +++++---- pkg/message/store.go | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/cmd/fmsg-backfill/main.go b/cmd/fmsg-backfill/main.go index a8ee6dd..9bedfd3 100644 --- a/cmd/fmsg-backfill/main.go +++ b/cmd/fmsg-backfill/main.go @@ -118,8 +118,9 @@ func finalize(ctx context.Context, db *sql.DB, id, batch int64) error { defer tx.Rollback() var files message.Files committed := false + commitAttempted := false defer func() { - if !committed { + if !committed && !commitAttempted { files.Cleanup() } }() @@ -131,6 +132,7 @@ func finalize(ctx context.Context, db *sql.DB, id, batch int64) error { if err != nil { return err } + commitAttempted = true err = tx.Commit() committed = err == nil return err diff --git a/cmd/fmsgd/host.go b/cmd/fmsgd/host.go index 6223cc1..091f6d9 100644 --- a/cmd/fmsgd/host.go +++ b/cmd/fmsgd/host.go @@ -1654,10 +1654,11 @@ func downloadMessage(c net.Conn, r io.Reader, h *FMsgHeader, skipData bool) erro localOutcome[strings.ToLower(addrs[i].ToString())] = codes[i] } - stored := storeAcceptedMessage(h, codes, acceptedTo, acceptedAddTo, localOutcome, primaryFilepath) - if stored { - wireStored = true - } + // If a commit acknowledgement is lost, storage may have succeeded. + // Retain the durable wire files once storage is attempted; an orphan is + // preferable to deleting bytes referenced by a committed identity. + wireStored = len(acceptedTo)+len(acceptedAddTo) > 0 + storeAcceptedMessage(h, codes, acceptedTo, acceptedAddTo, localOutcome, primaryFilepath) return rejectAccept(c, codes) } diff --git a/pkg/message/store.go b/pkg/message/store.go index c14756e..350c875 100644 --- a/pkg/message/store.go +++ b/pkg/message/store.go @@ -46,8 +46,8 @@ func (t SQLTx) Exec(c context.Context, q string, a ...any) error { return e } -// Files tracks newly prepared directories. Call Cleanup on rollback, including -// commit failure. Successful commits retain these files for future federation. +// Files tracks newly prepared directories. Clean up only after definite rollback. +// Retain files on an ambiguous commit error: the database may have committed. type Files []string func (f Files) Cleanup() { From 3f1e434d65a93c8b5e69a6b39f3fe372645c0078 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 10 Sep 2026 15:49:44 +0800 Subject: [PATCH 4/6] Run integration CI against matching API and stack branches --- .github/workflows/integration-test.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3dd3c3d..15d5db1 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -11,15 +11,26 @@ jobs: - name: Trigger and wait for integration test env: GH_TOKEN: ${{ secrets.FMSG_DOCKER_PAT }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} run: | - FMSGD_REF="${{ github.event.pull_request.head.ref }}" + FMSGD_REF="$PR_BRANCH" + matching_ref() { + if gh api "repos/markmnl/$1/git/ref/heads/$PR_BRANCH" >/dev/null 2>&1; then + printf '%s\n' "$PR_BRANCH" + else + echo main + fi + } + DOCKER_REF=$(matching_ref fmsg-docker) + WEBAPI_REF=$(matching_ref fmsg-webapi) + DISPATCH_STARTED=$(date -u +%Y-%m-%dT%H:%M:%SZ) - # Trigger the integration test workflow in fmsg-docker, - # passing the PR branch so it builds fmsgd from the PR + # Coordinated schema/API changes use their companion branches. gh workflow run integration-test.yml \ --repo markmnl/fmsg-docker \ - --ref main \ - -f fmsgd_ref="$FMSGD_REF" + --ref "$DOCKER_REF" \ + -f fmsgd_ref="$FMSGD_REF" \ + -f fmsg_webapi_ref="$WEBAPI_REF" echo "Triggered integration test for fmsgd_ref=$FMSGD_REF, polling for run..." @@ -30,9 +41,12 @@ jobs: RUN_ID=$(gh run list \ --repo markmnl/fmsg-docker \ --workflow integration-test.yml \ + --event workflow_dispatch \ + --branch "$DOCKER_REF" \ + --created ">=$DISPATCH_STARTED" \ --limit 10 \ --json databaseId,displayTitle \ - --jq ".[] | select(.displayTitle | contains(\"$FMSGD_REF\")) | .databaseId" \ + | jq -r --arg ref "$FMSGD_REF" '.[] | select(.displayTitle | contains($ref)) | .databaseId' \ | head -1) if [ -n "$RUN_ID" ]; then break From 17cb088bca99337b8456c6111dc6121514aa650c Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 10 Sep 2026 16:11:05 +0800 Subject: [PATCH 5/6] Move legacy conversion into standalone offline migration binary --- .github/workflows/go1.25.yml | 16 +- README.md | 41 ++- cmd/fmsg-backfill/main.go | 175 +++++------ cmd/fmsg-backfill/migrate.go | 390 ++++++++++++++++++++++++ cmd/fmsg-backfill/migrate_test.go | 291 ++++++++++++++++++ cmd/fmsg-backfill/testdata/previous.sql | 389 +++++++++++++++++++++++ cmd/fmsg-backfill/wire.go | 228 ++++++++++++++ cmd/fmsgd/common_type_test.go | 87 +----- cmd/fmsgd/sender.go | 229 +------------- cmd/fmsgd/sender_hash_test.go | 47 +-- cmd/fmsgd/store.go | 200 ++---------- cmd/fmsgd/store_test.go | 14 - dd.sql | 249 ++++++--------- pkg/message/store.go | 63 +--- pkg/message/store_integration_test.go | 37 +-- schema.go | 9 + 16 files changed, 1619 insertions(+), 846 deletions(-) create mode 100644 cmd/fmsg-backfill/migrate.go create mode 100644 cmd/fmsg-backfill/migrate_test.go create mode 100644 cmd/fmsg-backfill/testdata/previous.sql create mode 100644 cmd/fmsg-backfill/wire.go create mode 100644 schema.go diff --git a/.github/workflows/go1.25.yml b/.github/workflows/go1.25.yml index 6ddf095..5188b6a 100644 --- a/.github/workflows/go1.25.yml +++ b/.github/workflows/go1.25.yml @@ -10,6 +10,20 @@ jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + FMSG_TEST_DATABASE_URL: postgres://postgres@localhost:5432/postgres?sslmode=disable steps: - uses: actions/checkout@v3 @@ -22,4 +36,4 @@ jobs: run: go build -v ./... - name: Test - run: go test -v ./... + run: go test -race ./... diff --git a/README.md b/README.md index baa632a..396069e 100644 --- a/README.md +++ b/README.md @@ -157,26 +157,41 @@ access to the shared files (normally the same service user/group). The API keeps expanded downloadable content separately. New received messages also preserve their wire payloads before expanding the downloadable copies. -Upgrade the daemon, API and schema together while message writes and federation are -paused: install compatible binaries, rerun `dd.sql`, backfill, then resume services. -The schema refuses a newly committed sent message without a 32-byte hash. It is not -compatible with an older API that stamps only `time_sent`. Existing hashes are never -replaced by the migration. +`dd.sql` bootstraps a new, empty database. The daemon and API require finalized +sent messages and do not repair old rows during normal operation. -Build the maintenance command with `go build -o fmsg-backfill ./cmd/fmsg-backfill`. -It uses the same standard `PG*` connection variables as the daemon and must have -access to the stored file paths. First inspect, then apply: +For an existing installation, build the single standalone migration binary: + +```sh +CGO_ENABLED=0 go build -o fmsg-backfill ./cmd/fmsg-backfill +``` + +The binary embeds the schema changes; no SQL scripts, source checkout or running +services are needed on the target host. It upgrades the pre-finalization schema +(with `wire_header` and add-to batch hashes) and can also verify a completed migration. +It uses the standard `PG*` connection variables. Run it as the service account with +write access to the database and every stored payload path, including shared volumes. + +Stop both services and back up the message database and data directory together. +Then validate and apply the conversion before starting the matching daemon and API: ```sh ./fmsg-backfill -domain example.com ./fmsg-backfill -domain example.com -apply ``` -The default invocation lists pending local messages and batches without writing. -`-apply` preserves timestamps and finalizes parents before children; it can be rerun. -Missing files, inconsistent already-hashed children, or legacy representations that -cannot reproduce an existing hash are reported with a nonzero exit status. Resolve -these records before resuming dependent delivery; hashes are not silently rewritten. +The default is a full dry run: it reconstructs and verifies every sent message and +batch, then rolls back schema/data changes and removes staged files. `-apply` commits +the schema and data together in one transaction. It preserves timestamps, message IDs +and all published hashes, finalizes local-only parents before replies, and prepares +existing federated messages for later delivery. A successful run installs the strict +schema; **do not rerun `dd.sql` against the existing database**. + +Missing files, inconsistent reply identities or representations that cannot reproduce +a published hash fail the entire migration. Old received compression must be +reconstructible with the exact declared wire size; otherwise recover the original wire +payload before upgrading. Resolve reported records and rerun while services remain +stopped. The command is separate from the daemon and is not bundled in its image. A process crash before commit may leave an unreferenced `.fmsg-wire-*` directory; only remove such directories after checking both snapshot columns for references. diff --git a/cmd/fmsg-backfill/main.go b/cmd/fmsg-backfill/main.go index 9bedfd3..ab2e0bc 100644 --- a/cmd/fmsg-backfill/main.go +++ b/cmd/fmsg-backfill/main.go @@ -1,4 +1,5 @@ -// fmsg-backfill assigns missing identities without changing sent timestamps. +// fmsg-backfill upgrades the pre-finalization message store offline. All legacy +// reconstruction and schema conversion live in this standalone command. package main import ( @@ -6,134 +7,112 @@ import ( "database/sql" "flag" "fmt" + "io" "log" "os" + "regexp" + "strings" _ "github.com/lib/pq" - "github.com/markmnl/fmsgd/pkg/message" + "github.com/markmnl/fmsgd" ) func main() { - if err := run(); err != nil { - log.Print(err) - os.Exit(1) - } -} -func run() error { domain := flag.String("domain", "", "local sending domain (required)") - apply := flag.Bool("apply", false, "write hashes; default only lists pending messages") + apply := flag.Bool("apply", false, "commit schema and data conversion; default validates then rolls back") flag.Parse() if *domain == "" { - return fmt.Errorf("-domain is required") + log.Fatal("-domain is required") } - db, err := sql.Open("postgres", "") - if err != nil { - return err + db, err := sql.Open("postgres", "") // standard PG* environment variables + if err == nil { + defer db.Close() + err = migrate(context.Background(), db, *domain, *apply, os.Stdout) } - defer db.Close() - ctx := context.Background() - rows, err := db.QueryContext(ctx, `SELECT id FROM msg WHERE time_sent IS NOT NULL AND sha256 IS NULL AND lower(split_part(from_addr,'@',3))=lower($1) ORDER BY id`, *domain) if err != nil { - return err - } - var pending []int64 - for rows.Next() { - var id int64 - if err = rows.Scan(&id); err != nil { - break - } - pending = append(pending, id) - } - if err == nil { - err = rows.Err() + log.Print(err) + os.Exit(1) } - rows.Close() +} + +// One transaction owns both the schema change and every converted row. The +// operator stops services first; NOWAIT also refuses a store still in use. +func migrate(ctx context.Context, db *sql.DB, domain string, apply bool, out io.Writer) error { + tx, err := db.BeginTx(ctx, nil) if err != nil { return err } - if !*apply { - for _, id := range pending { - fmt.Printf("message %d needs finalization\n", id) - } - } else { - for len(pending) > 0 { - var remaining []int64 - for _, id := range pending { - err = finalize(ctx, db, id, 0) - if err != nil { - remaining = append(remaining, id) - log.Printf("message %d: %v", id, err) - } else { - fmt.Printf("finalized message %d\n", id) - } - } - if len(remaining) == len(pending) { - return fmt.Errorf("%d messages could not be finalized; repair reported data and rerun", len(remaining)) + m := &migration{tx: tx, domain: domain, visiting: make(map[int64]bool), done: make(map[int64]bool)} + commitAttempted := false + defer func() { + _ = tx.Rollback() + // A lost COMMIT acknowledgement does not prove rollback. Retain the + // files in that case and let the next run verify committed snapshots. + if !commitAttempted { + for _, dir := range m.files { + _ = os.RemoveAll(dir) } - pending = remaining } - } - rows, err = db.QueryContext(ctx, `SELECT b.msg_id,b.id FROM msg_add_to_batch b JOIN msg m ON m.id=b.msg_id WHERE m.time_sent IS NOT NULL AND b.sha256 IS NULL AND lower(split_part(b.add_to_from,'@',3))=lower($1) ORDER BY b.id`, *domain) - if err != nil { - return err - } - var batches [][2]int64 - for rows.Next() { - var b [2]int64 - if err = rows.Scan(&b[0], &b[1]); err != nil { - break + }() + if _, err = tx.ExecContext(ctx, `LOCK TABLE msg,msg_to,msg_attachment,msg_add_to_batch,msg_add_to,msg_add_to_notify IN ACCESS EXCLUSIVE MODE NOWAIT`); err != nil { + return fmt.Errorf("stop daemon and API before migration: %w", err) + } + // Bootstrap SQL remains plain CREATE statements. Only this command knows + // the previous schema and how to replace its triggers in place. + _, functions, ok := strings.Cut(fmsgd.Schema, "-- Functions and triggers.\n") + if !ok { + return fmt.Errorf("embedded schema has no functions section") + } + triggerPattern := regexp.MustCompile(`(?s)create (?:constraint )?trigger (\w+)\s+.*?\bon (\w+)\s`) + for _, match := range triggerPattern.FindAllStringSubmatch(functions, -1) { + if _, err = tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+match[1]+" ON "+match[2]); err != nil { + return err } - batches = append(batches, b) } - if err == nil { - err = rows.Err() + if _, err = tx.ExecContext(ctx, ` + DROP TRIGGER IF EXISTS trg_msg_prevent_unreferenceable_parent ON msg; + DROP FUNCTION IF EXISTS prevent_referenced_msg_from_becoming_unreferenceable(); + ALTER TABLE msg ADD COLUMN IF NOT EXISTS wire_message jsonb; + ALTER TABLE msg_add_to_batch ADD COLUMN IF NOT EXISTS wire_message jsonb; + CREATE INDEX IF NOT EXISTS msg_add_to_batch_sha256_idx ON msg_add_to_batch (sha256) WHERE sha256 IS NOT NULL; + CREATE INDEX IF NOT EXISTS msg_pid_idx ON msg (pid) WHERE pid IS NOT NULL; + `); err != nil { + return err } - rows.Close() + ids, err := m.ids(`SELECT id FROM msg WHERE time_sent IS NOT NULL ORDER BY id`) if err != nil { return err } - failed := 0 - for _, b := range batches { - if !*apply { - fmt.Printf("batch %d of message %d needs finalization\n", b[1], b[0]) - continue + for _, id := range ids { + if err = m.message(id); err != nil { + return fmt.Errorf("message %d: %w; database changes rolled back", id, err) } - if err = finalize(ctx, db, b[0], b[1]); err != nil { - log.Printf("batch %d: %v", b[1], err) - failed++ - } else { - fmt.Printf("finalized batch %d\n", b[1]) - } - } - if failed > 0 { - return fmt.Errorf("%d batches could not be finalized", failed) + fmt.Fprintf(out, "verified message %d\n", id) } - return nil -} -func finalize(ctx context.Context, db *sql.DB, id, batch int64) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { + // Draft children may have existed before their local parent had a hash. + if _, err = tx.ExecContext(ctx, `UPDATE msg child SET psha256=parent.sha256 FROM msg parent WHERE child.pid=parent.id AND child.psha256 IS NULL AND child.time_sent IS NULL`); err != nil { return err } - defer tx.Rollback() - var files message.Files - committed := false - commitAttempted := false - defer func() { - if !committed && !commitAttempted { - files.Cleanup() - } - }() - if batch == 0 { - _, err = message.Finalize(ctx, message.SQLTx{Tx: tx}, id, 0, &files) - } else { - _, err = message.FinalizeBatch(ctx, message.SQLTx{Tx: tx}, id, batch, &files) + if err = m.validate(); err != nil { + return err } - if err != nil { + // Replace function definitions only here, without duplicating them or + // carrying upgrade statements in the bootstrap schema. + functions = strings.ReplaceAll(functions, "create function ", "create or replace function ") + if _, err = tx.ExecContext(ctx, functions); err != nil { return err } + if !apply { + if err = tx.Rollback(); err != nil { + return err + } + fmt.Fprintln(out, "Dry run passed; schema, data and staged files rolled back. Run with -apply to commit.") + return nil + } commitAttempted = true - err = tx.Commit() - committed = err == nil - return err + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit outcome uncertain; keep payload files and rerun to verify: %w", err) + } + fmt.Fprintln(out, "Migration committed. Start the matching daemon and API; do not rerun dd.sql.") + return nil } diff --git a/cmd/fmsg-backfill/migrate.go b/cmd/fmsg-backfill/migrate.go new file mode 100644 index 0000000..4c490f6 --- /dev/null +++ b/cmd/fmsg-backfill/migrate.go @@ -0,0 +1,390 @@ +package main + +import ( + "bytes" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/markmnl/fmsgd/pkg/fmsg" +) + +type migration struct { + tx *sql.Tx + domain string + files []string + visiting map[int64]bool + done map[int64]bool +} + +type oldMessage struct { + id int64 + pid *int64 + time *float64 + h *fmsg.Header // expanded payload paths from the old store + hash []byte + wire []byte + prepared []byte +} + +type oldBatch struct { + id int64 + from fmsg.Address + time float64 + to []fmsg.Address + hash []byte + prepared []byte +} + +func address(s string) (fmsg.Address, error) { + p := strings.Split(s, "@") + if len(p) != 3 || p[0] != "" || p[1] == "" || p[2] == "" || len(s) > 255 { + return fmsg.Address{}, fmt.Errorf("invalid stored address %q", s) + } + return fmsg.Address{User: p[1], Domain: p[2]}, nil +} + +func (m *migration) ids(query string, args ...any) ([]int64, error) { + rows, err := m.tx.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err = rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (m *migration) addresses(query string, id int64) ([]fmsg.Address, error) { + rows, err := m.tx.Query(query, id) + if err != nil { + return nil, err + } + defer rows.Close() + var list []fmsg.Address + for rows.Next() { + var raw string + if err = rows.Scan(&raw); err != nil { + return nil, err + } + a, err := address(raw) + if err != nil { + return nil, err + } + list = append(list, a) + } + return list, rows.Err() +} + +func (m *migration) load(id int64) (*oldMessage, error) { + s := &oldMessage{id: id, h: &fmsg.Header{}} + var from string + var noReply, important, terminal bool + err := m.tx.QueryRow(`SELECT version,pid,psha256,no_reply,is_important,is_terminal,time_sent,from_addr,topic,type,size,filepath,sha256,wire_header,wire_message FROM msg WHERE id=$1`, id).Scan(&s.h.Version, &s.pid, &s.h.Pid, &noReply, &important, &terminal, &s.time, &from, &s.h.Topic, &s.h.Type, &s.h.Size, &s.h.Filepath, &s.hash, &s.wire, &s.prepared) + if err != nil { + return nil, err + } + s.h.From, err = address(from) + if err != nil { + return nil, err + } + if noReply { + s.h.Flags |= fmsg.FlagNoReply + } + if important { + s.h.Flags |= fmsg.FlagImportant + } + if terminal { + s.h.Flags |= fmsg.FlagTerminal + } + if s.time != nil { + s.h.Timestamp = *s.time + } + if s.pid != nil || len(s.h.Pid) > 0 { + s.h.Flags |= fmsg.FlagHasPid + } + s.h.To, err = m.addresses(`SELECT addr FROM msg_to WHERE msg_id=$1 ORDER BY id`, id) + if err != nil { + return nil, err + } + rows, err := m.tx.Query(`SELECT type,filename,filesize,filepath FROM msg_attachment WHERE msg_id=$1 ORDER BY position,filename`, id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var a fmsg.AttachmentHeader + if err = rows.Scan(&a.Type, &a.Filename, &a.Size, &a.Filepath); err != nil { + return nil, err + } + s.h.Attachments = append(s.h.Attachments, a) + } + return s, rows.Err() +} + +func (m *migration) batches(id int64) ([]oldBatch, error) { + ids, err := m.ids(`SELECT id FROM msg_add_to_batch WHERE msg_id=$1 ORDER BY id`, id) + if err != nil { + return nil, err + } + var batches []oldBatch + for _, id := range ids { + b := oldBatch{id: id} + var from string + if err = m.tx.QueryRow(`SELECT add_to_from,time_added,sha256,wire_message FROM msg_add_to_batch WHERE id=$1`, id).Scan(&from, &b.time, &b.hash, &b.prepared); err != nil { + return nil, err + } + b.from, err = address(from) + if err != nil { + return nil, err + } + b.to, err = m.addresses(`SELECT addr FROM msg_add_to WHERE batch_id=$1 ORDER BY id`, id) + if err != nil { + return nil, err + } + batches = append(batches, b) + } + return batches, nil +} + +func (m *migration) message(id int64) error { + if m.done[id] { + return nil + } + if m.visiting[id] { + return fmt.Errorf("cyclic parent links at message %d", id) + } + m.visiting[id] = true + defer delete(m.visiting, id) + s, err := m.load(id) + if err != nil { + return err + } + if s.time == nil { + return fmt.Errorf("sent child references draft parent %d", id) + } + if s.pid != nil { + if err = m.message(*s.pid); err != nil { + return fmt.Errorf("parent %d: %w", *s.pid, err) + } + if len(s.h.Pid) == 0 { + if len(s.hash) > 0 { + return fmt.Errorf("hashed reply has no parent hash; cannot change its identity") + } + if err = m.tx.QueryRow(`SELECT sha256 FROM msg WHERE id=$1`, *s.pid).Scan(&s.h.Pid); err != nil { + return err + } + } + } + batches, err := m.batches(id) + if err != nil { + return err + } + var base, received *fmsg.Header + if len(s.prepared) > 0 { + base, err = fmsg.UnmarshalPrepared(s.prepared, s.hash) + } else if len(s.wire) > 0 { + received, err = decodeHeader(s.wire) + if err == nil && received.Flags&fmsg.FlagHasAddTo != 0 { + for _, b := range batches { + if len(b.prepared) > 0 { + candidate, e := fmsg.UnmarshalPrepared(b.prepared, b.hash) + if e != nil { + return e + } + if bytes.Equal(candidate.Encode(), s.wire) { + received = candidate + break + } + } + } + } + if err == nil && received.Filepath == "" { + received, err = m.restore(received, s.h) + } + if err == nil && received.Flags&fmsg.FlagHasAddTo == 0 { + base = received + } else if err == nil { + // The canonical row represents an original known only by the pid + // of the received add-to. Its original header is not recoverable. + if !bytes.Equal(received.Pid, s.hash) { + return fmt.Errorf("received batch pid differs from canonical identity") + } + } + } else { + // Rerunning against a fully migrated add-to-only original is valid. + for _, b := range batches { + if len(b.prepared) > 0 { + received, err = fmsg.UnmarshalPrepared(b.prepared, b.hash) + break + } + } + if received == nil && err == nil { + if len(s.hash) == 0 && !strings.EqualFold(s.h.From.Domain, m.domain) { + return fmt.Errorf("remote message has no published hash or wire header") + } + base, err = m.reconstruct(s.h, s.hash) + } + } + if err != nil { + return err + } + if base != nil { + hash, err := base.GetMessageHash() + if err != nil { + return err + } + if len(s.hash) > 0 && !bytes.Equal(hash, s.hash) { + return fmt.Errorf("cannot reproduce published message hash; identity preserved") + } + s.hash = hash + data, err := fmsg.MarshalPrepared(base) + if err != nil { + return err + } + if _, err = m.tx.Exec(`UPDATE msg SET sha256=$2,psha256=$3,wire_header=$4,wire_message=$5,is_deflate=$6 WHERE id=$1`, id, hash, bytesOrNull(base.Pid), base.Encode(), string(data), base.Flags&fmsg.FlagDeflate != 0); err != nil { + return err + } + } else { + base = received + } + if base == nil || len(s.hash) != 32 { + return fmt.Errorf("missing canonical identity or payload representation") + } + matchedReceived := received == nil || received.Flags&fmsg.FlagHasAddTo == 0 + for _, b := range batches { + h := batchHeader(base, s.hash, b) + if received != nil && bytes.Equal(h.Encode(), received.Encode()) { + matchedReceived = true + } + if len(b.prepared) > 0 { + h, err = fmsg.UnmarshalPrepared(b.prepared, b.hash) + } else { + if len(b.hash) == 0 && !strings.EqualFold(b.from.Domain, m.domain) && + (received == nil || !bytes.Equal(h.Encode(), received.Encode())) { + return fmt.Errorf("remote batch %d has no published hash or exact header", b.id) + } + h, err = selectTypes(h, b.hash) + } + if err != nil { + return fmt.Errorf("batch %d: %w", b.id, err) + } + if !bytes.Equal(h.Pid, s.hash) { + return fmt.Errorf("batch %d references a different original", b.id) + } + hash, err := h.GetMessageHash() + if err != nil { + return err + } + data, err := fmsg.MarshalPrepared(h) + if err != nil { + return err + } + if _, err = m.tx.Exec(`UPDATE msg_add_to_batch SET sha256=$2,wire_message=$3 WHERE id=$1`, b.id, hash, string(data)); err != nil { + return err + } + } + if !matchedReceived { + return fmt.Errorf("received wire header has no matching add-to batch") + } + m.done[id] = true + return nil +} + +func batchHeader(base *fmsg.Header, hash []byte, b oldBatch) *fmsg.Header { + h := base.Clone() + h.Flags |= fmsg.FlagHasPid | fmsg.FlagHasAddTo + h.Pid, h.Timestamp, h.Topic = hash, b.time, "" + h.AddToFrom, h.AddTo = &b.from, b.to + return h +} + +// Local sends previously selected compression and common types during the +// first network delivery. Try those historical forms only in this tool, and +// accept a candidate only if it reproduces the entire existing message hash. +func (m *migration) reconstruct(raw *fmsg.Header, expected []byte) (*fmsg.Header, error) { + h, dir, err := fmsg.Prepare(raw) + if err != nil { + return nil, err + } + if chosen, err := selectTypes(h, expected); err == nil { + m.files = append(m.files, dir) + return chosen, nil + } + _ = os.RemoveAll(dir) + h, dir, err = fmsg.Preserve(raw, filepath.Dir(raw.Filepath)) + if err != nil { + return nil, err + } + chosen, err := selectTypes(h, expected) + if err != nil { + _ = os.RemoveAll(dir) + return nil, err + } + m.files = append(m.files, dir) + return chosen, nil +} + +func selectTypes(h *fmsg.Header, expected []byte) (*fmsg.Header, error) { + for _, mode := range []int{0, 1, 2} { + c := h.Clone() + switch mode { + case 1: + fmsg.ApplyCommonTypes(c) + case 2: + c.Flags &^= fmsg.FlagCommonType + for i := range c.Attachments { + c.Attachments[i].Flags &^= 1 + } + } + hash, err := c.GetMessageHash() + if err != nil { + return nil, err + } + if len(expected) == 0 || bytes.Equal(expected, hash) { + return c, nil + } + } + return nil, fmt.Errorf("cannot reproduce published hash; identity preserved") +} + +func (m *migration) validate() error { + var invalid int + err := m.tx.QueryRow(`SELECT count(*) FROM msg m WHERE + (m.time_sent IS NOT NULL AND (m.sha256 IS NULL OR octet_length(m.sha256)<>32 OR + (m.wire_message IS NULL AND NOT EXISTS (SELECT 1 FROM msg_add_to_batch b WHERE b.msg_id=m.id AND b.wire_message IS NOT NULL)))) OR + (m.pid IS NOT NULL AND NOT EXISTS (SELECT 1 FROM msg p WHERE p.id=m.pid AND p.time_sent IS NOT NULL AND NOT p.is_terminal AND + (m.psha256=p.sha256 OR EXISTS (SELECT 1 FROM msg_add_to_batch b WHERE b.msg_id=p.id AND b.sha256=m.psha256)))) OR + (m.time_sent IS NULL AND (m.sha256 IS NOT NULL OR m.wire_message IS NOT NULL))`).Scan(&invalid) + if err != nil { + return err + } + if invalid != 0 { + return fmt.Errorf("%d messages violate finalized identity or parent invariants", invalid) + } + err = m.tx.QueryRow(`SELECT count(*) FROM msg_add_to_batch b JOIN msg m ON m.id=b.msg_id WHERE + m.is_terminal OR (m.time_sent IS NOT NULL AND + (b.sha256 IS NULL OR octet_length(b.sha256)<>32 OR b.wire_message IS NULL))`).Scan(&invalid) + if err != nil { + return err + } + if invalid != 0 { + return fmt.Errorf("%d batches violate finalized identity invariants", invalid) + } + return nil +} + +func bytesOrNull(b []byte) any { + if len(b) == 0 { + return nil + } + return b +} diff --git a/cmd/fmsg-backfill/migrate_test.go b/cmd/fmsg-backfill/migrate_test.go new file mode 100644 index 0000000..bede330 --- /dev/null +++ b/cmd/fmsg-backfill/migrate_test.go @@ -0,0 +1,291 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/markmnl/fmsgd/pkg/fmsg" +) + +func previousStore(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("FMSG_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set FMSG_TEST_DATABASE_URL to test the standalone migration") + } + admin, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + schema := fmt.Sprintf("backfill_%d", time.Now().UnixNano()) + if _, err = admin.Exec("CREATE SCHEMA " + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Exec("DROP SCHEMA " + schema + " CASCADE"); admin.Close() }) + u, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + db, err := sql.Open("postgres", u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + dd, err := os.ReadFile("testdata/previous.sql") + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(string(dd)); err != nil { + t.Fatal(err) + } + return db +} + +func rawMessage(t *testing.T, domain, content string) *fmsg.Header { + t.Helper() + path := filepath.Join(t.TempDir(), "body") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + return &fmsg.Header{Version: 1, From: fmsg.Address{User: "alice", Domain: domain}, + To: []fmsg.Address{{User: "bob", Domain: "example.com"}}, Timestamp: 1234.5, + Topic: "stored", Type: "text/plain;charset=UTF-8", Filepath: path, Size: uint32(len(content))} +} + +func putOld(t *testing.T, db *sql.DB, raw *fmsg.Header, pid any, hash, header []byte) int64 { + t.Helper() + var id int64 + if err := db.QueryRow(`INSERT INTO msg(version,pid,psha256,time_sent,from_addr,topic,type,size,filepath,sha256,wire_header,no_reply,is_important,is_terminal) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id`, raw.Version, pid, bytesOrNull(raw.Pid), raw.Timestamp, raw.From.ToString(), raw.Topic, raw.Type, raw.Size, raw.Filepath, bytesOrNull(hash), bytesOrNull(header), + raw.Flags&fmsg.FlagNoReply != 0, raw.Flags&fmsg.FlagImportant != 0, raw.Flags&fmsg.FlagTerminal != 0).Scan(&id); err != nil { + t.Fatal(err) + } + for _, a := range raw.To { + if _, err := db.Exec(`INSERT INTO msg_to(msg_id,addr,time_delivered,response_code) VALUES($1,$2,1235,200)`, id, a.ToString()); err != nil { + t.Fatal(err) + } + } + for i, a := range raw.Attachments { + if _, err := db.Exec(`INSERT INTO msg_attachment(msg_id,position,type,filename,filesize,filepath) VALUES($1,$2,$3,$4,$5,$6)`, id, i, a.Type, a.Filename, a.Size, a.Filepath); err != nil { + t.Fatal(err) + } + } + return id +} + +func putBatch(t *testing.T, db *sql.DB, id int64, b oldBatch) int64 { + t.Helper() + var bid int64 + if err := db.QueryRow(`INSERT INTO msg_add_to_batch(msg_id,add_to_from,time_added,sha256) VALUES($1,$2,$3,$4) RETURNING id`, id, b.from.ToString(), b.time, bytesOrNull(b.hash)).Scan(&bid); err != nil { + t.Fatal(err) + } + for _, a := range b.to { + if _, err := db.Exec(`INSERT INTO msg_add_to(msg_id,batch_id,addr) VALUES($1,$2,$3)`, id, bid, a.ToString()); err != nil { + t.Fatal(err) + } + } + return bid +} + +func prepared(t *testing.T, raw *fmsg.Header) *fmsg.Header { + t.Helper() + h, dir, err := fmsg.Prepare(raw) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return h +} +func hashOf(t *testing.T, h *fmsg.Header) []byte { + t.Helper() + hash, err := h.GetMessageHash() + if err != nil { + t.Fatal(err) + } + return hash +} + +func TestStandaloneMigration(t *testing.T) { + db := previousStore(t) + rootRaw := rawMessage(t, "example.com", "local root") + root := putOld(t, db, rootRaw, nil, nil, nil) + childRaw := rawMessage(t, "example.com", "local reply") + childRaw.Flags = fmsg.FlagHasPid + child := putOld(t, db, childRaw, root, nil, nil) + localBatch := putBatch(t, db, root, oldBatch{from: rootRaw.From, time: 1300, to: []fmsg.Address{{User: "carol", Domain: "example.com"}}}) + + // Published string-form hash must remain string-form, even though new + // finalization chooses common type IDs. + oldString := rawMessage(t, "example.com", "published") + oldHash := hashOf(t, oldString) + published := putOld(t, db, oldString, nil, oldHash, nil) + + // Received compression and mixed type encodings must preserve the exact + // wire header. Expanded body and attachment files are all the old store has. + remote := rawMessage(t, "example.org", strings.Repeat("compressible content ", 400)) + att := rawMessage(t, "example.org", strings.Repeat("attachment ", 300)) + remote.Attachments = []fmsg.AttachmentHeader{{Type: "text/plain;charset=UTF-8", Filename: "note.txt", Size: att.Size, Filepath: att.Filepath}} + remoteWire := prepared(t, remote).Clone() + remoteWire.Attachments[0].Flags &^= 1 + remoteHash := hashOf(t, remoteWire) + received := putOld(t, db, remote, nil, remoteHash, remoteWire.Encode()) + + // First delivery through add-to retains the canonical hash carried as pid, + // and prepares the batch without inventing an original header. + batchRaw := rawMessage(t, "example.org", strings.Repeat("forwarded content ", 400)) + batchBase := prepared(t, batchRaw) + canonicalHash := hashOf(t, batchBase) + b := oldBatch{from: batchRaw.From, time: 1400, to: []fmsg.Address{{User: "carol", Domain: "example.com"}}} + batchWire := batchHeader(batchBase, canonicalHash, b) + b.hash = hashOf(t, batchWire) + batchRaw.Timestamp = b.time + addToOnly := putOld(t, db, batchRaw, nil, canonicalHash, batchWire.Encode()) + receivedBatch := putBatch(t, db, addToOnly, b) + + // A dry run exercises the full conversion, then removes its columns and files. + if err := migrate(context.Background(), db, "example.com", false, io.Discard); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_schema=current_schema() AND column_name='wire_message'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("dry run changed schema: %d %v", count, err) + } + for _, raw := range []*fmsg.Header{rootRaw, childRaw, oldString} { + matches, _ := filepath.Glob(filepath.Join(filepath.Dir(raw.Filepath), ".fmsg-wire-*")) + if len(matches) != 0 { + t.Fatal("dry run left files", matches) + } + } + if err := migrate(context.Background(), db, "example.com", true, io.Discard); err != nil { + t.Fatal(err) + } + var rootHash []byte + var snapshots = make(map[int64][]byte) + for _, id := range []int64{root, child, published, received, addToOnly} { + var hash, data, parent []byte + var stamp float64 + if err := db.QueryRow(`SELECT sha256,wire_message,psha256,time_sent FROM msg WHERE id=$1`, id).Scan(&hash, &data, &parent, &stamp); err != nil { + t.Fatal(err) + } + if id == root { + rootHash = hash + } + if id == child && !bytes.Equal(parent, rootHash) { + t.Fatal("reply parent not backfilled") + } + if id == published && !bytes.Equal(hash, oldHash) { + t.Fatal("published string hash changed") + } + if id == received && !bytes.Equal(hash, remoteHash) { + t.Fatal("received hash changed") + } + if id == addToOnly { + if !bytes.Equal(hash, canonicalHash) || len(data) != 0 || stamp != 1400 { + t.Fatal("invented original identity") + } + } else { + if stamp != 1234.5 { + t.Fatal("timestamp changed") + } + if _, err := fmsg.UnmarshalPrepared(data, hash); err != nil { + t.Fatal(err) + } + } + snapshots[id] = data + } + for _, id := range []int64{localBatch, receivedBatch} { + var hash, data []byte + if err := db.QueryRow(`SELECT sha256,wire_message FROM msg_add_to_batch WHERE id=$1`, id).Scan(&hash, &data); err != nil { + t.Fatal(err) + } + if _, err := fmsg.UnmarshalPrepared(data, hash); err != nil { + t.Fatal(err) + } + if id == receivedBatch && !bytes.Equal(hash, b.hash) { + t.Fatal("received batch identity changed") + } + } + if err := migrate(context.Background(), db, "example.com", true, io.Discard); err != nil { + t.Fatal("rerun", err) + } + for id, expected := range snapshots { + var got []byte + db.QueryRow(`SELECT wire_message FROM msg WHERE id=$1`, id).Scan(&got) + if !bytes.Equal(expected, got) { + t.Fatal("rerun changed snapshot", id) + } + } + if _, err := db.Exec(`UPDATE msg SET topic='changed' WHERE id=$1`, root); err == nil { + t.Fatal("migration did not install strict immutability") + } + if _, err := db.Exec(`UPDATE msg SET wire_message=NULL WHERE id=$1`, root); err == nil { + t.Fatal("migration permits clearing snapshot") + } + if _, err := db.Exec(`UPDATE msg_to SET time_read=2000 WHERE msg_id=$1`, root); err != nil { + t.Fatal("receipt blocked", err) + } + if _, err := db.Exec(`INSERT INTO msg(version,time_sent,from_addr,topic,type,size,filepath) VALUES(1,1234,'@alice@example.com','','text/plain',0,'')`); err == nil { + t.Fatal("migration permits sent message without identity") + } +} + +func TestMigrationFailureRollsBackEverything(t *testing.T) { + for _, cause := range []string{"missing file", "hash mismatch", "hashed child without parent hash"} { + t.Run(cause, func(t *testing.T) { + db := previousStore(t) + raw := rawMessage(t, "example.com", "good") + root := putOld(t, db, raw, nil, nil, nil) + bad := rawMessage(t, "example.com", "bad") + switch cause { + case "missing file": + putOld(t, db, bad, nil, nil, nil) + os.Remove(bad.Filepath) + case "hash mismatch": + putOld(t, db, bad, nil, bytes.Repeat([]byte{7}, 32), nil) + case "hashed child without parent hash": + putOld(t, db, bad, root, bytes.Repeat([]byte{7}, 32), nil) + } + if err := migrate(context.Background(), db, "example.com", true, io.Discard); err == nil { + t.Fatal("unsafe migration succeeded") + } + var hash []byte + if err := db.QueryRow(`SELECT sha256 FROM msg WHERE id=$1`, root).Scan(&hash); err != nil || hash != nil { + t.Fatal("partial data conversion", err) + } + var count int + db.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_schema=current_schema() AND column_name='wire_message'`).Scan(&count) + if count != 0 { + t.Fatal("partial schema conversion") + } + files, _ := filepath.Glob(filepath.Join(filepath.Dir(raw.Filepath), ".fmsg-wire-*")) + if len(files) > 0 { + t.Fatal("rollback left staged files", files) + } + }) + } +} + +func TestDecodeHeaderRejectsTruncation(t *testing.T) { + h := prepared(t, rawMessage(t, "example.com", strings.Repeat("content ", 200))) + data := h.Encode() + for i := 0; i < len(data); i++ { + if _, err := decodeHeader(data[:i]); err == nil { + t.Fatalf("accepted prefix %d", i) + } + } + if _, err := decodeHeader(append(data, 0)); err == nil { + t.Fatal("accepted trailing data") + } +} diff --git a/cmd/fmsg-backfill/testdata/previous.sql b/cmd/fmsg-backfill/testdata/previous.sql new file mode 100644 index 0000000..2116073 --- /dev/null +++ b/cmd/fmsg-backfill/testdata/previous.sql @@ -0,0 +1,389 @@ +/**************************************************************** + * + * PostgreSQL database objects data definition for fmsgd + * + * This script is IDEMPOTENT: every statement is safe to re-run + * (create table/index if not exists, alter table add column if + * not exists, create or replace function, drop trigger if exists + * before create trigger). Migrating an existing database is + * therefore just re-running the whole script, e.g.: + * + * psql -d fmsgd -v ON_ERROR_STOP=1 -f dd.sql + * + * Keep it that way: add new objects and columns only with + * idempotent statements, and name indexes explicitly to match + * PostgreSQL's default generated names so indexes that already + * exist unnamed on live databases are recognised, not duplicated. + * + ****************************************************************/ + +-- database with encoding UTF8 should already be created and connected + +create table if not exists msg ( + id bigserial primary key, + version int not null, + pid bigint references msg (id), + no_reply boolean not null default false, + is_important boolean not null default false, + is_deflate boolean not null default false, + is_terminal boolean not null default false, -- SPEC §3 bit 6: leaf message, nothing may reference it via pid + time_sent double precision, -- time sending host recieved message for sending, message timestamp field, NULL means message not ready for sending i.e. draft + from_addr varchar(255) not null, + topic varchar(255) not null, + type varchar(255) not null, + sha256 bytea unique, + psha256 bytea, + size int not null, -- spec allows uint32 but we don't enforced by FMSG_MAX_MSG_SIZE + filepath text not null, + wire_header bytea -- received messages: the exact wire header bytes (fields 1-13), so any hash can always be faithfully recomputed (SPEC §11); null for locally-authored messages +); +create index if not exists msg_lower_idx on msg ((lower(from_addr))); +alter table msg add column if not exists wire_header bytea; -- upgrade path for databases created before this column +alter table msg add column if not exists is_terminal boolean not null default false; -- upgrade path (SPEC v0.6.0) + +create table if not exists msg_to ( + id bigserial primary key, + msg_id bigint not null references msg (id), + addr varchar(255) not null, + time_delivered double precision, -- if sending, time sending host recieved delivery confirmation, if receiving, time successfully received message + time_last_attempt double precision, -- only used when sending, time of last delivery attempt if failed; otherwise null + time_read double precision, -- time recipient read the message; null if unread + response_code smallint, -- when sending, response code of last delivery attempt if failed; when receiving, the per-recipient code this host responded, or a negative local sentinel (-1 attempt got no response, retryable; -2 recorded from an exchange, another host's delivery) + attempt_count int not null default 0, -- number of failed delivery attempts; used for exponential back-off + unique (msg_id, addr) +); +create index if not exists msg_to_lower_idx on msg_to ((lower(addr))); + +-- Each add-to delivery for a shared message is one batch: a single sender +-- (add_to_from) added a set of recipients at a point in time. Storing batches +-- separately lets readers reconstruct who added which recipients and when, +-- which a single flat recipient list cannot preserve (SPEC §12). A batch's +-- identity is its message hash (sha256), which covers the batch's time: the +-- same addresses re-issued at a new time are a distinct batch, not a +-- duplicate (SPEC §11/§12). sha256 is null for rows recorded before this +-- column existed and for locally originated batches not yet hashed. +create table if not exists msg_add_to_batch ( + id bigserial primary key, + msg_id bigint not null references msg (id), + add_to_from varchar(255) not null, -- sender that added this batch's recipients + time_added double precision not null, -- the batch message's wire time field (for locally originated batches, when the batch was created) + sha256 bytea -- batch message hash: the batch's identity (SPEC §11) +); +alter table msg_add_to_batch add column if not exists sha256 bytea; +create index if not exists msg_add_to_batch_msg_id_idx on msg_add_to_batch (msg_id); + +create table if not exists msg_add_to ( + id bigserial primary key, + msg_id bigint not null references msg (id), + batch_id bigint not null references msg_add_to_batch (id), -- batch this recipient was added in + addr varchar(255) not null, + time_delivered double precision, -- if sending, time sending host recieved delivery confirmation, if receiving, time successfully received message + time_last_attempt double precision, -- only used when sending, time of last delivery attempt if failed; otherwise null + time_read double precision, -- time recipient read the message; null if unread + response_code smallint, -- when sending, response code of last delivery attempt if failed; when receiving, the per-recipient code this host responded, or a negative local sentinel (-1 attempt got no response, retryable; -2 recorded from an exchange, another host's delivery) + attempt_count int not null default 0, -- number of failed delivery attempts; used for exponential back-off + unique (batch_id, addr) +); +-- An address is unique within a batch, not across batches: distinct batches +-- may re-add the same address (each batch is its own sibling branch, SPEC +-- §12). Migrate existing databases off the old per-message constraint. +alter table msg_add_to drop constraint if exists msg_add_to_msg_id_addr_key; +create unique index if not exists msg_add_to_batch_id_addr_key on msg_add_to (batch_id, addr); +create index if not exists msg_add_to_lower_idx on msg_add_to ((lower(addr))); +create index if not exists msg_add_to_batch_id_idx on msg_add_to (batch_id); + +create table if not exists msg_attachment ( + msg_id bigint references msg (id), + position smallint not null default 0, + flags smallint not null default 0, + type varchar(255) not null default 'application/octet-stream', + filename varchar(255) not null, + filesize int not null, + filepath text not null, + primary key (msg_id, filename) +); + +-- keep protocol parent hash populated for locally-created replies that set +-- the relational parent id. A reply cannot reference a draft parent or a +-- terminal parent (SPEC v0.6.0 §3: a Sending Host must not transmit a reply +-- to a terminal message, so refuse to create one), and any explicit psha256 +-- must match the referenced parent's sha256. +create or replace function populate_msg_psha256_from_pid() returns trigger as $$ +declare + parent_time_sent double precision; + parent_sha256 bytea; + parent_is_terminal boolean; +begin + if NEW.pid is null then + return NEW; + end if; + + select parent.time_sent, parent.sha256, parent.is_terminal + into parent_time_sent, parent_sha256, parent_is_terminal + from msg parent + where parent.id = NEW.pid; + + if not found then + raise exception 'parent message % does not exist', NEW.pid; + end if; + + if parent_time_sent is null then + raise exception 'cannot set pid %: parent message is a draft', NEW.pid; + end if; + + if parent_is_terminal then + raise exception 'cannot set pid %: parent message is terminal', NEW.pid; + end if; + + if parent_sha256 is null or octet_length(parent_sha256) = 0 then + -- parent was delivered locally only and has no sha256 yet; psha256 cannot be populated + return NEW; + end if; + + if NEW.psha256 is null or octet_length(NEW.psha256) = 0 then + NEW.psha256 = parent_sha256; + elsif NEW.psha256 <> parent_sha256 then + -- a reply may reference one of the parent's add-to batch messages by + -- its batch hash (SPEC §12); the relational parent is the shared row + if not exists ( + select 1 from msg_add_to_batch b + where b.msg_id = NEW.pid and b.sha256 = NEW.psha256 + ) then + raise exception 'psha256 does not match parent message % sha256 or any of its add-to batch hashes', NEW.pid; + end if; + end if; + + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_msg_populate_psha256 on msg; +create trigger trg_msg_populate_psha256 + before insert or update of pid, psha256 on msg + for each row execute function populate_msg_psha256_from_pid(); + +-- recipients cannot be added to a terminal message (SPEC §12): refuse to +-- create a batch for one, so the sender never has such a unit to transmit. +create or replace function prevent_add_to_terminal_msg() returns trigger as $$ +begin + if exists (select 1 from msg where id = NEW.msg_id and is_terminal) then + raise exception 'cannot add recipients to message %: it is terminal', NEW.msg_id; + end if; + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_msg_add_to_batch_terminal on msg_add_to_batch; +create trigger trg_msg_add_to_batch_terminal + before insert on msg_add_to_batch + for each row execute function prevent_add_to_terminal_msg(); + +-- once a message has replies, it must remain referenceable by protocol hash. +create or replace function prevent_referenced_msg_from_becoming_unreferenceable() returns trigger as $$ +begin + if exists (select 1 from msg child where child.pid = NEW.id) then + if NEW.time_sent is null then + raise exception 'cannot make message % a draft: it has replies', NEW.id; + end if; + + if OLD.sha256 is not null and (NEW.sha256 is null or octet_length(NEW.sha256) = 0) then + raise exception 'cannot clear sha256 for message %: it has replies', NEW.id; + end if; + + if OLD.sha256 is distinct from NEW.sha256 then + raise exception 'cannot change sha256 for message %: it has replies', NEW.id; + end if; + end if; + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_msg_prevent_unreferenceable_parent on msg; +create trigger trg_msg_prevent_unreferenceable_parent + before update of time_sent, sha256 on msg + for each row execute function prevent_referenced_msg_from_becoming_unreferenceable(); + +-- Notify the sender's outgoing worker (channel new_msg_to) whenever new +-- delivery work appears. One function serves all three triggers, dispatching +-- on the table it fired for: +-- * msg -- a draft message transitions to sent (time_sent set +-- for the first time); notify every recipient. +-- * msg_to/msg_add_to -- a recipient row is inserted against an already-sent +-- message (recipients added via add-to after the +-- message was sent, including a freshly inserted +-- message whose recipient rows follow in the same +-- transaction); notify that recipient. +-- The payload is advisory only: the worker re-polls fully on any wake-up. +create or replace function notify_msg_sent() returns trigger as $$ +begin + if TG_TABLE_NAME = 'msg' then + if OLD.time_sent is null and NEW.time_sent is not null then + perform pg_notify('new_msg_to', NEW.id::text || ',' || addr) + from msg_to where msg_id = NEW.id; + + perform pg_notify('new_msg_to', NEW.id::text || ',' || addr) + from msg_add_to where msg_id = NEW.id; + end if; + elsif NEW.time_delivered is null then + perform pg_notify('new_msg_to', NEW.msg_id::text || ',' || NEW.addr) + from msg where id = NEW.msg_id and time_sent is not null; + end if; + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_msg_to_insert on msg_to; +create trigger trg_msg_to_insert + after insert on msg_to + for each row execute function notify_msg_sent(); + +drop trigger if exists trg_msg_add_to_insert on msg_add_to; +create trigger trg_msg_add_to_insert + after insert on msg_add_to + for each row execute function notify_msg_sent(); + +drop trigger if exists trg_msg_sent on msg; +create trigger trg_msg_sent + after update on msg + for each row execute function notify_msg_sent(); + +-- Notify listeners (channel new_msg) that a message has become sent/arrived: +-- time_sent set for the first time, on insert (e.g. a message received from a +-- remote host) or update (a local draft being sent). Unlike new_msg_to this +-- fires regardless of recipient domain, so push-notification listeners can wake +-- without polling. Payload is ",", one notification per recipient +-- -- the listener checks addr against its currently-subscribed clients and only +-- fetches message detail for those that are connected. +-- +-- This is a DEFERRABLE constraint trigger so it runs at COMMIT: on insert the +-- msg row is written before its msg_to/msg_add_to rows (FK ordering), so a +-- plain row trigger would see no recipients. At commit every recipient row in +-- the transaction is visible. +create or replace function notify_new_msg() returns trigger as $$ +begin + if (TG_OP = 'INSERT' and NEW.time_sent is not null) or + (TG_OP = 'UPDATE' and OLD.time_sent is null and NEW.time_sent is not null) then + perform pg_notify('new_msg', NEW.id::text || ',' || addr) + from msg_to where msg_id = NEW.id; + + perform pg_notify('new_msg', NEW.id::text || ',' || addr) + from msg_add_to where msg_id = NEW.id; + end if; + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_new_msg on msg; +create constraint trigger trg_new_msg + after insert or update on msg + deferrable initially deferred + for each row execute function notify_new_msg(); + +-- Notify the sender (channel delivered) once a recipient's delivery is +-- confirmed, so the sender's UI can unlock replying without a manual reload. +-- Fires on the NULL -> non-NULL transition of time_delivered, which happens +-- once per recipient row regardless of who performs the UPDATE (fmsgd's own +-- remote delivery, its local-domain delivery, or fmsg-webapi's same-domain +-- delivery) -- triggering on the tables rather than the call site covers all +-- of them. Payload is ",", the same shape as new_msg's +-- payload but with the sender's address instead of the recipient's, since +-- it's the sender whose UI needs to react. Unlike trg_new_msg this does not +-- need to be deferred: the msg row referenced by msg_id already exists (FK) +-- by the time msg_to/msg_add_to is updated. +create or replace function notify_delivered() returns trigger as $$ +begin + perform pg_notify('delivered', NEW.msg_id::text || ',' || m.from_addr) + from msg m where m.id = NEW.msg_id; + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_msg_to_delivered on msg_to; +create trigger trg_msg_to_delivered + after update of time_delivered on msg_to + for each row + when (OLD.time_delivered is null and NEW.time_delivered is not null) + execute function notify_delivered(); + +drop trigger if exists trg_msg_add_to_delivered on msg_add_to; +create trigger trg_msg_add_to_delivered + after update of time_delivered on msg_add_to + for each row + when (OLD.time_delivered is null and NEW.time_delivered is not null) + execute function notify_delivered(); + +-- Sender-side state for add-to participant notification (SPEC §10.2): an +-- add-to message is sent to every participant domain of the message being +-- added to -- the domains of from and every to address as well as the new +-- recipients' -- so all participants learn recipients were added, not only +-- the domains hosting the new recipients. Domains hosting a recipient of the +-- batch itself learn through normal recipient delivery; every other +-- participant domain gets one row here per batch and receives the add-to as +-- a notification-only exchange completing at code 11. Rows are created by +-- the Web API when recipients are added through it (the local domain itself +-- needs no row -- this database is its record). +create table if not exists msg_add_to_notify ( + id bigserial primary key, + batch_id bigint not null references msg_add_to_batch (id), + domain varchar(255) not null, + time_notified double precision, -- time remote host acknowledged the batch; null means pending + time_last_attempt double precision, -- time of last failed attempt; drives exponential back-off + response_code smallint, -- response code of last attempt + attempt_count int not null default 0, + unique (batch_id, domain) +); + +-- Wake the sender's outgoing worker (channel new_msg_to) for a pending +-- participant notification, mirroring notify_msg_sent for recipient rows. +-- The payload is advisory only: the worker re-polls fully on any wake-up. +create or replace function notify_add_to_notify_pending() returns trigger as $$ +begin + perform pg_notify('new_msg_to', b.msg_id::text || ',' || NEW.domain) + from msg_add_to_batch b + inner join msg m on m.id = b.msg_id + where b.id = NEW.batch_id and m.time_sent is not null; + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_msg_add_to_notify_insert on msg_add_to_notify; +create trigger trg_msg_add_to_notify_insert + after insert on msg_add_to_notify + for each row execute function notify_add_to_notify_pending(); + +-- Notify listeners (channel recipients_added) that an add-to batch was +-- recorded against a sent message, so existing participants' clients learn of +-- the new recipients without polling. Fires wherever a batch is recorded -- +-- added locally through the Web API or received from a remote host -- because +-- both paths insert a msg_add_to_batch row. Payload is ",", one +-- notification per participant (from, every msg_to and every msg_add_to +-- address, including the new batch's own recipients, who have no other +-- realtime event for a message that was sent before they were added); the +-- listener checks addr against its currently-connected clients, exactly as +-- new_msg. Like trg_new_msg this is a deferred constraint trigger: the +-- batch's own msg_add_to rows are inserted after the batch row, so only at +-- commit is the full recipient set visible. +create or replace function notify_recipients_added() returns trigger as $$ +begin + if not exists (select 1 from msg where id = NEW.msg_id and time_sent is not null) then + return NEW; + end if; + + perform pg_notify('recipients_added', NEW.msg_id::text || ',' || from_addr) + from msg where id = NEW.msg_id; + + perform pg_notify('recipients_added', NEW.msg_id::text || ',' || addr) + from msg_to where msg_id = NEW.msg_id; + + perform pg_notify('recipients_added', NEW.msg_id::text || ',' || addr) + from msg_add_to where msg_id = NEW.msg_id; + + return NEW; +end; +$$ language plpgsql; + +drop trigger if exists trg_recipients_added on msg_add_to_batch; +create constraint trigger trg_recipients_added + after insert on msg_add_to_batch + deferrable initially deferred + for each row execute function notify_recipients_added(); diff --git a/cmd/fmsg-backfill/wire.go b/cmd/fmsg-backfill/wire.go new file mode 100644 index 0000000..6fe6934 --- /dev/null +++ b/cmd/fmsg-backfill/wire.go @@ -0,0 +1,228 @@ +package main + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/markmnl/fmsgd/pkg/fmsg" +) + +// Decode the old wire_header column offline, without network parser state or +// present-day timestamp limits. Round-tripping must reproduce every byte. +func decodeHeader(data []byte) (*fmsg.Header, error) { + d := headerReader{r: bytes.NewReader(data)} + h := &fmsg.Header{Version: d.byte(), Flags: d.byte()} + if h.Flags&fmsg.FlagHasPid != 0 { + h.Pid = d.take(32) + } + h.From = d.address() + h.To = d.addresses() + if h.Flags&fmsg.FlagHasAddTo != 0 { + a := d.address() + h.AddToFrom = &a + h.AddTo = d.addresses() + } + d.number(&h.Timestamp) + if h.Flags&fmsg.FlagHasPid == 0 { + h.Topic = d.text() + } + h.Type, h.TypeID = d.mediaType(h.Flags&fmsg.FlagCommonType != 0) + d.number(&h.Size) + if h.Flags&fmsg.FlagDeflate != 0 { + d.number(&h.ExpandedSize) + } + count := d.byte() + for i := 0; i < int(count); i++ { + a := fmsg.AttachmentHeader{Flags: d.byte()} + a.Type, a.TypeID = d.mediaType(a.Flags&1 != 0) + a.Filename = d.text() + d.number(&a.Size) + if a.Flags&2 != 0 { + d.number(&a.ExpandedSize) + } + if a.Flags&^uint8(3) != 0 { + d.err = fmt.Errorf("reserved attachment flags in stored header") + } + h.Attachments = append(h.Attachments, a) + } + if d.err != nil { + return nil, fmt.Errorf("invalid stored wire header: %w", d.err) + } + if h.Version != 1 || h.Flags&128 != 0 || len(h.To) == 0 || + (h.Flags&fmsg.FlagHasAddTo != 0 && (len(h.AddTo) == 0 || len(h.Pid) != 32)) || + d.r.Len() != 0 || !bytes.Equal(h.Encode(), data) { + return nil, fmt.Errorf("stored wire header does not round-trip") + } + return h, nil +} + +type headerReader struct { + r *bytes.Reader + err error +} + +func (d *headerReader) take(n int) []byte { + b := make([]byte, n) + if d.err == nil { + _, d.err = io.ReadFull(d.r, b) + } + return b +} +func (d *headerReader) byte() byte { return d.take(1)[0] } +func (d *headerReader) text() string { return string(d.take(int(d.byte()))) } +func (d *headerReader) number(v any) { + if d.err == nil { + d.err = binary.Read(d.r, binary.LittleEndian, v) + } +} +func (d *headerReader) address() fmsg.Address { + raw := d.text() + if d.err != nil { + return fmsg.Address{} + } + a, err := address(raw) + if err != nil { + d.err = err + } + return a +} +func (d *headerReader) addresses() []fmsg.Address { + n := int(d.byte()) + list := make([]fmsg.Address, n) + for i := range list { + list[i] = d.address() + } + return list +} +func (d *headerReader) mediaType(common bool) (string, uint8) { + if !common { + return d.text(), 0 + } + id := d.byte() + typ, ok := fmsg.GetCommonMediaType(id) + if !ok { + d.err = fmt.Errorf("unknown common media type %d", id) + } + return typ, id +} + +// Previous receivers retained expanded API files and the wire header, but not +// compressed payload files. Recreate a valid stream of the declared size. The +// protocol hashes expanded bytes, so compressed bytes need not be identical; +// the header (including wire size) and expanded bytes must be identical. +func (m *migration) restore(wire, raw *fmsg.Header) (*fmsg.Header, error) { + h := wire.Clone() + var temps []string + defer func() { + for _, path := range temps { + _ = os.Remove(path) + } + }() + part := func(path string, rawSize, size, expanded uint32, compressed bool) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + want := size + if compressed { + want = expanded + } + if !info.Mode().IsRegular() || info.Size() != int64(want) || rawSize != want { + return "", fmt.Errorf("stored payload length differs from wire header: %s", path) + } + if !compressed { + return path, nil + } + p, err := restoreCompressed(path, size) + if err == nil { + temps = append(temps, p) + } + return p, err + } + var err error + h.Filepath, err = part(raw.Filepath, raw.Size, h.Size, h.ExpandedSize, h.Flags&fmsg.FlagDeflate != 0) + if err != nil { + return nil, err + } + if len(h.Attachments) != len(raw.Attachments) { + return nil, fmt.Errorf("stored attachments differ from wire header") + } + for i := range h.Attachments { + a := &h.Attachments[i] + found := false + for _, r := range raw.Attachments { + if r.Filename == a.Filename { + a.Filepath, err = part(r.Filepath, r.Size, a.Size, a.ExpandedSize, a.Flags&2 != 0) + if err != nil { + return nil, err + } + found = true + break + } + } + if !found { + return nil, fmt.Errorf("missing attachment %s", a.Filename) + } + } + h, dir, err := fmsg.Preserve(h, filepath.Dir(raw.Filepath)) + if err == nil { + m.files = append(m.files, dir) + } + return h, err +} + +func restoreCompressed(path string, size uint32) (string, error) { + in, err := os.Open(path) + if err != nil { + return "", err + } + defer in.Close() + out, err := os.CreateTemp("", "fmsg-backfill-zlib-*") + if err != nil { + return "", err + } + keep := false + defer func() { + out.Close() + if !keep { + os.Remove(out.Name()) + } + }() + for _, level := range []int{zlib.DefaultCompression, 1, 9, 0, zlib.HuffmanOnly, 2, 3, 4, 5, 7, 8} { + if _, err = in.Seek(0, io.SeekStart); err != nil { + return "", err + } + if err = out.Truncate(0); err != nil { + return "", err + } + if _, err = out.Seek(0, io.SeekStart); err != nil { + return "", err + } + zw, err := zlib.NewWriterLevel(out, level) + if err != nil { + return "", err + } + _, copyErr := io.Copy(zw, in) + err = zw.Close() + if copyErr != nil { + return "", copyErr + } + if err != nil { + return "", err + } + n, err := out.Seek(0, io.SeekCurrent) + if err != nil { + return "", err + } + if n == int64(size) { + keep = true + return out.Name(), nil + } + } + return "", fmt.Errorf("cannot reconstruct %d-byte compressed representation of %s; restore original wire payload before upgrading", size, path) +} diff --git a/cmd/fmsgd/common_type_test.go b/cmd/fmsgd/common_type_test.go index f153f57..e28ae0b 100644 --- a/cmd/fmsgd/common_type_test.go +++ b/cmd/fmsgd/common_type_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "github.com/markmnl/fmsgd/pkg/fmsg" "os" "path/filepath" "testing" @@ -10,7 +11,7 @@ import ( // Outgoing headers encode Common Media Type IDs (SPEC §4) where the stored // type string has one, as FMSG-005 requires for reactions (ID 56). -func commonTypeTestFields(t *testing.T) *msgFields { +func commonTypeTestHeader(t *testing.T) *FMsgHeader { t.Helper() dir := t.TempDir() bodyPath := filepath.Join(dir, "data.txt") @@ -21,16 +22,16 @@ func commonTypeTestFields(t *testing.T) *msgFields { if err := os.WriteFile(attPath, []byte("png"), 0o600); err != nil { t.Fatal(err) } - return &msgFields{ - version: 1, - size: 4, - from: FMsgAddress{User: "alice", Domain: "example.com"}, - to: []FMsgAddress{{User: "bob", Domain: "example.org"}}, - timeSent: 1754280000, - topic: "types", - typ: "text/plain;charset=UTF-8", - filepath: bodyPath, - attachments: []FMsgAttachmentHeader{ + return &FMsgHeader{ + Version: 1, + Size: 4, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "example.org"}}, + Timestamp: 1754280000, + Topic: "types", + Type: "text/plain;charset=UTF-8", + Filepath: bodyPath, + Attachments: []FMsgAttachmentHeader{ {Type: "image/png", Filename: "pic.png", Size: 3, Filepath: attPath}, {Type: "application/x-custom", Filename: "custom.bin", Size: 3, Filepath: attPath}, }, @@ -38,8 +39,8 @@ func commonTypeTestFields(t *testing.T) *msgFields { } func TestApplyCommonTypesEncodesIDs(t *testing.T) { - h := commonTypeTestFields(t).originalHeader() - if !applyCommonTypes(h) { + h := commonTypeTestHeader(t) + if !fmsg.ApplyCommonTypes(h) { t.Fatal("applyCommonTypes reported no change") } if h.Flags&FlagCommonType == 0 || h.TypeID != 56 { @@ -58,65 +59,7 @@ func TestApplyCommonTypesEncodesIDs(t *testing.T) { if !bytes.Contains(wire, []byte("application/x-custom")) { t.Error("unmapped type string must appear on the wire") } - if applyCommonTypes(h) { + if fmsg.ApplyCommonTypes(h) { t.Error("second application must be a no-op") } } - -func TestEncodeForWireUsesCommonTypesForNewMessage(t *testing.T) { - m := commonTypeTestFields(t) - h, common, err := encodeForWire(m.originalHeader, deflateState{}, true, nil) - if err != nil { - t.Fatal(err) - } - if !common || h.Flags&FlagCommonType == 0 { - t.Errorf("new message should use common type IDs (common=%v flags=%#08b)", common, h.Flags) - } - h, common, err = encodeForWire(m.originalHeader, deflateState{}, false, nil) - if err != nil { - t.Fatal(err) - } - if common || h.Flags&FlagCommonType != 0 { - t.Errorf("commonTypes=false must keep string types (common=%v flags=%#08b)", common, h.Flags) - } -} - -// A message whose hash was recorded before this host encoded common type IDs -// keeps its string types, so every delivery reproduces the stored hash. -func TestEncodeForWireKeepsRecordedForm(t *testing.T) { - m := commonTypeTestFields(t) - - stringForm := m.originalHeader() - stringHash, err := stringForm.GetMessageHash() - if err != nil { - t.Fatal(err) - } - h, common, err := encodeForWire(m.originalHeader, deflateState{}, true, stringHash) - if err != nil { - t.Fatal(err) - } - if common || h.Flags&FlagCommonType != 0 { - t.Errorf("hash recorded in string form must keep string form (common=%v flags=%#08b)", common, h.Flags) - } - got, _ := h.GetMessageHash() - if !bytes.Equal(got, stringHash) { - t.Error("string form does not reproduce the recorded hash") - } - - commonForm := m.originalHeader() - applyCommonTypes(commonForm) - commonHash, err := commonForm.GetMessageHash() - if err != nil { - t.Fatal(err) - } - if bytes.Equal(commonHash, stringHash) { - t.Fatal("forms hash identically; test no longer discriminates") - } - h, common, err = encodeForWire(m.originalHeader, deflateState{}, true, commonHash) - if err != nil { - t.Fatal(err) - } - if !common || h.Flags&FlagCommonType == 0 { - t.Errorf("hash recorded in common form must keep common form (common=%v flags=%#08b)", common, h.Flags) - } -} diff --git a/cmd/fmsgd/sender.go b/cmd/fmsgd/sender.go index cd23046..a8eab6c 100644 --- a/cmd/fmsgd/sender.go +++ b/cmd/fmsgd/sender.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "crypto/tls" "database/sql" "encoding/hex" @@ -287,121 +286,6 @@ func updateNotify(tx *sql.Tx, notifyID int64, now float64, code int, notified bo } } -// deflatePart is one compressed payload (message body or attachment). -type deflatePart struct { - path string - size uint32 - expanded uint32 -} - -// deflateState captures the result of compressing a message's body and -// attachments once, so the same compressed payload can be applied to every -// outgoing wire header — the original message and each add-to batch share the -// shared message data (SPEC §12). -type deflateState struct { - body deflatePart - bodyUsed bool - atts []deflatePart - cleanup []string // temp files to remove once delivery completes -} - -// computeDeflate compresses the message body and each attachment of m where -// worthwhile. Apply the result to a unit header with applyTo; remove its temp -// files with removeTempFiles after delivery. -func computeDeflate(m *msgFields, msgID int64) deflateState { - var d deflateState - d.atts = make([]deflatePart, len(m.attachments)) - - if shouldCompress(m.typ, uint32(m.size)) { - dp, cs, ok, derr := tryCompress(m.filepath, uint32(m.size)) - if derr != nil { - log.Printf("WARN: sender: compress msg data for msg %d: %s", msgID, derr) - } else if ok { - log.Printf("INFO: sender: compressed msg %d data: %d -> %d bytes", msgID, m.size, cs) - d.body = deflatePart{path: dp, size: cs, expanded: uint32(m.size)} - d.bodyUsed = true - d.cleanup = append(d.cleanup, dp) - } - } - for i := range m.attachments { - att := m.attachments[i] - if !shouldCompress(att.Type, att.Size) { - continue - } - dp, cs, ok, derr := tryCompress(att.Filepath, att.Size) - if derr != nil { - log.Printf("WARN: sender: compress attachment %s for msg %d: %s", att.Filename, msgID, derr) - } else if ok { - log.Printf("INFO: sender: compressed msg %d attachment %s: %d -> %d bytes", msgID, att.Filename, att.Size, cs) - d.atts[i] = deflatePart{path: dp, size: cs, expanded: att.Size} - d.cleanup = append(d.cleanup, dp) - } - } - return d -} - -// applyTo rewrites h's body and attachment fields to send the compressed -// payloads, setting the corresponding deflate flags. -func (d deflateState) applyTo(h *FMsgHeader) { - if d.bodyUsed { - h.Filepath = d.body.path - h.ExpandedSize = d.body.expanded - h.Size = d.body.size - h.Flags |= FlagDeflate - } - for i := range h.Attachments { - if i >= len(d.atts) || d.atts[i].path == "" { - continue - } - h.Attachments[i].Filepath = d.atts[i].path - h.Attachments[i].ExpandedSize = d.atts[i].expanded - h.Attachments[i].Size = d.atts[i].size - h.Attachments[i].Flags |= 1 << 1 - } -} - -// applyCommonTypes encodes h's type, and each attachment's type, as a Common -// Media Type ID (SPEC §4) where the type string has one, so the wire carries -// one byte instead of the string. Standards such as FMSG-005 require the ID -// form. It reports whether anything changed. -func applyCommonTypes(h *FMsgHeader) bool { return fmsg.ApplyCommonTypes(h) } - -// encodeForWire builds a unit header in its transmitted form: build, apply -// deflate, then, when commonTypes is set, common type IDs. The message hash -// covers the header exactly as transmitted, so when storedHash was recorded by -// an earlier delivery the form that reproduces it wins: a message first sent -// before this host encoded common type IDs keeps string types for every later -// delivery. It reports whether common type IDs were used, so a message's -// add-to batches can follow the original's form. -func encodeForWire(build func() *FMsgHeader, d deflateState, commonTypes bool, storedHash []byte) (*FMsgHeader, bool, error) { - h := build() - d.applyTo(h) - if !commonTypes || !applyCommonTypes(h) { - return h, commonTypes, nil - } - if len(storedHash) == 0 { - return h, true, nil - } - hash, err := h.GetMessageHash() - if err != nil { - return nil, false, err - } - if bytes.Equal(hash, storedHash) { - return h, true, nil - } - // Recorded in string form before this host encoded common type IDs. - h = build() - d.applyTo(h) - return h, false, nil -} - -// removeTempFiles deletes the compression temp files. -func (d deflateState) removeTempFiles() { - for _, p := range d.cleanup { - _ = os.Remove(p) - } -} - // lockPendingRecipients locks (FOR UPDATE SKIP LOCKED) the undelivered, // retryable rows in `table` for one message on `domain`, returning the locked // addresses. For msg_add_to it locks only rows in batchID, so each add-to batch @@ -490,61 +374,9 @@ func deliverMessage(target pendingTarget) { } rtx.Rollback() - // Compress the shared payload once; every unit header reuses it. Deflate - // must be applied BEFORE the shared hash is computed: the message hash - // covers the header fields exactly as transmitted (SPEC "Message hash"), - // and applyTo changes flags, size and expanded size. Hashing the - // undeflated form recorded a sha256 the receiving host never computes, - // so cross-host replies bounced with code 6 (parent not found). - var d deflateState - defer func() { d.removeTempFiles() }() - var orig *FMsgHeader - useCommonTypes := true - sharedHash := m.storedHash - if m.wire != nil { - orig = m.wire.Clone() - } else { - // A message received only as an add-to has no original header, but - // its stored batches remain independently deliverable. - hasPreparedBatch := false - for _, b := range batches { - if len(b.Prepared) > 0 { - hasPreparedBatch = true - break - } - } - if !hasPreparedBatch { - d = computeDeflate(m, target.MsgID) - orig, useCommonTypes, err = encodeForWire(m.originalHeader, d, true, m.storedHash) - if err != nil { - log.Printf("ERROR: sender: building msg %d: %s", target.MsgID, err) - return - } - } - } - if orig != nil { - actual, hashErr := orig.GetMessageHash() - if hashErr != nil { - log.Printf("ERROR: sender: hash msg %d: %s", target.MsgID, hashErr) - return - } - if len(sharedHash) > 0 && !bytes.Equal(actual, sharedHash) { - log.Printf("ERROR: sender: immutable hash mismatch for msg %d", target.MsgID) - return - } - sharedHash = actual - } - if len(sharedHash) != 32 { - log.Printf("ERROR: sender: missing identity for msg %d", target.MsgID) - return - } - - // Persist the shared hash (so replies/add-to referencing this message - // resolve) and link any pending children — once for the whole message. - if err := ensureSharedHash(db, target.MsgID, sharedHash); err != nil { - log.Printf("ERROR: sender: %s", err) - return - } + // Sending never changes an identity or its representation. Messages that + // first arrived through add-to have batch snapshots but no original header. + orig := m.wire // SPEC §10.2: a host must not transmit a reply to a terminal message, nor // an add-to batch of one. dd.sql refuses to create such rows, so this is @@ -579,32 +411,9 @@ func deliverMessage(target pendingTarget) { recordUnitInvalid(db, target, "msg_add_to", b.ID) continue } - var h *FMsgHeader - var err error - if len(b.Prepared) > 0 { - h, err = fmsg.UnmarshalPrepared(b.Prepared, b.Hash) - } else { - h, _, err = encodeForWire(func() *FMsgHeader { return m.addToHeader(b, sharedHash) }, d, useCommonTypes, b.Hash) - } - if err != nil { - log.Printf("ERROR: sender: building add-to wire header for batch %d of msg %d: %s", b.ID, target.MsgID, err) - continue - } - // Persist the batch hash — the batch's identity (SPEC §11) — once, - // so replies referencing this batch resolve at this host too, which - // must verify messages it sent, not only ones it received. Cached on - // h, so the challenge response reuses this computation. - batchHash, err := h.GetMessageHash() + h, err := fmsg.UnmarshalPrepared(b.Prepared, b.Hash) if err != nil { - log.Printf("ERROR: sender: computing batch hash for batch %d of msg %d: %s", b.ID, target.MsgID, err) - continue - } - if len(b.Hash) > 0 && !bytes.Equal(batchHash, b.Hash) { - log.Printf("ERROR: sender: immutable batch hash mismatch for %d", b.ID) - continue - } - if err := ensureBatchHash(db, b.ID, batchHash); err != nil { - log.Printf("ERROR: sender: %s", err) + log.Printf("ERROR: sender: load finalized batch %d of msg %d: %s", b.ID, target.MsgID, err) continue } deliverUnit(db, target, h, "msg_add_to", b.ID) @@ -699,34 +508,6 @@ func markLocalDelivered(target pendingTarget) { } } -// ensureBatchHash persists an add-to batch's message hash when not yet stored. -// Like ensureSharedHash for the canonical hash, this is what lets replies that -// reference the batch via pid resolve on the host that originated the batch -// (SPEC §11: a host verifies messages it sent, not only ones it received). -func ensureBatchHash(db *sql.DB, batchID int64, batchHash []byte) error { - if _, err := db.Exec(`UPDATE msg_add_to_batch SET sha256 = $1 WHERE id = $2 AND sha256 IS NULL`, batchHash, batchID); err != nil { - return fmt.Errorf("storing sha256 for add-to batch %d: %w", batchID, err) - } - return nil -} - -// ensureSharedHash persists the message's canonical hash when not yet stored and -// resolves any pending child (reply/add-to) links that reference it. -func ensureSharedHash(db *sql.DB, msgID int64, sharedHash []byte) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(`UPDATE msg SET sha256 = $1 WHERE id = $2 AND sha256 IS NULL`, sharedHash, msgID); err != nil { - return fmt.Errorf("storing sha256 for msg %d: %w", msgID, err) - } - if err := resolvePendingChildLinks(txParentLinkStore{tx: tx}, msgID, sharedHash); err != nil { - return fmt.Errorf("resolving child pids for msg %d: %w", msgID, err) - } - return tx.Commit() -} - // deliverUnit sends one wire message — the original message or a single add-to // batch — to target.Domain over its own connection, recording per-recipient // outcomes. It owns its transaction: it locks this unit's pending recipients in diff --git a/cmd/fmsgd/sender_hash_test.go b/cmd/fmsgd/sender_hash_test.go index caeb476..25b23b3 100644 --- a/cmd/fmsgd/sender_hash_test.go +++ b/cmd/fmsgd/sender_hash_test.go @@ -2,17 +2,15 @@ package main import ( "bytes" + "github.com/markmnl/fmsgd/pkg/fmsg" "os" "path/filepath" "strings" "testing" ) -// TestSharedHashUsesTransmittedForm guards the ordering fixed in the sender: -// the shared hash must be computed over the header exactly as transmitted -// (SPEC "Message hash"). computeDeflate/applyTo change flags, size and -// expanded size, so hashing before deflate records a sha256 the receiving -// host never computes — cross-host replies then bounce with code 6. +// The prepared identity must cover the transmitted header, after compression. +// Outgoing delivery restores this representation without choosing it again. func TestSharedHashUsesTransmittedForm(t *testing.T) { dir := t.TempDir() body := strings.Repeat("compressible markdown body — the quick brown fox. ", 200) @@ -21,26 +19,27 @@ func TestSharedHashUsesTransmittedForm(t *testing.T) { t.Fatal(err) } - m := &msgFields{ - version: 1, - size: len(body), - from: FMsgAddress{User: "alice", Domain: "example.com"}, - to: []FMsgAddress{{User: "bob", Domain: "example.org"}}, - timeSent: 1754280000, - topic: "hash form", - typ: "text/markdown", - filepath: bodyPath, + h := &FMsgHeader{ + Version: 1, + Size: uint32(len(body)), + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "example.org"}}, + Timestamp: 1754280000, + Topic: "hash form", + Type: "text/markdown", + Filepath: bodyPath, } - undeflated, err := m.originalHeader().GetMessageHash() + undeflated, err := h.GetMessageHash() if err != nil { t.Fatal(err) } - d := computeDeflate(m, 1) - defer d.removeTempFiles() - wire := m.originalHeader() - d.applyTo(wire) + wire, dir, err := fmsg.Prepare(h) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) if wire.Flags&FlagDeflate == 0 { t.Fatal("test body should have deflated — deflate heuristics changed?") } @@ -55,8 +54,14 @@ func TestSharedHashUsesTransmittedForm(t *testing.T) { // The receiving host recomputes the hash from the wire form it stored — // the sender's recorded shared hash must be that same transmitted form. - receiver := m.originalHeader() - d.applyTo(receiver) + data, err := fmsg.MarshalPrepared(wire) + if err != nil { + t.Fatal(err) + } + receiver, err := fmsg.UnmarshalPrepared(data, transmitted) + if err != nil { + t.Fatal(err) + } got, err := receiver.GetMessageHash() if err != nil { t.Fatal(err) diff --git a/cmd/fmsgd/store.go b/cmd/fmsgd/store.go index f6f9ddb..6df824c 100644 --- a/cmd/fmsgd/store.go +++ b/cmd/fmsgd/store.go @@ -301,36 +301,15 @@ func getMsgByBatchHash(batchHash []byte) (*FMsgHeader, error) { } defer tx.Rollback() - var msgID, batchID int64 - err = tx.QueryRow(`SELECT msg_id, id FROM msg_add_to_batch WHERE sha256 = $1`, batchHash).Scan(&msgID, &batchID) + var prepared []byte + err = tx.QueryRow(`SELECT wire_message FROM msg_add_to_batch WHERE sha256 = $1`, batchHash).Scan(&prepared) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - - m, err := loadMsgFields(tx, msgID) - if err != nil { - return nil, err - } - batches, err := loadAddToBatches(tx, msgID) - if err != nil { - return nil, err - } - for i := range batches { - if batches[i].ID == batchID { - if len(batches[i].Prepared) > 0 { - return fmsg.UnmarshalPrepared(batches[i].Prepared, batches[i].Hash) - } - sharedHash, err := m.sharedHash() - if err != nil { - return nil, err - } - return m.addToHeader(batches[i], sharedHash), nil - } - } - return nil, fmt.Errorf("add-to batch %d missing for msg %d", batchID, msgID) + return fmsg.UnmarshalPrepared(prepared, batchHash) } // existingMsgIDForAddTo returns the id of an already-stored message row whose @@ -674,39 +653,24 @@ func storeMsgHeaderOnly(msg *FMsgHeader) error { return tx.Commit() } -// msgFields holds a message's stored columns plus its original recipients and -// attachments: the raw material for building the message's wire headers. Add-to -// recipients are NOT included here — they belong to batches (see addToBatch), -// each of which is delivered as its own add-to message (SPEC §12). +// msgFields contains the immutable representation used for outgoing delivery. +// A message first received through add-to stores its representation on the batch. type msgFields struct { - version int - size int - noReply, isImportant, isDeflate bool - isTerminal bool // SPEC §3 bit 6: no message may reference this one via pid - parentPid []byte // relational parent hash (stored pid column) - wire *FMsgHeader - storedHash []byte // stored sha256; empty when not yet persisted - from FMsgAddress - to []FMsgAddress - attachments []FMsgAttachmentHeader - timeSent float64 - topic, typ string - filepath string -} - -// loadMsgFields reads the msg row, its msg_to recipients and its attachments. + parentPid []byte + storedHash []byte + wire *FMsgHeader + isTerminal bool +} + func loadMsgFields(tx *sql.Tx, msgID int64) (*msgFields, error) { var m msgFields - var fromAddr string var prepared []byte - if err := tx.QueryRow(` - SELECT version, no_reply, is_important, is_deflate, is_terminal, psha256, sha256, from_addr, topic, type, time_sent, size, filepath, wire_message - FROM msg WHERE id = $1 - `, msgID).Scan(&m.version, &m.noReply, &m.isImportant, &m.isDeflate, &m.isTerminal, &m.parentPid, &m.storedHash, - &fromAddr, &m.topic, &m.typ, &m.timeSent, &m.size, &m.filepath, &prepared); err != nil { - return nil, fmt.Errorf("load msg %d: %w", msgID, err) + if err := tx.QueryRow(`SELECT psha256,sha256,is_terminal,wire_message FROM msg WHERE id=$1 AND time_sent IS NOT NULL`, msgID).Scan(&m.parentPid, &m.storedHash, &m.isTerminal, &prepared); err != nil { + return nil, err + } + if len(m.storedHash) != 32 { + return nil, fmt.Errorf("message %d has no finalized identity", msgID) } - if len(prepared) > 0 { var err error m.wire, err = fmsg.UnmarshalPrepared(prepared, m.storedHash) @@ -714,46 +678,6 @@ func loadMsgFields(tx *sql.Tx, msgID int64) (*msgFields, error) { return nil, fmt.Errorf("load prepared msg %d: %w", msgID, err) } } - from, err := parseAddress([]byte(fromAddr)) - if err != nil { - return nil, fmt.Errorf("invalid from address %s: %w", fromAddr, err) - } - m.from = *from - - m.to, err = loadRecipientAddrs(tx, `SELECT addr FROM msg_to WHERE msg_id = $1 ORDER BY id`, msgID) - if err != nil { - return nil, fmt.Errorf("load recipients for msg %d: %w", msgID, err) - } - - attRows, err := tx.Query(` - SELECT flags, type, filename, filesize, filepath - FROM msg_attachment - WHERE msg_id = $1 - ORDER BY position, filename - `, msgID) - if err != nil { - return nil, fmt.Errorf("load attachments for msg %d: %w", msgID, err) - } - m.attachments = []FMsgAttachmentHeader{} - for attRows.Next() { - var flags, filesize int - var typ, filename, filepath string - if err := attRows.Scan(&flags, &typ, &filename, &filesize, &filepath); err != nil { - attRows.Close() - return nil, fmt.Errorf("scan attachment row: %w", err) - } - m.attachments = append(m.attachments, FMsgAttachmentHeader{ - Flags: uint8(flags), - Type: typ, - Filename: filename, - Size: uint32(filesize), - Filepath: filepath, - }) - } - attRows.Close() - if err := attRows.Err(); err != nil { - return nil, fmt.Errorf("attachments query err for msg %d: %w", msgID, err) - } return &m, nil } @@ -779,25 +703,6 @@ func loadRecipientAddrs(tx *sql.Tx, query string, msgID int64) ([]FMsgAddress, e return addrs, rows.Err() } -// baseFlags returns the persisted flag bits (no_reply/important/deflate/ -// terminal) shared by every wire form of the message. -func (m *msgFields) baseFlags() uint8 { - var f uint8 - if m.noReply { - f |= FlagNoReply - } - if m.isImportant { - f |= FlagImportant - } - if m.isDeflate { - f |= FlagDeflate - } - if m.isTerminal { - f |= FlagTerminal - } - return f -} - // isStoredMsgTerminal reports whether the stored message identified by hash — // a message's canonical hash or one of its add-to batch hashes (SPEC §11) — // has the terminal flag set. False when no such message is stored. @@ -820,60 +725,6 @@ func isStoredMsgTerminal(db *sql.DB, hash []byte) (bool, error) { return terminal, err } -// originalHeader builds the message in its original (non-add-to) wire form, -// whose pid (if any) references the relational parent. -func (m *msgFields) originalHeader() *FMsgHeader { - if m.wire != nil { - return m.wire.Clone() - } - flags := m.baseFlags() - if len(m.parentPid) > 0 { - flags |= FlagHasPid - } - return &FMsgHeader{ - Version: uint8(m.version), - Flags: flags, - Pid: m.parentPid, - From: m.from, - To: m.to, - Timestamp: m.timeSent, - Topic: m.topic, - Type: m.typ, - Size: uint32(m.size), - Attachments: append([]FMsgAttachmentHeader(nil), m.attachments...), // own copy: wire forms mutate attachment flags - Filepath: m.filepath, - } -} - -// sharedHash returns the canonical hash identifying this message: its persisted -// sha256 (computed at first outbound delivery over the header exactly as -// transmitted — deflated form; see the sender), or — when not yet persisted -// (e.g. local-only delivery, where nothing external can reference it) — its -// undeflated original-form hash as a local fallback. Add-to batches reference -// this value as their pid (SPEC §12). -func (m *msgFields) sharedHash() ([]byte, error) { - if len(m.storedHash) > 0 { - return m.storedHash, nil - } - return m.originalHeader().GetMessageHash() -} - -// addToHeader builds the wire header that delivers one add-to batch: a duplicate -// of the original message carrying this batch's sender, recipients and -// timestamp, with pid set to the shared message hash (SPEC §12). A fresh header -// is returned each call so hash caches never cross between batches. -func (m *msgFields) addToHeader(batch addToBatch, sharedHash []byte) *FMsgHeader { - h := m.originalHeader() - h.Flags |= FlagHasPid | FlagHasAddTo - h.Pid = sharedHash - from := batch.From - h.AddToFrom = &from - h.AddTo = batch.Recipients - h.Timestamp = batch.TimeAdded - h.Topic = "" // pid is present, so topic is omitted on the wire - return h -} - // addToBatch is one add-to delivery: a single sender added a set of recipients // at a point in time (SPEC §12). type addToBatch struct { @@ -882,7 +733,7 @@ type addToBatch struct { TimeAdded float64 Recipients []FMsgAddress Prepared []byte - Hash []byte // batch message hash once persisted (SPEC §11); nil before first delivery + Hash []byte // finalized batch identity (SPEC §11) } // loadAddToBatches returns every add-to batch for a message, each with its @@ -950,7 +801,10 @@ func loadMsg(tx *sql.Tx, msgID int64) (*FMsgHeader, error) { return nil, fmt.Errorf("load add-to recipients for msg %d: %w", msgID, err) } - h := m.originalHeader() + var h *FMsgHeader + if m.wire != nil { + h = m.wire.Clone() + } if m.wire == nil { // When the original arrived through add-to, its batch retains the // exact wire payloads; the API files have already been expanded. @@ -968,15 +822,13 @@ func loadMsg(tx *sql.Tx, msgID int64) (*FMsgHeader, error) { } } } + if h == nil { + return nil, fmt.Errorf("message %d has no finalized representation", msgID) + } if len(addTo) > 0 { - // The wire pid of an add-to message references the shared message, not - // that message's relational parent (SPEC §12). - sharedHash, err := m.sharedHash() - if err != nil { - return nil, fmt.Errorf("compute shared hash for msg %d: %w", msgID, err) - } + // The wire pid of an add-to references the shared identity. h.Flags |= FlagHasPid | FlagHasAddTo - h.Pid = sharedHash + h.Pid = m.storedHash h.AddTo = addTo } return h, nil diff --git a/cmd/fmsgd/store_test.go b/cmd/fmsgd/store_test.go index bc34311..b5bfd07 100644 --- a/cmd/fmsgd/store_test.go +++ b/cmd/fmsgd/store_test.go @@ -225,17 +225,3 @@ func TestInboundRecipientRow(t *testing.T) { t.Fatalf("remote: got (%v, %v), want (nil, %d)", delivered, code, localResponseCodeNotOurDelivery) } } - -func TestBaseFlagsIncludesTerminal(t *testing.T) { - m := &msgFields{noReply: true, isTerminal: true} - got := m.baseFlags() - if got&FlagTerminal == 0 { - t.Fatalf("baseFlags() = %#08b, want terminal bit set", got) - } - if got&FlagNoReply == 0 { - t.Fatalf("baseFlags() = %#08b, want no reply bit set", got) - } - if (&msgFields{}).baseFlags()&FlagTerminal != 0 { - t.Fatalf("baseFlags() set terminal for a non-terminal message") - } -} diff --git a/dd.sql b/dd.sql index 78ddbad..a3d640d 100644 --- a/dd.sql +++ b/dd.sql @@ -1,25 +1,8 @@ -/**************************************************************** - * - * PostgreSQL database objects data definition for fmsgd - * - * This script is IDEMPOTENT: every statement is safe to re-run - * (create table/index if not exists, alter table add column if - * not exists, create or replace function, drop trigger if exists - * before create trigger). Migrating an existing database is - * therefore just re-running the whole script, e.g.: - * - * psql -d fmsgd -v ON_ERROR_STOP=1 -f dd.sql - * - * Keep it that way: add new objects and columns only with - * idempotent statements, and name indexes explicitly to match - * PostgreSQL's default generated names so indexes that already - * exist unnamed on live databases are recognised, not duplicated. - * - ****************************************************************/ - --- database with encoding UTF8 should already be created and connected - -create table if not exists msg ( +-- PostgreSQL bootstrap schema for a new, empty fmsg message database. +-- Existing installations use the standalone fmsg-backfill binary before +-- starting this version. This file is not an upgrade script. + +create table msg ( id bigserial primary key, version int not null, pid bigint references msg (id), @@ -35,13 +18,12 @@ create table if not exists msg ( psha256 bytea, size int not null, -- spec allows uint32 but we don't enforced by FMSG_MAX_MSG_SIZE filepath text not null, - wire_header bytea -- received messages: the exact wire header bytes (fields 1-13), so any hash can always be faithfully recomputed (SPEC §11); null for locally-authored messages + wire_header bytea, -- exact protocol header (fields 1-13) + wire_message jsonb -- durable original wire representation; null for drafts or originals received only through add-to ); -create index if not exists msg_lower_idx on msg ((lower(from_addr))); -alter table msg add column if not exists wire_header bytea; -- upgrade path for databases created before this column -alter table msg add column if not exists is_terminal boolean not null default false; -- upgrade path (SPEC v0.6.0) +create index msg_lower_idx on msg ((lower(from_addr))); -create table if not exists msg_to ( +create table msg_to ( id bigserial primary key, msg_id bigint not null references msg (id), addr varchar(255) not null, @@ -52,7 +34,7 @@ create table if not exists msg_to ( attempt_count int not null default 0, -- number of failed delivery attempts; used for exponential back-off unique (msg_id, addr) ); -create index if not exists msg_to_lower_idx on msg_to ((lower(addr))); +create index msg_to_lower_idx on msg_to ((lower(addr))); -- Each add-to delivery for a shared message is one batch: a single sender -- (add_to_from) added a set of recipients at a point in time. Storing batches @@ -60,19 +42,18 @@ create index if not exists msg_to_lower_idx on msg_to ((lower(addr))); -- which a single flat recipient list cannot preserve (SPEC §12). A batch's -- identity is its message hash (sha256), which covers the batch's time: the -- same addresses re-issued at a new time are a distinct batch, not a --- duplicate (SPEC §11/§12). sha256 is null for rows recorded before this --- column existed and for locally originated batches not yet hashed. -create table if not exists msg_add_to_batch ( +-- duplicate (SPEC §11/§12). Batches of a draft finalize when it is sent. +create table msg_add_to_batch ( id bigserial primary key, msg_id bigint not null references msg (id), add_to_from varchar(255) not null, -- sender that added this batch's recipients time_added double precision not null, -- the batch message's wire time field (for locally originated batches, when the batch was created) - sha256 bytea -- batch message hash: the batch's identity (SPEC §11) + sha256 bytea, -- finalized batch identity (SPEC §11) + wire_message jsonb -- durable batch wire representation ); -alter table msg_add_to_batch add column if not exists sha256 bytea; -create index if not exists msg_add_to_batch_msg_id_idx on msg_add_to_batch (msg_id); +create index msg_add_to_batch_msg_id_idx on msg_add_to_batch (msg_id); -create table if not exists msg_add_to ( +create table msg_add_to ( id bigserial primary key, msg_id bigint not null references msg (id), batch_id bigint not null references msg_add_to_batch (id), -- batch this recipient was added in @@ -84,15 +65,10 @@ create table if not exists msg_add_to ( attempt_count int not null default 0, -- number of failed delivery attempts; used for exponential back-off unique (batch_id, addr) ); --- An address is unique within a batch, not across batches: distinct batches --- may re-add the same address (each batch is its own sibling branch, SPEC --- §12). Migrate existing databases off the old per-message constraint. -alter table msg_add_to drop constraint if exists msg_add_to_msg_id_addr_key; -create unique index if not exists msg_add_to_batch_id_addr_key on msg_add_to (batch_id, addr); -create index if not exists msg_add_to_lower_idx on msg_add_to ((lower(addr))); -create index if not exists msg_add_to_batch_id_idx on msg_add_to (batch_id); - -create table if not exists msg_attachment ( +create index msg_add_to_lower_idx on msg_add_to ((lower(addr))); +create index msg_add_to_batch_id_idx on msg_add_to (batch_id); + +create table msg_attachment ( msg_id bigint references msg (id), position smallint not null default 0, flags smallint not null default 0, @@ -103,12 +79,38 @@ create table if not exists msg_attachment ( primary key (msg_id, filename) ); +-- Sender-side state for add-to participant notification (SPEC §10.2): an +-- add-to message is sent to every participant domain of the message being +-- added to -- the domains of from and every to address as well as the new +-- recipients' -- so all participants learn recipients were added, not only +-- the domains hosting the new recipients. Domains hosting a recipient of the +-- batch itself learn through normal recipient delivery; every other +-- participant domain gets one row here per batch and receives the add-to as +-- a notification-only exchange completing at code 11. Rows are created by +-- the Web API when recipients are added through it (the local domain itself +-- needs no row -- this database is its record). +create table msg_add_to_notify ( + id bigserial primary key, + batch_id bigint not null references msg_add_to_batch (id), + domain varchar(255) not null, + time_notified double precision, -- time remote host acknowledged the batch; null means pending + time_last_attempt double precision, -- time of last failed attempt; drives exponential back-off + response_code smallint, -- response code of last attempt + attempt_count int not null default 0, + unique (batch_id, domain) +); + +create index msg_add_to_batch_sha256_idx on msg_add_to_batch (sha256) where sha256 is not null; +create index msg_pid_idx on msg (pid) where pid is not null; + +-- Functions and triggers. + -- keep protocol parent hash populated for locally-created replies that set -- the relational parent id. A reply cannot reference a draft parent or a -- terminal parent (SPEC v0.6.0 §3: a Sending Host must not transmit a reply -- to a terminal message, so refuse to create one), and any explicit psha256 -- must match the referenced parent's sha256. -create or replace function populate_msg_psha256_from_pid() returns trigger as $$ +create function populate_msg_psha256_from_pid() returns trigger as $$ declare parent_time_sent double precision; parent_sha256 bytea; @@ -135,9 +137,8 @@ begin raise exception 'cannot set pid %: parent message is terminal', NEW.pid; end if; - if parent_sha256 is null or octet_length(parent_sha256) = 0 then - -- parent was delivered locally only and has no sha256 yet; psha256 cannot be populated - return NEW; + if parent_sha256 is null or octet_length(parent_sha256) <> 32 then + raise exception 'parent message % has no finalized identity', NEW.pid; end if; if NEW.psha256 is null or octet_length(NEW.psha256) = 0 then @@ -157,14 +158,13 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_msg_populate_psha256 on msg; create trigger trg_msg_populate_psha256 before insert or update of pid, psha256 on msg for each row execute function populate_msg_psha256_from_pid(); -- recipients cannot be added to a terminal message (SPEC §12): refuse to -- create a batch for one, so the sender never has such a unit to transmit. -create or replace function prevent_add_to_terminal_msg() returns trigger as $$ +create function prevent_add_to_terminal_msg() returns trigger as $$ begin if exists (select 1 from msg where id = NEW.msg_id and is_terminal) then raise exception 'cannot add recipients to message %: it is terminal', NEW.msg_id; @@ -173,36 +173,10 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_msg_add_to_batch_terminal on msg_add_to_batch; create trigger trg_msg_add_to_batch_terminal before insert on msg_add_to_batch for each row execute function prevent_add_to_terminal_msg(); --- once a message has replies, it must remain referenceable by protocol hash. -create or replace function prevent_referenced_msg_from_becoming_unreferenceable() returns trigger as $$ -begin - if exists (select 1 from msg child where child.pid = NEW.id) then - if NEW.time_sent is null then - raise exception 'cannot make message % a draft: it has replies', NEW.id; - end if; - - if OLD.sha256 is not null and (NEW.sha256 is null or octet_length(NEW.sha256) = 0) then - raise exception 'cannot clear sha256 for message %: it has replies', NEW.id; - end if; - - if OLD.sha256 is not null and OLD.sha256 is distinct from NEW.sha256 then - raise exception 'cannot change sha256 for message %: it has replies', NEW.id; - end if; - end if; - return NEW; -end; -$$ language plpgsql; - -drop trigger if exists trg_msg_prevent_unreferenceable_parent on msg; -create trigger trg_msg_prevent_unreferenceable_parent - before update of time_sent, sha256 on msg - for each row execute function prevent_referenced_msg_from_becoming_unreferenceable(); - -- Notify the sender's outgoing worker (channel new_msg_to) whenever new -- delivery work appears. One function serves all three triggers, dispatching -- on the table it fired for: @@ -214,7 +188,7 @@ create trigger trg_msg_prevent_unreferenceable_parent -- message whose recipient rows follow in the same -- transaction); notify that recipient. -- The payload is advisory only: the worker re-polls fully on any wake-up. -create or replace function notify_msg_sent() returns trigger as $$ +create function notify_msg_sent() returns trigger as $$ begin if TG_TABLE_NAME = 'msg' then if OLD.time_sent is null and NEW.time_sent is not null then @@ -232,17 +206,14 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_msg_to_insert on msg_to; create trigger trg_msg_to_insert after insert on msg_to for each row execute function notify_msg_sent(); -drop trigger if exists trg_msg_add_to_insert on msg_add_to; create trigger trg_msg_add_to_insert after insert on msg_add_to for each row execute function notify_msg_sent(); -drop trigger if exists trg_msg_sent on msg; create trigger trg_msg_sent after update on msg for each row execute function notify_msg_sent(); @@ -259,7 +230,7 @@ create trigger trg_msg_sent -- msg row is written before its msg_to/msg_add_to rows (FK ordering), so a -- plain row trigger would see no recipients. At commit every recipient row in -- the transaction is visible. -create or replace function notify_new_msg() returns trigger as $$ +create function notify_new_msg() returns trigger as $$ begin if (TG_OP = 'INSERT' and NEW.time_sent is not null) or (TG_OP = 'UPDATE' and OLD.time_sent is null and NEW.time_sent is not null) then @@ -273,7 +244,6 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_new_msg on msg; create constraint trigger trg_new_msg after insert or update on msg deferrable initially deferred @@ -290,7 +260,7 @@ create constraint trigger trg_new_msg -- it's the sender whose UI needs to react. Unlike trg_new_msg this does not -- need to be deferred: the msg row referenced by msg_id already exists (FK) -- by the time msg_to/msg_add_to is updated. -create or replace function notify_delivered() returns trigger as $$ +create function notify_delivered() returns trigger as $$ begin perform pg_notify('delivered', NEW.msg_id::text || ',' || m.from_addr) from msg m where m.id = NEW.msg_id; @@ -298,45 +268,22 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_msg_to_delivered on msg_to; create trigger trg_msg_to_delivered after update of time_delivered on msg_to for each row when (OLD.time_delivered is null and NEW.time_delivered is not null) execute function notify_delivered(); -drop trigger if exists trg_msg_add_to_delivered on msg_add_to; create trigger trg_msg_add_to_delivered after update of time_delivered on msg_add_to for each row when (OLD.time_delivered is null and NEW.time_delivered is not null) execute function notify_delivered(); --- Sender-side state for add-to participant notification (SPEC §10.2): an --- add-to message is sent to every participant domain of the message being --- added to -- the domains of from and every to address as well as the new --- recipients' -- so all participants learn recipients were added, not only --- the domains hosting the new recipients. Domains hosting a recipient of the --- batch itself learn through normal recipient delivery; every other --- participant domain gets one row here per batch and receives the add-to as --- a notification-only exchange completing at code 11. Rows are created by --- the Web API when recipients are added through it (the local domain itself --- needs no row -- this database is its record). -create table if not exists msg_add_to_notify ( - id bigserial primary key, - batch_id bigint not null references msg_add_to_batch (id), - domain varchar(255) not null, - time_notified double precision, -- time remote host acknowledged the batch; null means pending - time_last_attempt double precision, -- time of last failed attempt; drives exponential back-off - response_code smallint, -- response code of last attempt - attempt_count int not null default 0, - unique (batch_id, domain) -); - -- Wake the sender's outgoing worker (channel new_msg_to) for a pending -- participant notification, mirroring notify_msg_sent for recipient rows. -- The payload is advisory only: the worker re-polls fully on any wake-up. -create or replace function notify_add_to_notify_pending() returns trigger as $$ +create function notify_add_to_notify_pending() returns trigger as $$ begin perform pg_notify('new_msg_to', b.msg_id::text || ',' || NEW.domain) from msg_add_to_batch b @@ -346,7 +293,6 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_msg_add_to_notify_insert on msg_add_to_notify; create trigger trg_msg_add_to_notify_insert after insert on msg_add_to_notify for each row execute function notify_add_to_notify_pending(); @@ -363,7 +309,7 @@ create trigger trg_msg_add_to_notify_insert -- new_msg. Like trg_new_msg this is a deferred constraint trigger: the -- batch's own msg_add_to rows are inserted after the batch row, so only at -- commit is the full recipient set visible. -create or replace function notify_recipients_added() returns trigger as $$ +create function notify_recipients_added() returns trigger as $$ begin if not exists (select 1 from msg where id = NEW.msg_id and time_sent is not null) then return NEW; @@ -382,65 +328,66 @@ begin end; $$ language plpgsql; -drop trigger if exists trg_recipients_added on msg_add_to_batch; create constraint trigger trg_recipients_added after insert on msg_add_to_batch deferrable initially deferred for each row execute function notify_recipients_added(); --- Durable protocol representations, shared by the API finalizer and daemon. --- NULL on legacy rows; received add-to variants belong to their batch only. -alter table msg add column if not exists wire_message jsonb; -alter table msg_add_to_batch add column if not exists wire_message jsonb; -create index if not exists msg_add_to_batch_sha256_idx on msg_add_to_batch (sha256) where sha256 is not null; -create index if not exists msg_pid_idx on msg (pid) where pid is not null; - --- Preserve protocol identity; local relational pid links and delivery/read --- metadata are bookkeeping and may still change. Legacy NULL hashes may be --- filled once without changing the timestamp, including parents with replies. -create or replace function protect_msg_identity() returns trigger as $$ +-- Sent protocol fields are immutable. Relational pid links and delivery/read +-- metadata remain bookkeeping and may change. +create function protect_msg_identity() returns trigger as $$ begin - if OLD.time_sent is not null then - if NEW.time_sent is distinct from OLD.time_sent then - raise exception 'sent message timestamp is immutable'; - end if; - if OLD.sha256 is not null and - (NEW.sha256 is distinct from OLD.sha256 or - row(NEW.version,NEW.psha256,NEW.no_reply,NEW.is_important,NEW.is_terminal, - NEW.is_deflate,NEW.from_addr,NEW.topic,NEW.type,NEW.size,NEW.filepath) - is distinct from - row(OLD.version,OLD.psha256,OLD.no_reply,OLD.is_important,OLD.is_terminal, - OLD.is_deflate,OLD.from_addr,OLD.topic,OLD.type,OLD.size,OLD.filepath) or - (OLD.wire_header is not null and NEW.wire_header is distinct from OLD.wire_header) or - (OLD.wire_message is not null and NEW.wire_message is distinct from OLD.wire_message)) then - raise exception 'sent message content and hash are immutable'; - end if; + if OLD.time_sent is not null and + row(NEW.time_sent,NEW.sha256,NEW.version,NEW.psha256,NEW.no_reply, + NEW.is_important,NEW.is_terminal,NEW.is_deflate,NEW.from_addr, + NEW.topic,NEW.type,NEW.size,NEW.filepath,NEW.wire_header,NEW.wire_message) + is distinct from + row(OLD.time_sent,OLD.sha256,OLD.version,OLD.psha256,OLD.no_reply, + OLD.is_important,OLD.is_terminal,OLD.is_deflate,OLD.from_addr, + OLD.topic,OLD.type,OLD.size,OLD.filepath,OLD.wire_header,OLD.wire_message) then + raise exception 'sent message content, timestamp and hash are immutable'; end if; return NEW; end; $$ language plpgsql; -drop trigger if exists trg_msg_identity on msg; create trigger trg_msg_identity before update on msg for each row execute function protect_msg_identity(); --- Validate at commit so receiving hosts can assemble rows and recipients in --- one transaction. Existing unhashed rows are backfilled by fmsg-backfill. -create or replace function require_sent_msg_hash() returns trigger as $$ +-- Validate after all rows in the transaction have been assembled. An original +-- first received via add-to has its payload representation on the received batch. +create function require_sent_msg_hash() returns trigger as $$ begin - if TG_OP='UPDATE' then - if OLD.time_sent is not distinct from NEW.time_sent and OLD.sha256 is not distinct from NEW.sha256 then return null; end if; + if exists (select 1 from msg m where m.id=NEW.id and m.time_sent is not null + and (m.sha256 is null or octet_length(m.sha256) <> 32 or + (m.wire_message is null and not exists ( + select 1 from msg_add_to_batch b where b.msg_id=m.id + and b.sha256 is not null and b.wire_message is not null)))) then + raise exception 'sent message % requires a 32-byte sha256 and a wire representation', NEW.id; end if; - if exists (select 1 from msg where id=NEW.id and time_sent is not null - and (sha256 is null or octet_length(sha256) <> 32)) then - raise exception 'sent message % requires a 32-byte sha256', NEW.id; + if exists (select 1 from msg_add_to_batch b join msg m on m.id=b.msg_id + where m.id=NEW.id and m.time_sent is not null + and (b.sha256 is null or octet_length(b.sha256) <> 32 or b.wire_message is null)) then + raise exception 'sent message % has an unfinalized add-to batch', NEW.id; end if; return null; end; $$ language plpgsql; -drop trigger if exists trg_msg_require_hash on msg; create constraint trigger trg_msg_require_hash after insert or update on msg deferrable initially deferred for each row execute function require_sent_msg_hash(); -create or replace function protect_msg_parts() returns trigger as $$ +create function require_sent_batch_hash() returns trigger as $$ +begin + if exists (select 1 from msg_add_to_batch b join msg m on m.id=b.msg_id + where b.id=NEW.id and m.time_sent is not null + and (b.sha256 is null or octet_length(b.sha256) <> 32 or b.wire_message is null)) then + raise exception 'sent batch % requires a 32-byte sha256 and a wire representation', NEW.id; + end if; + return null; +end; +$$ language plpgsql; +create constraint trigger trg_batch_require_hash after insert or update on msg_add_to_batch + deferrable initially deferred for each row execute function require_sent_batch_hash(); + +create function protect_msg_parts() returns trigger as $$ declare message_id bigint; frozen boolean; @@ -465,7 +412,7 @@ begin if not found then raise exception 'batch does not belong to message'; end if; end if; else - select time_sent is not null and sha256 is not null into frozen from msg where id=message_id for update; + select time_sent is not null into frozen from msg where id=message_id for update; end if; if frozen then raise exception 'finalized message parts are immutable'; end if; if TG_OP='DELETE' then return OLD; end if; @@ -473,14 +420,11 @@ begin end; $$ language plpgsql; -- AFTER INSERT allows an ON CONFLICT DO NOTHING receipt to remain a no-op. -drop trigger if exists trg_msg_to_content on msg_to; create trigger trg_msg_to_content after insert or update or delete on msg_to for each row execute function protect_msg_parts(); -drop trigger if exists trg_msg_attachment_content on msg_attachment; create trigger trg_msg_attachment_content after insert or update or delete on msg_attachment for each row execute function protect_msg_parts(); -drop trigger if exists trg_msg_add_to_content on msg_add_to; create trigger trg_msg_add_to_content after insert or update or delete on msg_add_to for each row execute function protect_msg_parts(); -create or replace function protect_batch_identity() returns trigger as $$ +create function protect_batch_identity() returns trigger as $$ begin if OLD.sha256 is not null and row(NEW.msg_id,NEW.add_to_from,NEW.time_added,NEW.sha256,NEW.wire_message) @@ -490,5 +434,4 @@ begin return NEW; end; $$ language plpgsql; -drop trigger if exists trg_batch_identity on msg_add_to_batch; create trigger trg_batch_identity before update on msg_add_to_batch for each row execute function protect_batch_identity(); diff --git a/pkg/message/store.go b/pkg/message/store.go index 350c875..1cc1ab5 100644 --- a/pkg/message/store.go +++ b/pkg/message/store.go @@ -3,7 +3,6 @@ package message import ( - "bytes" "context" "database/sql" "fmt" @@ -139,19 +138,15 @@ func load(ctx context.Context, tx Tx, id int64) (*stored, error) { return s, err } -// Finalize stamps and hashes a draft, or backfills an unhashed sent message at -// its original time. Parents must already have identities. Existing hashes -// are never replaced. The caller must check ownership/draft status separately. +// Finalize stamps and hashes a draft. Parents must already have identities. +// The caller must check ownership separately. func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Files) ([]byte, error) { s, err := load(ctx, tx, id) if err != nil { return nil, err } - if len(s.hash) > 0 { - return s.hash, nil - } - if s.time != nil { - timestamp = *s.time + if s.time != nil || len(s.hash) != 0 { + return nil, fmt.Errorf("message %d is already finalized", id) } s.h.Timestamp = timestamp if s.pid != nil { @@ -160,7 +155,7 @@ func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Fi return nil, fmt.Errorf("parent unavailable: %w", err) } if len(parentHash) != 32 { - return nil, fmt.Errorf("parent %d needs hash backfill first", *s.pid) + return nil, fmt.Errorf("parent %d has no finalized identity", *s.pid) } if len(s.h.Pid) == 0 { s.h.Pid = parentHash @@ -169,17 +164,6 @@ func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Fi if len(s.h.Pid) > 0 && len(s.h.Pid) != 32 { return nil, fmt.Errorf("invalid parent hash") } - if s.time != nil { - // A previously hashed child with a missing parent hash cannot be - // repaired without changing an established protocol identity. - var inconsistent bool - if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM msg WHERE pid=$1 AND sha256 IS NOT NULL AND psha256 IS NULL)`, id).Scan(&inconsistent); err != nil { - return nil, err - } - if inconsistent { - return nil, fmt.Errorf("message %d has hashed children without parent hashes; manual repair required", id) - } - } h, dir, err := fmsg.Prepare(s.h) if err != nil { return nil, err @@ -196,9 +180,6 @@ func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Fi if err = tx.Exec(ctx, `UPDATE msg SET time_sent=$2,sha256=$3,psha256=$4,wire_header=$5,wire_message=$6,is_deflate=$7 WHERE id=$1`, id, timestamp, hash, h.Pid, h.Encode(), string(snapshot), h.Flags&fmsg.FlagDeflate != 0); err != nil { return nil, err } - if err = tx.Exec(ctx, `UPDATE msg SET psha256=$2 WHERE pid=$1 AND psha256 IS NULL AND sha256 IS NULL`, id, hash); err != nil { - return nil, err - } rows, err := tx.Query(ctx, `SELECT id FROM msg_add_to_batch WHERE msg_id=$1 AND sha256 IS NULL ORDER BY id`, id) if err != nil { return nil, err @@ -222,7 +203,7 @@ func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Fi if err = tx.Exec(ctx, `UPDATE msg_add_to_batch SET time_added=GREATEST(time_added,$2) WHERE id=$1 AND sha256 IS NULL`, b, timestamp); err != nil { return nil, err } - if _, err = FinalizeBatch(ctx, tx, id, b, files); err != nil { + if _, err = FinalizeBatch(ctx, tx, id, b); err != nil { return nil, err } } @@ -231,7 +212,7 @@ func Finalize(ctx context.Context, tx Tx, id int64, timestamp float64, files *Fi // FinalizeBatch seals one add-to exchange. Its payload representation is copied // from the original (or a received batch), never recompressed independently. -func FinalizeBatch(ctx context.Context, tx Tx, id, batchID int64, files *Files) ([]byte, error) { +func FinalizeBatch(ctx context.Context, tx Tx, id, batchID int64) ([]byte, error) { s, err := load(ctx, tx, id) if err != nil { return nil, err @@ -240,7 +221,7 @@ func FinalizeBatch(ctx context.Context, tx Tx, id, batchID int64, files *Files) return nil, nil } // a draft's batches finalize with its send if len(s.hash) != 32 { - return nil, fmt.Errorf("message %d needs hash backfill first", id) + return nil, fmt.Errorf("message %d has no finalized identity", id) } var from string var timestamp float64 @@ -261,34 +242,6 @@ func FinalizeBatch(ctx context.Context, tx Tx, id, batchID int64, files *Files) err = tx.QueryRow(ctx, `SELECT wire_message,sha256 FROM msg_add_to_batch WHERE msg_id=$1 AND wire_message IS NOT NULL ORDER BY id LIMIT 1`, id).Scan(&snapshot, &batchHash) if err == nil { h, err = fmsg.UnmarshalPrepared(snapshot, batchHash) - } else { - // Legacy local rows can be upgraded only if preparation reproduces - // their existing identity. Do not replace a published hash. - var dir string - h, dir, err = fmsg.Prepare(s.h) - if err == nil { - *files = append(*files, dir) - var got []byte - got, err = h.GetMessageHash() - if err == nil && !bytes.Equal(got, s.hash) { - h = h.Clone() - h.Flags &^= fmsg.FlagCommonType - for i := range h.Attachments { - h.Attachments[i].Flags &^= 1 - } - got, err = h.GetMessageHash() - if err == nil && !bytes.Equal(got, s.hash) { - err = fmt.Errorf("cannot reproduce legacy message %d; existing hash preserved", id) - } - } - if err == nil { - var data []byte - data, err = fmsg.MarshalPrepared(h) - if err == nil { - err = tx.Exec(ctx, `UPDATE msg SET wire_message=$2 WHERE id=$1`, id, string(data)) - } - } - } } } if err != nil { diff --git a/pkg/message/store_integration_test.go b/pkg/message/store_integration_test.go index 8ceb4da..923969c 100644 --- a/pkg/message/store_integration_test.go +++ b/pkg/message/store_integration_test.go @@ -85,43 +85,38 @@ func seal(t *testing.T, db *sql.DB, id int64) []byte { } return hash } -func TestFinalizeBackfillAndImmutability(t *testing.T) { - db, dd := testStore(t) - // Simulate the old local-only writer, then migrate with existing replies. - if _, err := db.Exec(`DROP TRIGGER trg_msg_require_hash ON msg`); err != nil { - t.Fatal(err) - } +func TestFinalizeAndImmutability(t *testing.T) { + db, _ := testStore(t) root := insertDraft(t, db, nil, "root") - if _, err := db.Exec(`UPDATE msg SET time_sent=100 WHERE id=$1`, root); err != nil { - t.Fatal(err) - } - child := insertDraft(t, db, root, "child") - if _, err := db.Exec(`UPDATE msg SET time_sent=101 WHERE id=$1`, child); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(dd); err != nil { - t.Fatal(err) - } hash := seal(t, db, root) + child := insertDraft(t, db, root, "child") childHash := seal(t, db, child) if len(hash) != 32 || len(childHash) != 32 { t.Fatal("missing identities") } - if got := seal(t, db, root); !bytes.Equal(hash, got) { - t.Fatal("backfill is not idempotent") - } var stamp float64 var parent []byte if err := db.QueryRow(`SELECT time_sent,psha256 FROM msg WHERE id=$1`, child).Scan(&stamp, &parent); err != nil { t.Fatal(err) } - if stamp != 101 || !bytes.Equal(parent, hash) { - t.Fatal("backfill altered timestamp or lost parent") + if stamp != 1234.5 || !bytes.Equal(parent, hash) { + t.Fatal("finalization lost timestamp or parent") + } + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + var files Files + if _, err = Finalize(context.Background(), SQLTx{tx}, root, 999, &files); err == nil { + t.Fatal("accepted an already finalized message") } + tx.Rollback() for _, query := range []string{ `UPDATE msg SET time_sent=102 WHERE id=$1`, `UPDATE msg SET topic='changed' WHERE id=$1`, `UPDATE msg SET sha256=NULL WHERE id=$1`, + `UPDATE msg SET wire_message=NULL WHERE id=$1`, + `UPDATE msg SET wire_header=NULL WHERE id=$1`, `UPDATE msg_to SET addr='@mallory@example.com' WHERE msg_id=$1`, `INSERT INTO msg_to(msg_id,addr) VALUES($1,'@carol@example.com')`, `DELETE FROM msg_to WHERE msg_id=$1`, diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..5f4fca0 --- /dev/null +++ b/schema.go @@ -0,0 +1,9 @@ +// Package fmsgd exposes the bootstrap schema for offline maintenance tools. +package fmsgd + +import _ "embed" + +// Schema is the schema for a new message database. The daemon does not apply it. +// +//go:embed dd.sql +var Schema string From 9c2339f10d5ab4de59477bd751abda7adf585636 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 10 Sep 2026 16:14:06 +0800 Subject: [PATCH 6/6] Cover published compression and draft state in migration tests --- cmd/fmsg-backfill/migrate_test.go | 41 +++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/cmd/fmsg-backfill/migrate_test.go b/cmd/fmsg-backfill/migrate_test.go index bede330..31b9028 100644 --- a/cmd/fmsg-backfill/migrate_test.go +++ b/cmd/fmsg-backfill/migrate_test.go @@ -132,6 +132,22 @@ func TestStandaloneMigration(t *testing.T) { oldHash := hashOf(t, oldString) published := putOld(t, db, oldString, nil, oldHash, nil) + localCompressed := rawMessage(t, "example.com", strings.Repeat("published compressed ", 300)) + localWire := prepared(t, localCompressed) + localHash := hashOf(t, localWire) + compressed := putOld(t, db, localCompressed, nil, localHash, nil) + publishedBatch := oldBatch{from: oldString.From, time: 1301, to: []fmsg.Address{{User: "carol", Domain: "example.com"}}} + publishedBatch.hash = hashOf(t, batchHeader(oldString, oldHash, publishedBatch)) + publishedBatchID := putBatch(t, db, published, publishedBatch) + + // Pending drafts and draft batches remain editable; an existing draft + // reply gains its newly finalized parent's protocol identity. + var draft int64 + if err := db.QueryRow(`INSERT INTO msg(version,pid,from_addr,topic,type,size,filepath) VALUES(1,$1,'@alice@example.com','','text/plain',0,'') RETURNING id`, root).Scan(&draft); err != nil { + t.Fatal(err) + } + draftBatch := putBatch(t, db, draft, oldBatch{from: rootRaw.From, time: 1302, to: []fmsg.Address{{User: "carol", Domain: "example.com"}}}) + // Received compression and mixed type encodings must preserve the exact // wire header. Expanded body and attachment files are all the old store has. remote := rawMessage(t, "example.org", strings.Repeat("compressible content ", 400)) @@ -173,7 +189,7 @@ func TestStandaloneMigration(t *testing.T) { } var rootHash []byte var snapshots = make(map[int64][]byte) - for _, id := range []int64{root, child, published, received, addToOnly} { + for _, id := range []int64{root, child, published, compressed, received, addToOnly} { var hash, data, parent []byte var stamp float64 if err := db.QueryRow(`SELECT sha256,wire_message,psha256,time_sent FROM msg WHERE id=$1`, id).Scan(&hash, &data, &parent, &stamp); err != nil { @@ -188,6 +204,9 @@ func TestStandaloneMigration(t *testing.T) { if id == published && !bytes.Equal(hash, oldHash) { t.Fatal("published string hash changed") } + if id == compressed && !bytes.Equal(hash, localHash) { + t.Fatal("published compressed hash changed") + } if id == received && !bytes.Equal(hash, remoteHash) { t.Fatal("received hash changed") } @@ -205,7 +224,7 @@ func TestStandaloneMigration(t *testing.T) { } snapshots[id] = data } - for _, id := range []int64{localBatch, receivedBatch} { + for _, id := range []int64{localBatch, publishedBatchID, receivedBatch} { var hash, data []byte if err := db.QueryRow(`SELECT sha256,wire_message FROM msg_add_to_batch WHERE id=$1`, id).Scan(&hash, &data); err != nil { t.Fatal(err) @@ -213,10 +232,28 @@ func TestStandaloneMigration(t *testing.T) { if _, err := fmsg.UnmarshalPrepared(data, hash); err != nil { t.Fatal(err) } + if id == publishedBatchID && !bytes.Equal(hash, publishedBatch.hash) { + t.Fatal("published batch hash changed") + } if id == receivedBatch && !bytes.Equal(hash, b.hash) { t.Fatal("received batch identity changed") } } + var draftHash, draftParent, draftSnapshot []byte + var draftTime *float64 + if err := db.QueryRow(`SELECT sha256,psha256,wire_message,time_sent FROM msg WHERE id=$1`, draft).Scan(&draftHash, &draftParent, &draftSnapshot, &draftTime); err != nil { + t.Fatal(err) + } + if len(draftHash) != 0 || len(draftSnapshot) != 0 || draftTime != nil || !bytes.Equal(draftParent, rootHash) { + t.Fatal("draft identity/state changed") + } + if err := db.QueryRow(`SELECT sha256,wire_message FROM msg_add_to_batch WHERE id=$1`, draftBatch).Scan(&draftHash, &draftSnapshot); err != nil { + t.Fatal(err) + } + if len(draftHash) != 0 || len(draftSnapshot) != 0 { + t.Fatal("prematurely finalized draft batch") + } + if err := migrate(context.Background(), db, "example.com", true, io.Discard); err != nil { t.Fatal("rerun", err) }