Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions cmd/fmsgd/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,28 +451,37 @@ 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 {
log.Printf("ERROR: sender: %s", err)
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).
Expand Down
67 changes: 67 additions & 0 deletions cmd/fmsgd/sender_hash_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 5 additions & 3 deletions cmd/fmsgd/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading