A bounce could be recorded as a successful delivery. The log-tailer's Fixed: - A bounce could be recorded as a successful delivery. The log-tailer's - Log-tailer resumes where it stopped instead of jumping to end-of-file on - Level-2 rate limit no longer overshoots under concurrency: messages that Changed: - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg - Code comments no longer cite the archived specification. References like - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the - `docs/architecture.md` gained a *Code layers* section: a diagram of - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale … (1 more in CHANGELOG) Security: - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass - `saslpasswd2` argv: the application login is now passed after a `--` Added: - Optional password encryption for the two secret-bearing downloads (plan - Domain import accepts an encrypted export: the envelope is detected by its - `selfpost-backup` writes encrypted archives and reads them back: - `TestParseDelivery` covers the exotic mail.log shapes the review asked for - docs: README *Encrypting a backup or export*; `docs/security.md` § … (14 more in CHANGELOG) Removed: - docs: `security.md` — accepted risk «restore old backup revives session rows» Changed: - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release - docs: `architecture.md` — sessions (SQLite, idle renew, password change), - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. … (10 more in CHANGELOG) Full detail: CHANGELOG.md (0.5.0).
153 lines
4.3 KiB
Go
153 lines
4.3 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type fakeRun struct {
|
|
args []string
|
|
stdin string
|
|
calls int
|
|
}
|
|
|
|
func newFakeSASL() (*SASLDB, *fakeRun) {
|
|
fr := &fakeRun{}
|
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
|
s.run = func(args []string, stdin []byte) error {
|
|
fr.calls++
|
|
fr.args = args
|
|
fr.stdin = string(stdin)
|
|
return nil
|
|
}
|
|
return s, fr
|
|
}
|
|
|
|
func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) {
|
|
s, fr := newFakeSASL()
|
|
const secret = "s3cr3t-p4ss"
|
|
if err := s.Set("alerts", secret); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
if fr.stdin != secret {
|
|
t.Errorf("password not passed on stdin: got %q", fr.stdin)
|
|
}
|
|
joined := strings.Join(fr.args, " ")
|
|
if strings.Contains(joined, secret) {
|
|
t.Errorf("password leaked into argv: %q", joined)
|
|
}
|
|
// Expected fixed flags and the login as its own trailing argument, behind
|
|
// "--" so it can never be parsed as an option.
|
|
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
|
|
if len(fr.args) != len(want) {
|
|
t.Fatalf("args = %v, want %v", fr.args, want)
|
|
}
|
|
for i := range want {
|
|
if fr.args[i] != want[i] {
|
|
t.Fatalf("args = %v, want %v", fr.args, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSASLDeleteArgs(t *testing.T) {
|
|
s, fr := newFakeSASL()
|
|
if err := s.Delete("alerts"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
|
|
if strings.Join(fr.args, " ") != strings.Join(want, " ") {
|
|
t.Errorf("delete args = %v, want %v", fr.args, want)
|
|
}
|
|
if fr.stdin != "" {
|
|
t.Errorf("delete should not send stdin, got %q", fr.stdin)
|
|
}
|
|
}
|
|
|
|
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
|
|
// same shape `db_dump <sasldb2>` emits.
|
|
func makeDump(pairs [][2][]byte) []byte {
|
|
var b strings.Builder
|
|
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
|
for _, p := range pairs {
|
|
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
|
|
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
|
|
}
|
|
b.WriteString("DATA=END\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
func saslKey(login, realm, prop string) []byte {
|
|
return []byte(login + "\x00" + realm + "\x00" + prop)
|
|
}
|
|
|
|
func TestSecretExtractsPassword(t *testing.T) {
|
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
|
s.dump = func(path string) ([]byte, error) {
|
|
if path != "/data/sasl/sasldb2" {
|
|
t.Errorf("dump path = %q", path)
|
|
}
|
|
return makeDump([][2][]byte{
|
|
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
|
|
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
|
|
}), nil
|
|
}
|
|
got, err := s.Secret("alerts")
|
|
if err != nil {
|
|
t.Fatalf("Secret: %v", err)
|
|
}
|
|
if got != "hunter2-pass" {
|
|
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
|
|
}
|
|
}
|
|
|
|
func TestSecretRealmMismatchNotFound(t *testing.T) {
|
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
|
s.dump = func(string) ([]byte, error) {
|
|
// Same login but a different realm must not match.
|
|
return makeDump([][2][]byte{
|
|
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
|
|
}), nil
|
|
}
|
|
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
|
|
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestSecretMissingLoginNotFound(t *testing.T) {
|
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
|
s.dump = func(string) ([]byte, error) {
|
|
return makeDump(nil), nil
|
|
}
|
|
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
|
|
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
|
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
|
called := false
|
|
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
|
|
if _, err := s.Secret("bad login"); err == nil {
|
|
t.Error("Secret accepted invalid login")
|
|
}
|
|
if called {
|
|
t.Error("db_dump invoked for an invalid login")
|
|
}
|
|
}
|
|
|
|
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
|
|
s, fr := newFakeSASL()
|
|
if err := s.Set("bad login", "pw"); err == nil {
|
|
t.Error("Set accepted invalid login")
|
|
}
|
|
if err := s.Delete("bad@login"); err == nil {
|
|
t.Error("Delete accepted invalid login")
|
|
}
|
|
if fr.calls != 0 {
|
|
t.Errorf("saslpasswd2 invoked %d times for invalid logins, want 0", fr.calls)
|
|
}
|
|
}
|