mesh-mail: a send ask the operator channel can carry (issue 868) #326
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
// mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any
|
||||
// message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the
|
||||
// account, the recipients, the subject and the first lines regardless — so the ask must say all of that in
|
||||
// a form the content rule lets through (novox/hq issue 868, "Decided" note and review round 1). These tests
|
||||
// build real asks through mail_send and judge them with the real rule, copied verbatim as content_rule.go.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details
|
||||
// together. Useful for a readability check (nothing unmasked is visible anywhere), but never for judging
|
||||
// whether messenger would refuse the ask as a whole — judgedWords is that (novox/hq issue 868, review
|
||||
// round 1): Details is filtered line by line, not judged with the rest (holder.go's sayOn).
|
||||
func wholeAsk(r *sendRouter, id string) string {
|
||||
a := r.published[id]
|
||||
return a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
||||
}
|
||||
|
||||
// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's
|
||||
// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect.
|
||||
// Details is never part of it.
|
||||
func judgedWords(a asks.Ask) string {
|
||||
words := []string{a.Headline, a.Explanation, a.OnExpiry}
|
||||
for _, o := range a.Options {
|
||||
words = append(words, o.Label, o.Does)
|
||||
}
|
||||
return strings.Join(words, "\n")
|
||||
}
|
||||
|
||||
// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger
|
||||
// itself judges an ask as a whole, on judgedWords alone.
|
||||
func mustNotBeRefused(t *testing.T, a asks.Ask) {
|
||||
t.Helper()
|
||||
if refusal, ok := Check(judgedWords(a)); !ok {
|
||||
t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a))
|
||||
}
|
||||
}
|
||||
|
||||
// (1) A recipient that is one of the operator's own mesh-mail accounts (sendTestBundle's "mesh", address
|
||||
// jschoubben@novox.be) is named by the account's name alone: the ask carries no mail address at all, and
|
||||
// passes the real content rule. This is the first test issue 868's decision asked for; it fails today
|
||||
// because AskMailSend puts the raw address into the ask.
|
||||
func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh", "to": []any{"jschoubben@novox.be"},
|
||||
"subject": "Testing issue 868", "body": "Does the ask reach the operator now?"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
if json.Unmarshal([]byte(text), &out) != nil {
|
||||
t.Fatalf("not JSON: %s", text)
|
||||
}
|
||||
id, _ := out["ask"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") {
|
||||
t.Errorf("the ask carries a mail address for its own account recipient:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "your account mesh") {
|
||||
t.Errorf("the ask does not name the own account:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, router.published[id])
|
||||
}
|
||||
|
||||
// (2) A recipient outside the operator's own accounts is said without its address or host: its first
|
||||
// letter, an ellipsis and the domain's labels as words.
|
||||
func TestMailSendAskForOutsideRecipientCarriesNoAddress(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs) // to: ops@example.org, lead@example.org
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") || strings.Contains(whole, "example.org") {
|
||||
t.Errorf("the ask carries the recipients' address or host:\n%s", whole)
|
||||
}
|
||||
for _, want := range []string{"o… at example org", "l… at example org"} {
|
||||
if !strings.Contains(whole, want) {
|
||||
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
||||
}
|
||||
}
|
||||
mustNotBeRefused(t, router.published[id])
|
||||
}
|
||||
|
||||
// (3) More than three recipients are counted rather than said one by one.
|
||||
func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh",
|
||||
"to": []any{"a@example.org", "b@example.org", "c@example.org", "d@example.org", "e@example.org"},
|
||||
"subject": "Many recipients", "body": "body"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") {
|
||||
t.Errorf("the ask carries a mail address among many recipients:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "and 2 more") {
|
||||
t.Errorf("the ask does not count the recipients past the first three:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, router.published[id])
|
||||
}
|
||||
|
||||
// (4) A subject holding a mail address and a link has both masked before the ask carries it.
|
||||
func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"},
|
||||
"subject": "Contact ops@example.org or see https://example.com/issue/42",
|
||||
"body": "See https://example.com/issue/42 and write to ops@example.org for details."}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") || strings.Contains(whole, "example.com") || strings.Contains(whole, "://") {
|
||||
t.Errorf("the ask carries an unmasked address or link:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") {
|
||||
t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, router.published[id])
|
||||
}
|
||||
|
||||
// (5) review round 1: a subject the masking does not catch (an IP, a two-slash path-shaped reference, a
|
||||
// 40-character hex hash) does not refuse the whole ask: the real content rule (content_rule.go) still judges
|
||||
// the masked explanation as refused, so the subject and first lines are left out of it and pointed at Details
|
||||
// instead — the ask still names the account and recipients, and still reaches the operator.
|
||||
func TestMailSendAskFallsBackToDetailsWhenTheSubjectStillRefuses(t *testing.T) {
|
||||
for _, subject := range []string{
|
||||
"Invoice 2026/10/15",
|
||||
"server 10.0.0.1",
|
||||
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", // 40 hex characters: reHex's shape
|
||||
} {
|
||||
t.Run(subject, func(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, "subject": subject, "body": "body"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
a := router.published[id]
|
||||
mustNotBeRefused(t, a)
|
||||
if !strings.Contains(a.Explanation, "mesh") {
|
||||
t.Errorf("the explanation does not name the account:\n%s", a.Explanation)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "o… at example org") {
|
||||
t.Errorf("the explanation does not name the recipient:\n%s", a.Explanation)
|
||||
}
|
||||
if strings.Contains(a.Explanation, subject) {
|
||||
t.Errorf("the explanation still carries the subject that refused the ask:\n%s", a.Explanation)
|
||||
}
|
||||
if !strings.Contains(a.Details, "Details") && !strings.Contains(a.Explanation, "Details") {
|
||||
t.Errorf("the explanation does not point at Details:\n%s", a.Explanation)
|
||||
}
|
||||
if !strings.Contains(a.Details, subject) {
|
||||
t.Errorf("Details does not carry the subject (filtered line by line by messenger, as the subject's own line): %s", a.Details)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// (6) review round 1: a recipient written with a display name ("Name <addr>") is matched against the
|
||||
// operator's own accounts, and masked, by its address alone.
|
||||
func TestMailSendAskMatchesADisplayNameRecipientByItsAddress(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh", "to": []any{"Jochen Schoubben <jschoubben@novox.be>"},
|
||||
"subject": "Testing a display name", "body": "body"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") || strings.Contains(whole, "Jochen Schoubben") {
|
||||
t.Errorf("the ask carries the display-name recipient's address or name:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "your account mesh") {
|
||||
t.Errorf("the ask does not match the display-name recipient to its own account:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, router.published[id])
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
// What may leave the mesh (novox/hq to-be 45 §5, research 028 Q8, ADR 0227): roles and words. A
|
||||
// message carrying an address, a path or anything shaped like a secret is refused here, by the holder,
|
||||
// because Telegram is not end-to-end encrypted for bots and this rule is the only thing between a
|
||||
// condition's words and someone else's server. It is not trusted to each source.
|
||||
//
|
||||
// Deliberately wider than it must be: a commit id or a long random name is refused too. A source
|
||||
// that wants its message through says it in words; a refusal is said, never silent.
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Refusal says why a text may not leave: the class of what it carried, never the text itself.
|
||||
type Refusal struct {
|
||||
Class string // address, path or secret
|
||||
What string // a few words: "an IPv4 address", "a URL", …
|
||||
}
|
||||
|
||||
func (r Refusal) String() string { return r.Class + " (" + r.What + ")" }
|
||||
|
||||
var (
|
||||
reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`)
|
||||
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`)
|
||||
reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`)
|
||||
reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`)
|
||||
rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`)
|
||||
reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`)
|
||||
reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`)
|
||||
reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`)
|
||||
reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`)
|
||||
reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`)
|
||||
reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`)
|
||||
reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`)
|
||||
)
|
||||
|
||||
// topLevel are names that end a host name — the generic and country ones a mesh's names use, and the
|
||||
// private ones (.internal, .lan, .home, .local) a mesh is likelier to.
|
||||
var topLevel = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz
|
||||
site online tech page link
|
||||
be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie
|
||||
internal lan home local localdomain corp intranet private arpa test example invalid localhost`) {
|
||||
topLevel[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check says whether a text may leave the mesh, and when not, why.
|
||||
func Check(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case reURL.MatchString(text):
|
||||
return Refusal{"address", "a URL"}, false
|
||||
case reEmail.MatchString(text):
|
||||
return Refusal{"address", "a mail address"}, false
|
||||
case reIPv4.MatchString(text):
|
||||
return Refusal{"address", "an IPv4 address"}, false
|
||||
case reMAC.MatchString(text):
|
||||
return Refusal{"address", "a hardware address"}, false
|
||||
case reIPv6.MatchString(text):
|
||||
return Refusal{"address", "an IPv6 address"}, false
|
||||
}
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
if reWinPath.MatchString(text) {
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
if refusal, ok := randomRun(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||
}) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
|
||||
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
|
||||
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
|
||||
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
|
||||
// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever
|
||||
// the run holds.
|
||||
func CheckSecret(text string) (Refusal, bool) {
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
return randomRunOutsidePaths(text)
|
||||
}
|
||||
|
||||
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
||||
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
for _, piece := range strings.Split(run, "/") {
|
||||
if len(piece) >= 20 && looksRandom(piece) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// secretShape is the secret shapes a pattern names.
|
||||
func secretShape(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case rePEM.MatchString(text):
|
||||
return Refusal{"secret", "a key block"}, false
|
||||
case reJWT.MatchString(text):
|
||||
return Refusal{"secret", "a signed token"}, false
|
||||
case reBotToken.MatchString(text):
|
||||
return Refusal{"secret", "a bot token"}, false
|
||||
case reKnown.MatchString(text):
|
||||
return Refusal{"secret", "a known token shape"}, false
|
||||
case reAssigned.MatchString(text):
|
||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||
case reHex.MatchString(text):
|
||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
||||
func randomRun(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
if looksRandom(run) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one
|
||||
// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
func isPath(w string) bool {
|
||||
if w == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(w, "/") && len(w) > 1 {
|
||||
return true
|
||||
}
|
||||
for _, p := range []string{"~/", "./", "../", "$HOME", "${"} {
|
||||
if strings.HasPrefix(w, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\")
|
||||
}
|
||||
|
||||
// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind
|
||||
// (`machine.ace.silent`), which none of these is.
|
||||
func isHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(w, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return topLevel[parts[len(parts)-1]]
|
||||
}
|
||||
|
||||
// looksRandom: letters and digits mixed, and the characters spread as a random string's are. A
|
||||
// sentence's words are separated, so only an unbroken run reaches here.
|
||||
func looksRandom(s string) bool {
|
||||
var letters, digits int
|
||||
counts := map[rune]int{}
|
||||
for _, r := range s {
|
||||
counts[r]++
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
letters++
|
||||
case unicode.IsDigit(r):
|
||||
digits++
|
||||
}
|
||||
}
|
||||
if letters == 0 || digits == 0 {
|
||||
// One class only: a word, or a number. A long number of digits alone is a count or a time.
|
||||
return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0
|
||||
}
|
||||
return entropy(counts, len(s)) >= 3.3
|
||||
}
|
||||
|
||||
func hasUpperAndLower(s string) bool {
|
||||
return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0
|
||||
}
|
||||
|
||||
func entropy(counts map[rune]int, n int) float64 {
|
||||
e := 0.0
|
||||
for _, c := range counts {
|
||||
p := float64(c) / float64(n)
|
||||
e -= p * math.Log2(p)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
// content_rule.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR
|
||||
// 0253 §3), copied here verbatim — byte for byte, only the file renamed, since Go allows nothing else to
|
||||
// differ (novox/hq issue 868, review round 1). mesh-mail carries it at run time, not only in its tests: the
|
||||
// ask it builds is checked against the real rule before it is published (send.go), so a subject or a first
|
||||
// line the masking does not catch (an IP, a hardware address, a path-shaped reference, a hex hash, …) falls
|
||||
// back to a safe explanation rather than refusing the whole ask. Messenger's content rule stays messenger's:
|
||||
// this is a pinned copy, never a dependency on it, the way modules/networkmanager/cmd/uplink-tools/copies_test.go
|
||||
// pins its copy of uplink.go.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContentRuleCopyMatchesMessengers(t *testing.T) {
|
||||
src := filepath.Join("..", "..", "..", "messenger", "cmd", "messenger", "content.go")
|
||||
theirs, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Skipf("messenger's content.go is not beside this module (%v); nothing to pin against", err)
|
||||
}
|
||||
mine, err := os.ReadFile("content_rule.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(mine, theirs) {
|
||||
t.Error("mesh-mail's copy of messenger's content.go (content_rule.go) differs from " + src +
|
||||
": change this copy to match messenger's content rule, verbatim, whenever it changes")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -169,6 +170,12 @@ type SendRequest struct {
|
||||
Body string `json:"body"`
|
||||
Caller string `json:"caller"`
|
||||
At time.Time `json:"at"`
|
||||
// AskTo is To, already composed in a form the operator channel's content rule lets through (novox/hq
|
||||
// issue 868): a recipient that is one of the operator's own mesh-mail accounts named by that account's
|
||||
// name, any other said without its address or host. Composed once, from the accounts configured when
|
||||
// the send was asked, and kept so the ask reads the same way every time it is recomposed (Decide,
|
||||
// mail_send_status) as it did when the operator was first asked.
|
||||
AskTo string `json:"ask_to"`
|
||||
// Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2).
|
||||
Decided *SendDecision `json:"decided,omitempty"`
|
||||
}
|
||||
@@ -205,25 +212,52 @@ func firstChars(s string, n int) string {
|
||||
}
|
||||
|
||||
// Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first
|
||||
// lines of the body (novox/hq ADR 0361 §2).
|
||||
// lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets
|
||||
// through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and
|
||||
// body have every mail address and link or host name masked (maskText) before they reach the ask. Messenger
|
||||
// refuses an ask as a whole on its Headline, Explanation, OnExpiry and each option's Label and Does alone
|
||||
// (asks.go's TakeAsk) — never Details, which it filters line by line instead (holder.go's sayOn) — so once
|
||||
// those are composed, the real content rule (content_rule.go, mesh-mail's own run-time copy) judges them
|
||||
// exactly as messenger would. When the masking still leaves something the rule refuses (an IP, a hardware
|
||||
// address, a path-shaped reference, a hex hash, … novox/hq issue 868, review round 1), the subject and first
|
||||
// lines are left out of the Explanation and pointed at Details instead, so the ask always reaches the operator.
|
||||
// The mail itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body.
|
||||
func (s SendRequest) Ask() asks.Ask {
|
||||
to := strings.Join(s.To, ", ")
|
||||
to := s.AskTo
|
||||
subject := maskText(s.Subject)
|
||||
body := maskText(firstChars(s.Body, ShownBodyChars))
|
||||
headline := fmt.Sprintf("Send mail from %s?", s.Account)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = "Send mail?"
|
||||
}
|
||||
explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, s.Subject)
|
||||
if len([]rune(explanation)) > asks.SummaryLength {
|
||||
explanation = fmt.Sprintf("From %s: %q.", s.Account, s.Subject)
|
||||
}
|
||||
details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s",
|
||||
s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, s.Subject, firstChars(s.Body, ShownBodyChars))
|
||||
a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: "nothing is sent"}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
onExpiry := "nothing is sent"
|
||||
options := []struct{ id, label, does string }{
|
||||
{OptionSend, "Send", "the mail is sent through " + s.Account + "'s outgoing server"},
|
||||
{OptionDecline, "Decline", "nothing is sent"},
|
||||
} {
|
||||
}
|
||||
// judged is exactly the words messenger refuses (or lets through) an ask as a whole on.
|
||||
judged := func(explanation string) string {
|
||||
words := []string{headline, explanation, onExpiry}
|
||||
for _, o := range options {
|
||||
words = append(words, o.label, o.does)
|
||||
}
|
||||
return strings.Join(words, "\n")
|
||||
}
|
||||
explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject)
|
||||
if len([]rune(explanation)) > asks.SummaryLength {
|
||||
explanation = fmt.Sprintf("From %s: %q.", s.Account, subject)
|
||||
}
|
||||
if _, ok := Check(judged(explanation)); !ok {
|
||||
explanation = fmt.Sprintf("From %s to %s. The subject and first lines are in Details.", s.Account, to)
|
||||
if len([]rune(explanation)) > asks.SummaryLength {
|
||||
explanation = "Send mail. The subject and first lines are in Details."
|
||||
}
|
||||
}
|
||||
details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s",
|
||||
s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body)
|
||||
a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: onExpiry}
|
||||
for _, o := range options {
|
||||
binds, _ := asks.ActDigest(s.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
@@ -298,13 +332,16 @@ type routerRecord struct {
|
||||
// ---- asking ----------------------------------------------------------------------------------------------------
|
||||
|
||||
// SendInput is one send a caller asks for: the account is already the caller's to use (the grants check, the same
|
||||
// as mail_read's, is the tool's to make before this is called).
|
||||
// as mail_read's, is the tool's to make before this is called). Accounts is every account the operator has
|
||||
// configured (not only the one sending from), read fresh by the tool: composeAskTo needs the whole list to know
|
||||
// which recipients are the operator's own (novox/hq issue 868).
|
||||
type SendInput struct {
|
||||
Account Account
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
Caller string
|
||||
Account Account
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
Caller string
|
||||
Accounts []Account
|
||||
}
|
||||
|
||||
// cleanAddresses trims and drops empty entries.
|
||||
@@ -368,7 +405,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
|
||||
return refuse(fmt.Sprintf("the account %s has no outgoing server configured", in.Account.Name))
|
||||
}
|
||||
req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body,
|
||||
Caller: in.Caller, At: now.UTC()}
|
||||
Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)}
|
||||
ask := req.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
return refuse(err.Error())
|
||||
@@ -402,6 +439,119 @@ func onwards(to []string) string {
|
||||
return fmt.Sprintf(" and %d more", len(to)-1)
|
||||
}
|
||||
|
||||
// ---- masking the ask for the operator channel's content rule (ADR 0253 §3, novox/hq issue 868) ------------------
|
||||
//
|
||||
// messenger's content rule refuses any message that carries a mail address, a URL or a bare host name, because
|
||||
// Telegram is not end-to-end encrypted for bots. ADR 0361 §2 still needs the ask to say the recipients, the
|
||||
// subject and the first lines of the body, so mail_send composes them in a form the rule lets through instead:
|
||||
// a recipient of the operator's own is named by its account; any other loses its address and host; the subject
|
||||
// and body have every address and link masked. The mail itself is sent unchanged — only the ask's words differ.
|
||||
|
||||
// mostShownRecipients is how many recipients the ask names one by one before it counts the rest.
|
||||
const mostShownRecipients = 3
|
||||
|
||||
// composeAskTo is the recipient line for an ask: a recipient that is one of the accounts given is named "your
|
||||
// account <name>"; any other is said without its address or host (its first letter, an ellipsis, and its
|
||||
// domain's labels as words); past the first few, the rest are counted rather than named.
|
||||
func composeAskTo(to []string, accounts []Account) string {
|
||||
own := map[string]string{}
|
||||
for _, a := range accounts {
|
||||
if a.Address != "" {
|
||||
own[strings.ToLower(a.Address)] = a.Name
|
||||
}
|
||||
}
|
||||
shown, more := to, 0
|
||||
if len(shown) > mostShownRecipients {
|
||||
shown, more = shown[:mostShownRecipients], len(shown)-mostShownRecipients
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, addr := range shown {
|
||||
parts = append(parts, oneAskRecipient(addr, own))
|
||||
}
|
||||
line := strings.Join(parts, ", ")
|
||||
if more > 0 {
|
||||
line += fmt.Sprintf(" and %d more", more)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// oneAskRecipient is one recipient's words in the ask: "your account <name>" for one of the accounts given, or
|
||||
// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). A
|
||||
// recipient written with a display name ("Name <addr>") is matched and masked by its address alone, parsed the
|
||||
// same way AskMailSend already validates it (novox/hq issue 868, review round 1).
|
||||
func oneAskRecipient(addr string, own map[string]string) string {
|
||||
bare := addr
|
||||
if parsed, err := mail.ParseAddress(addr); err == nil {
|
||||
bare = parsed.Address
|
||||
}
|
||||
if name, ok := own[strings.ToLower(bare)]; ok {
|
||||
return "your account " + name
|
||||
}
|
||||
local, domain, found := strings.Cut(bare, "@")
|
||||
if !found || local == "" {
|
||||
return "a recipient"
|
||||
}
|
||||
first := string([]rune(local)[:1])
|
||||
return first + "… at " + strings.ReplaceAll(domain, ".", " ")
|
||||
}
|
||||
|
||||
// maskEmail and maskURL mirror messenger's content rule's own patterns (content_test.go's reEmail, reURL),
|
||||
// so what they catch here is masked before the rule ever judges it.
|
||||
var (
|
||||
maskEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
maskURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S+`)
|
||||
// maskWord splits text the same way the content rule's Check does (its own strings.FieldsFunc separators),
|
||||
// so a bare host name is found on the same tokens the rule itself would judge.
|
||||
maskWord = regexp.MustCompile(`[^\s"'` + "`" + `()\[\]{}<>,;|]+`)
|
||||
)
|
||||
|
||||
// maskTopLevel are the top-level labels a bare host name's last part may be, for masking alone: the same list
|
||||
// content_test.go's own topLevel carries, so a word this masks is also a word the rule would have refused.
|
||||
var maskTopLevel = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz
|
||||
site online tech page link
|
||||
be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie
|
||||
internal lan home local localdomain corp intranet private arpa test example invalid localhost`) {
|
||||
maskTopLevel[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// maskText replaces every mail address with "[address]" and every link or host name with "[link]", so the
|
||||
// subject and body first lines may reach the operator channel. It never touches s.Subject or s.Body themselves:
|
||||
// the mail is sent with what was actually written (novox/hq issue 868).
|
||||
func maskText(s string) string {
|
||||
s = maskEmail.ReplaceAllString(s, "[address]")
|
||||
s = maskURL.ReplaceAllString(s, "[link]")
|
||||
return maskWord.ReplaceAllStringFunc(s, func(word string) string {
|
||||
trimmed := strings.TrimRight(word, ".,:;!?")
|
||||
if looksLikeHostName(trimmed) {
|
||||
return "[link]" + word[len(trimmed):]
|
||||
}
|
||||
return word
|
||||
})
|
||||
}
|
||||
|
||||
// looksLikeHostName: two labels or more, the last a top-level one — the same shape content.go's isHostName
|
||||
// judges a bare host name by.
|
||||
func looksLikeHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(w, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return maskTopLevel[parts[len(parts)-1]]
|
||||
}
|
||||
|
||||
// ---- deciding ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked:
|
||||
|
||||
@@ -334,7 +334,9 @@ func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first lines.
|
||||
// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first
|
||||
// lines — the recipients without their addresses, in the form the operator channel's content rule (ADR 0253 §3)
|
||||
// lets through (novox/hq issue 868; ask_test.go judges the real rule on asks like this one).
|
||||
func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
@@ -358,11 +360,14 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
|
||||
t.Errorf("the ask does not pass the asks contract: %v", err)
|
||||
}
|
||||
whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
||||
for _, want := range []string{"mesh", "ops@example.org", "lead@example.org", "The nightly gate is red", "The merge gate failed on the resolver change"} {
|
||||
for _, want := range []string{"mesh", "o… at example org", "l… at example org", "The nightly gate is red", "The merge gate failed on the resolver change"} {
|
||||
if !strings.Contains(whole, want) {
|
||||
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
||||
}
|
||||
}
|
||||
if strings.Contains(whole, "ops@example.org") || strings.Contains(whole, "lead@example.org") {
|
||||
t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole)
|
||||
}
|
||||
for _, o := range a.Options {
|
||||
if o.Level != asks.Approve || o.Binds == "" {
|
||||
t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds)
|
||||
|
||||
@@ -471,7 +471,8 @@ func (b *Bundle) sendTool(args map[string]any) (any, error) {
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no account %q is held; mail_accounts lists the accounts", account)
|
||||
}
|
||||
return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller}, b.sends(), b.askBus(), b.now())
|
||||
return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller, Accounts: accounts},
|
||||
b.sends(), b.askBus(), b.now())
|
||||
}
|
||||
|
||||
func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {
|
||||
|
||||
Reference in New Issue
Block a user