diff --git a/cmd/fmsgd/sender.go b/cmd/fmsgd/sender.go index 579d6e4..83cb111 100644 --- a/cmd/fmsgd/sender.go +++ b/cmd/fmsgd/sender.go @@ -451,14 +451,29 @@ func deliverMessage(target pendingTarget) { rtx.Rollback() return } - sharedHash, err := m.sharedHash() - if err != nil { - log.Printf("ERROR: sender: computing message hash for msg %d: %s", target.MsgID, err) - rtx.Rollback() - return - } 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). + d := computeDeflate(m, target.MsgID) + defer d.removeTempFiles() + + orig := m.originalHeader() + d.applyTo(orig) + + 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) + 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 { @@ -466,13 +481,7 @@ func deliverMessage(target pendingTarget) { return } - // Compress the shared payload once; every unit header reuses it. - d := computeDeflate(m, target.MsgID) - defer d.removeTempFiles() - // Deliver the original message to its pending msg_to recipients. - orig := m.originalHeader() - d.applyTo(orig) deliverUnit(db, target, orig, "msg_to", 0) // Deliver each add-to batch as its own add-to message (one sender each). diff --git a/cmd/fmsgd/sender_hash_test.go b/cmd/fmsgd/sender_hash_test.go new file mode 100644 index 0000000..caeb476 --- /dev/null +++ b/cmd/fmsgd/sender_hash_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "bytes" + "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. +func TestSharedHashUsesTransmittedForm(t *testing.T) { + dir := t.TempDir() + body := strings.Repeat("compressible markdown body — the quick brown fox. ", 200) + bodyPath := filepath.Join(dir, "data.md") + if err := os.WriteFile(bodyPath, []byte(body), 0o600); err != nil { + 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, + } + + undeflated, err := m.originalHeader().GetMessageHash() + if err != nil { + t.Fatal(err) + } + + d := computeDeflate(m, 1) + defer d.removeTempFiles() + wire := m.originalHeader() + d.applyTo(wire) + if wire.Flags&FlagDeflate == 0 { + t.Fatal("test body should have deflated — deflate heuristics changed?") + } + transmitted, err := wire.GetMessageHash() + if err != nil { + t.Fatal(err) + } + + if bytes.Equal(undeflated, transmitted) { + t.Fatal("forms hash identically; this regression test no longer exercises the trap") + } + + // 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) + got, err := receiver.GetMessageHash() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, transmitted) { + t.Fatal("transmitted-form hash not reproducible") + } +} diff --git a/cmd/fmsgd/store.go b/cmd/fmsgd/store.go index d7bc19e..e402af0 100644 --- a/cmd/fmsgd/store.go +++ b/cmd/fmsgd/store.go @@ -756,9 +756,11 @@ func (m *msgFields) originalHeader() *FMsgHeader { } // sharedHash returns the canonical hash identifying this message: its persisted -// sha256, or — when not yet persisted (e.g. local-only delivery) — its -// original-form message hash. Add-to batches reference this value as their pid -// (SPEC §12). +// 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