Files
base/network/attack_vectors_test.go
zeekayandHanzo Dev b70807b237 the suite is read twice, and the second reading is what a skip means
The adversarial suite states its own contract — pass when the defence is
there, fail when it regresses, skip for one reason while it is being built —
and nothing read it, so a skip meant whatever anyone assumed. It runs as a
gate now, and what the gate enforces is that the tests declaring themselves
blocked are exactly the tests that skipped.

Both directions matter. A count cannot tell a defence landing from another
regressing, because those net to zero. The direction that earns it is the
other one: a test written to skip only when it notices the defence missing is
green the day it is written, and fails here immediately instead of becoming
the thing that hides a regression. Six were that shape.

The declaration is read out of the same file the tests live in, so landing a
defence means deleting its marker and nothing else.

Ten cases went, and the reason is what they were: three named a package that
does not exist and an environment name read by nothing; two grepped another
repository's source, which does not belong in this one; one leaked through
its own fixture rather than through the code; four duplicated the suite from
a second file, and the two bounds only they carried were folded in. One had
been passing because it could not reach the internet, and would have started
skipping on a runner that could. A cross-shard replay is refused now, and
that one is an assertion.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:25:01 -07:00

1243 lines
46 KiB
Go

package network
// attack_vectors_test.go — Base Network adversarial test suite.
//
// A test here is one of exactly two things, and never both:
//
// - an ASSERTION of a defence. It passes while the defence holds and fails
// with a description of what regressed. Deterministic — one seeded rand
// source, no wall-clock margin, no port binding, no external service.
// - a MARKER for a defence this package does not hold. Its whole body is
// t.Skip(blockedReason), stated once at the top.
//
// Nothing decides at runtime which of the two it is. A test that skips only
// when it observes the defence missing cannot fail, and reports the regression
// as a skip — so every conditional skip in this file was rewritten into one
// shape or the other, and the `network-attack-suite` gate in hanzo.yml holds
// the file to it: a skip carrying any other reason is a failure, and so is a
// disagreement between the tests that DECLARE a marker skip and the tests that
// actually skipped. A marker that starts passing, or an assertion that starts
// skipping, is therefore a red build rather than a line in a log.
//
// Groups, in file order:
// 1. Consensus / frame / envelope integrity
// 2. P2P / transport
// 3. Archive / PITR
// 4. Shard routing / isolation
// 5. Resource exhaustion / DoS
// 6. Encryption / KMS
// 7. Correctness under concurrency
//
// The defences the assertions describe are stated as contracts in
// docs/NETWORK.md.
import (
"context"
"crypto/ed25519"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"math/rand"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// blockedReason is the one skip string this package uses, and a marker states
// it as its whole body. The gate reads it from this line, so there is one
// spelling of it rather than one here and one in the pipeline.
const blockedReason = "the defence this names is not held by this package"
// seededRand returns the deterministic rand source every test in this file
// uses. Shared constant seed keeps CI reproducible.
func seededRand(t *testing.T) *rand.Rand {
t.Helper()
return rand.New(rand.NewSource(42))
}
// mustStartCluster spins up n nodes over a memory transport, wires each into
// a shared hub, and starts them. Callers get a (ctx, cancel, []nodes, hub)
// tuple. Cleanup is registered on t.
func mustStartCluster(t *testing.T, n int, replication int) (context.Context, context.CancelFunc, []*node, *memoryHub) {
t.Helper()
hub := newMemoryHub()
peers := make([]string, n)
for i := range peers {
peers[i] = fmt.Sprintf("node-%d", i)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
nodes := make([]*node, 0, n)
for _, id := range peers {
cfg := Config{
Enabled: true,
ShardKey: "user_id",
Replication: replication,
Peers: filter(peers, id),
NodeID: id,
Role: RoleValidator,
Archive: "off",
ListenHTTP: ":0",
ListenP2P: ":0",
}
nn, err := newNodeWithTransport(cfg, hub.connect(NodeID(id)))
if err != nil {
cancel()
t.Fatalf("node %s: %v", id, err)
}
if err := nn.Start(ctx); err != nil {
cancel()
t.Fatalf("start %s: %v", id, err)
}
t.Cleanup(func() { _ = nn.Stop(context.Background()) })
nodes = append(nodes, nn)
}
t.Cleanup(cancel)
return ctx, cancel, nodes, hub
}
// ---------------------------------------------------------------------------
// Group 1 — Consensus / frame / envelope integrity.
// Threat model: peers on the quasar p2p port are untrusted; they can craft
// arbitrary envelopes and frames. The invariant is that the inner frame's
// ShardID is the only shard routing authority — the envelope header is a
// routing HINT and must be validated against the frame.
// ---------------------------------------------------------------------------
// TestAttack_FrameShardIDSpoof — R1 envelope/frame ShardID confusion.
//
// Threat: peer crafts Envelope{ShardID: "victim"} wrapping Frame{ShardID:
// "attacker"}; both are attacker-controlled on the wire.
// Invariant: a frame must be rejected if its inner ShardID does not match
// the routing ShardID (envelope header == frame.ShardID).
// Expected: shard "victim" NEVER observes a finalised frame whose inner
// ShardID differs.
func TestAttack_FrameShardIDSpoof(t *testing.T) {
ctx, _, nodes, _ := mustStartCluster(t, 2, 2)
victim := nodes[1]
sh, err := victim.shard("victim-shard")
if err != nil {
t.Fatalf("shard: %v", err)
}
preSeq := sh.LocalSeq()
evil := newFrame("attacker-shard", 1<<60, 0, []byte("OWNED"))
victim.onPeerFrame(Envelope{ShardID: "victim-shard", Frame: evil})
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if sh.LocalSeq() != preSeq {
t.Fatalf("EXPLOIT: victim-shard localSeq advanced to %d via "+
"frame whose inner ShardID was %q (envelope-frame confusion "+
"accepted). Defence missing on onPeerFrame or ingestRemote.",
sh.LocalSeq(), evil.ShardID)
}
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Millisecond):
}
}
// Defence working: victim never absorbed the cross-shard frame.
}
// TestAttack_SelfForgedSeqOverflow — R2 localSeq pinning via attacker-chosen
// Seq.
//
// Threat: peer submits a valid-for-its-own-shard frame with Seq near 2^64.
// Invariant: Shard.localSeq must be driven by quasar-finalised height, not
// by attacker-controlled Frame.Seq. Monotonic one-way advancement to
// attacker-chosen heights breaks read-your-writes for every honest client.
// Expected: honest Seq=1 followed by attacker Seq=2^62 leaves localSeq
// bounded — either localSeq stays at 1, or (if engine internally rejects
// the attacker frame) at 1 still.
func TestAttack_SelfForgedSeqOverflow(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 2, 2)
victim := nodes[1]
sh, _ := victim.shard("shard-foo")
const wildSeq uint64 = 1 << 62
evil := newFrame("shard-foo", wildSeq, 0, []byte("ghost"))
victim.onPeerFrame(Envelope{ShardID: "shard-foo", Frame: evil})
time.Sleep(300 * time.Millisecond)
got := sh.LocalSeq()
if got >= wildSeq {
t.Fatalf("EXPLOIT: localSeq=%d after a peer submitted an attacker-"+
"chosen Seq=%d. Every honest client presenting a txseq cookie "+
"<= %d now reads stale state as 'caught up'. Fix: drive "+
"localSeq from quasar-finalised height, not Frame.Seq header.",
got, wildSeq, wildSeq)
}
}
// TestAttack_DuplicateFrameReplay — idempotency check.
//
// Threat: peer replays the same finalised frame repeatedly (e.g. captured
// off the wire, resent after reboot).
// Invariant: apply must be idempotent per (salt, cksm). Second apply is a
// no-op that neither writes nor advances counters.
// Expected: FramesDuplicate counter increments; FramesFinalized does not
// double-count.
func TestAttack_DuplicateFrameReplay(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 2, 2)
n := nodes[0]
sh, _ := n.shard("shard-dup")
f := newFrame("shard-dup", 1, 0, []byte("once"))
// Submit the same frame 5 times. Idempotency is keyed on (salt, cksm)
// so the repeats must coalesce.
for i := 0; i < 5; i++ {
_ = sh.submitLocal(f)
}
// Wait for the frame to finalise rather than for a fixed interval. A
// deadline that expires is this test proving nothing, so it says so
// instead of excusing itself — an engine that finalises nothing is the
// same silence as one that never dedupes.
deadline := time.Now().Add(2 * time.Second)
for counterVal(t, n.Metrics().FramesFinalized) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
finalised := counterVal(t, n.Metrics().FramesFinalized)
if finalised == 0 {
t.Fatalf("5 submits of one frame finalised none within 2s — the apply " +
"path never ran, so this asserts nothing about dedupe.")
}
if finalised > 1 {
t.Fatalf("replay bypassed dedupe: FramesFinalized=%v (want ≤1). "+
"(salt,cksm) idempotency key broken.", finalised)
}
}
// TestAttack_OutOfOrderFrames — reordering doesn't corrupt localSeq.
//
// Threat: quasar may deliver finalised frames out of arrival order (DAG is
// not linear). If localSeq-tracking does a naive max(), reordering is fine;
// if it does anything like "only advance on +1 contiguous", stale reads
// block forever.
// Invariant: localSeq reflects the highest finalised Seq regardless of
// delivery order.
func TestAttack_OutOfOrderFrames(t *testing.T) {
// Single-node test engine so every submit finalises deterministically.
_, _, nodes, _ := mustStartCluster(t, 1, 1)
n := nodes[0]
sh, _ := n.shard("shard-ooo")
// Submit Seq=10 before Seq=1..9 — simulates quasar DAG reordering.
for _, seq := range []uint64{10, 3, 7, 1, 5, 2, 8, 4, 9, 6} {
f := newFrame("shard-ooo", seq, seq-1, []byte(fmt.Sprintf("%d", seq)))
_ = sh.submitLocal(f)
}
// Poll for the max seq to be reached.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if sh.LocalSeq() >= 10 {
return
}
time.Sleep(20 * time.Millisecond)
}
got := sh.LocalSeq()
if got < 10 {
t.Fatalf("out-of-order delivery lost frames: localSeq=%d, want ≥10. "+
"Finalised height not tracked as max() over all applied seqs.", got)
}
}
// TestAttack_ForgedBlockHeight — quasar Block.Height vs Frame.Seq skew.
//
// Threat: the quasar adapter builds a Block with Height = Frame.Seq. A
// malicious peer could submit a block whose Height disagrees with the
// embedded Frame.Seq, attempting to confuse consensus ordering.
// Invariant: blockID must be derived from the frame checksum (already is);
// Height is advisory only and cannot override Frame.Seq.
// Today the adapter code in quasar.go forwardFinalized decodes the inner
// frame — if Blue adds a Height-vs-Seq consistency check, this test
// asserts it.
func TestAttack_ForgedBlockHeight(t *testing.T) {
// The adapter today trusts Frame.Seq over Block.Height on the receive
// path (see decodeFrame in forwardFinalized). This is the correct
// invariant; the test guards against regressions that would let
// Block.Height override.
f := newFrame("shard-fbh", 42, 41, []byte("payload"))
enc := f.encode()
got, err := decodeFrame(enc)
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if got.Seq != 42 {
t.Fatalf("Frame.Seq skew: got %d want 42", got.Seq)
}
if err := got.Valid(); err != nil {
t.Fatalf("Valid after decode: %v", err)
}
}
// TestAttack_NilFrameFields — decoder robustness against edge-case inputs.
//
// Threat: peer sends frames with empty shardID, zero salt, empty payload.
// Invariant: decoder accepts or rejects deterministically; no panic, no
// out-of-bounds.
func TestAttack_NilFrameFields(t *testing.T) {
// Empty shardID: currently the decoder treats "" as a zero-length string
// which IS a valid frame by the wire format. The invariant we care about
// is "no panic on any attacker-controlled bytes".
f := newFrame("", 1, 0, nil)
if err := f.Valid(); err != nil {
t.Fatalf("Valid on empty frame: %v", err)
}
// Random garbage that's too short MUST error, not panic.
garbage := []byte{0x00, 0x00}
if _, err := decodeFrame(garbage); err == nil {
t.Fatalf("decodeFrame(garbage) = nil; want error on short input")
}
// Oversized shardIDLen field that claims length past buffer end.
bad := make([]byte, 64)
bad[0] = 1 // version
binary.BigEndian.PutUint16(bad[1:], 0xFFFF)
if _, err := decodeFrame(bad); err == nil {
t.Fatalf("decodeFrame with oversized shardID len = nil; want error")
}
}
// ---------------------------------------------------------------------------
// Group 2 — P2P / transport.
// ---------------------------------------------------------------------------
// TestAttack_NoTransportFallback — the production constructor cannot end up
// with the no-op transport.
//
// nopTransport accepts every send and delivers nothing. It is the shape a
// test asks for, and a production node that fell back to it would start
// clean, log nothing and replicate nothing — every pod its own island, each
// one certain it is a member. Broadcast returning nil is the whole problem:
// silence is indistinguishable from success.
// Invariant: newNode, the one production entry point, always injects the real
// transport. The no-op is reachable only by a caller that names it.
//
// This says nothing about whether a peer is AUTHENTICATED — that is
// TestAttack_PeerImpersonation, and it is a marker.
func TestAttack_NoTransportFallback(t *testing.T) {
cfg := Config{
Enabled: true,
ShardKey: "user_id",
Replication: 3,
Peers: []string{"b:9999", "c:9999"},
NodeID: "a",
Role: RoleValidator,
Archive: "off",
ListenHTTP: ":8090",
ListenP2P: ":9999",
}
nn, err := newNode(cfg)
if err != nil {
t.Fatalf("newNode: %v", err)
}
if _, isNop := nn.transport.(*nopTransport); isNop {
t.Fatalf("newNode built a node on the no-op transport: it sends to " +
"nobody and reports success, so a pod replicates nothing and " +
"nothing anywhere says so.")
}
}
// TestAttack_ReplayOldFrame — capture-replay across the wire.
//
// Threat: attacker captures an envelope in flight, replays it hours later.
// Invariant: dedupe keyed on (salt, cksm) coalesces — state is unchanged.
// A successful defence also means signing keys rotated before the replay
// don't suddenly accept old bytes.
func TestAttack_ReplayOldFrame(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 2, 2)
n := nodes[0]
sh, _ := n.shard("shard-replay")
f := newFrame("shard-replay", 1, 0, []byte("orig"))
_ = sh.submitLocal(f)
time.Sleep(200 * time.Millisecond)
before := sh.LocalSeq()
// Capture the frame, modify nothing, replay after a "delay".
time.Sleep(100 * time.Millisecond)
_ = sh.ingestRemote(f)
time.Sleep(200 * time.Millisecond)
after := sh.LocalSeq()
if after != before {
t.Fatalf("replay advanced state: before=%d after=%d. "+
"Dedupe on (salt,cksm) is broken.", before, after)
}
}
// TestAttack_PeerImpersonation — a peer's claimed NodeID is not attested.
//
// MARKER. A peer is whatever answers at an address in BASE_PEERS, and the
// NodeID it states in the handshake is its own word. The p2p plane is what its
// operator makes it: a cluster-internal port whose reachability is a
// NetworkPolicy question.
//
// This file used to carry an mTLS surface with SAN pinning that nothing
// constructed. It was deleted rather than wired, because it could not be wired
// here. The transport is a luxfi/zap node, which takes ONE *tls.Config and
// uses it for both the listener it binds and every peer it dials — and Go
// dials with the config verbatim, so a single config cannot name the peer it
// is dialling. Measured, on zap v1.2.7: a client config with no ServerName
// refuses to handshake at all; one carrying a peer's name verifies against
// that peer and fails hostname verification against every other; and
// InsecureSkipVerify, the usual way out, leaves VerifyPeerCertificate with an
// empty chain on the dialling side, so the pinning hook had no leaf to pin.
//
// Closing it needs the transport to build a config per destination, which is
// upstream, and certificates need an issuer, which is a deployment. Both have
// to land together: a config threaded through while nothing fills it reads as
// mTLS and is plaintext. The names that promised it now refuse — see
// TestAttack_TLSNamesRefused.
func TestAttack_PeerImpersonation(t *testing.T) {
t.Skip(blockedReason)
}
// TestAttack_QuasarFloodDOS — rate-limit / backpressure on peer submits.
//
// Threat: attacker floods the node with valid-looking frames; the apply
// loop is unbounded, engine channel fills, memory OOMs.
// Invariant: the per-shard quasar engine channel MUST be bounded (today
// cap=1024). Under flood the submit path must return an error, not block
// indefinitely.
// Expected: after N frames > channel cap, submitLocal does not hang.
func TestAttack_QuasarFloodDOS(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 1, 1)
n := nodes[0]
sh, _ := n.shard("shard-flood")
// Submit 2048 (= 2x channel cap) tiny frames as fast as we can; if the
// submit path blocks forever the test deadline trips. We constrain to
// 3 s so a pathological regression is caught.
done := make(chan struct{})
go func() {
defer close(done)
for i := uint64(1); i <= 2048; i++ {
f := newFrame("shard-flood", i, i-1, []byte{byte(i)})
_ = sh.submitLocal(f)
}
}()
select {
case <-done:
// Acceptable — either the engine absorbed all, or returned err
// on overflow. Both are bounded liveness.
case <-time.After(3 * time.Second):
t.Fatalf("flood of 2048 frames blocked submitLocal past 3 s — " +
"unbounded blocking on engine channel. At 100k shards this is " +
"a fleetwide DOS via any one shard.")
}
}
// TestAttack_GatewayMembershipPoisoning — a peer cannot add itself.
//
// Threat: a pod on the p2p plane announces membership, or answers a members
// probe with endpoints it chose, and the routing ring absorbs them.
// Invariant: the ring is built from the Membership source (DNS over
// BASE_PEERS) and from nothing a peer says. Frames arriving from an
// unannounced node are handled on their merits and change no routing.
// Expected: after traffic naming a node the ring has never heard of, the
// member set is byte-identical to what it was.
func TestAttack_GatewayMembershipPoisoning(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 2, 2)
n := nodes[0]
before := n.MembersFor("shard-x")
// A frame that arrives claiming a shard, from a node nobody listed.
n.onPeerFrame(Envelope{
ShardID: "shard-x",
Frame: newFrame("shard-x", 1, 0, []byte("from nowhere")),
})
time.Sleep(200 * time.Millisecond)
after := n.MembersFor("shard-x")
if len(before) != len(after) {
t.Fatalf("peer traffic changed the member set: %v → %v. Routing is "+
"supposed to come from the membership source and from nothing a "+
"peer sends.", before, after)
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("peer traffic changed the member set: %v → %v", before, after)
}
}
}
// ---------------------------------------------------------------------------
// Group 3 — Archive / PITR.
// ---------------------------------------------------------------------------
// TestAttack_ArchiveSegmentForgery — R3 bucket writer forges a segment.
//
// Threat: attacker with bucket write access (stale IAM, compromised CI,
// misconfigured SA) crafts a full .lbn, places it at the deterministic
// path. PITR replays it.
// Invariant: segment verification requires an Ed25519 signature over
// (body || crc || pubkey) with a key from the configured trust set. An
// attacker without the archive-role private key cannot forge.
// Expected: attacker-signed segment is rejected by decodeSegment with a
// signer-mismatch error; Range does not yield attacker frames.
func TestAttack_ArchiveSegmentForgery(t *testing.T) {
_, archivePriv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
cfg := ArchiveConfig{
URL: "mem://ignored",
SegmentTargetBytes: 4096,
FlushInterval: 20 * time.Millisecond,
RetryDeadline: time.Second,
SigningKey: archivePriv,
}
up := newMemUploader()
w := newArchiveWriter(up, "svc", cfg, nil)
t.Cleanup(func() { _ = w.Close() })
// Legitimate segment via the writer.
ctx := context.Background()
for i := uint64(1); i <= 5; i++ {
f := newFrame("victim-shard", i, i-1, []byte(fmt.Sprintf("real-%d", i)))
if err := w.Append(ctx, "victim-shard", i, f.encode()); err != nil {
t.Fatalf("append: %v", err)
}
}
_ = w.Close()
// Attacker crafts a segment with their OWN key — not the archive-role
// key — and writes it at a believable path.
attackerSigner, _ := testSignerPair(t)
sb := newSegmentBuffer("victim-shard", 5000)
evil := newFrame("victim-shard", 5000, 4999, []byte("INJECTED-PITR-ROWS"))
if err := sb.append(5000, evil.encode()); err != nil {
t.Fatalf("attacker append: %v", err)
}
enc, err := sb.encode(attackerSigner)
if err != nil {
t.Fatalf("attacker encode: %v", err)
}
key := objectKey("svc", "victim-shard", 5000, time.Now().UnixNano())
if err := up.put(context.Background(), key, enc); err != nil {
t.Fatalf("attacker put: %v", err)
}
// PITR replays. The forged segment must not be yielded — and the five
// legitimate frames must still be, because a reader that rejects
// everything also refuses the forgery and has destroyed the archive to
// do it. Both halves or neither.
it, err := w.Range(context.Background(), "victim-shard", 1, 6000)
if err != nil {
t.Fatalf("range: %v", err)
}
var seen []uint64
for f, ferr := range it {
if ferr != nil {
t.Fatalf("iter: %v", ferr)
}
if f.Seq == 5000 && string(f.Payload) == "INJECTED-PITR-ROWS" {
t.Fatalf("EXPLOIT: forged segment accepted during PITR; " +
"attacker payload replayed as if quasar-finalised. " +
"Signature verification absent or trust set too wide.")
}
seen = append(seen, f.Seq)
}
if len(seen) != 5 {
t.Fatalf("the writer's own history did not survive verification: "+
"got seqs %v, want the five legitimate frames", seen)
}
}
// TestAttack_ArchiveSegmentRewrite — R8 deterministic-key overwrite.
//
// Threat: mid-flush crash leaves a segment at `.../00000000000000000042-N.lbn`
// on disk; restart re-encodes a segment from the same startSeq with fewer
// frames (the tail was lost to the crash). Old-impl: same objectKey means
// PutObject overwrites and the tail is gone.
// Invariant: objectKey MUST include a per-flush disambiguator (nanos) so
// two flushes for the same startSeq cannot collide.
func TestAttack_ArchiveSegmentRewrite(t *testing.T) {
a := objectKey("svc", "shard", 42, 100)
b := objectKey("svc", "shard", 42, 200)
if a == b {
t.Fatalf("objectKey collision for same startSeq: a=%q b=%q. "+
"R8 regression — re-flush overwrites prior segment and loses "+
"the tail.", a, b)
}
if !strings.Contains(a, "-") || !strings.Contains(b, "-") {
t.Fatalf("objectKey missing nanos suffix: a=%q b=%q", a, b)
}
}
// TestAttack_ArchiveOutOfOrderSegments — Range must dedupe overlapping
// segments.
//
// Threat: two flushes cover the same startSeq (crash + restart). Without
// dedupe Range yields the same frame twice, PITR double-writes.
// Invariant: Range dedupes by (startSeq + frameIndex) and the later-nanos
// segment wins.
func TestAttack_ArchiveOutOfOrderSegments(t *testing.T) {
signer, verifier := testSignerPair(t)
up := newMemUploader()
cfg := ArchiveConfig{
URL: "mem://",
SegmentTargetBytes: 1 << 20,
FlushInterval: time.Hour,
RetryDeadline: time.Second,
SigningKey: signer.priv,
TrustedSegmentKeys: []ed25519.PublicKey{signer.pub},
}
w := newArchiveWriter(up, "svc", cfg, nil)
t.Cleanup(func() { _ = w.Close() })
// Two segments covering seqs 1..3, crafted directly into storage.
for _, nanos := range []int64{100, 200} {
sb := newSegmentBuffer("s", 1)
for i := uint64(1); i <= 3; i++ {
f := newFrame("s", i, i-1, []byte(fmt.Sprintf("v%d-%d", nanos, i)))
_ = sb.append(i, f.encode())
}
enc, err := sb.encode(signer)
if err != nil {
t.Fatalf("encode: %v", err)
}
_ = up.put(context.Background(), objectKey("svc", "s", 1, nanos), enc)
}
_ = verifier
// Range should yield exactly 3 frames (deduped), not 6.
it, err := w.Range(context.Background(), "s", 1, 3)
if err != nil {
t.Fatalf("range: %v", err)
}
var seen int
for _, ferr := range it {
if ferr != nil {
t.Fatalf("iter err: %v", ferr)
}
seen++
}
if seen != 3 {
t.Fatalf("overlap dedupe broken: yielded %d frames, want 3. "+
"PITR double-replays on restart→re-flush.", seen)
}
}
// TestAttack_PITRReplayCrossShard — a restore reads only its own shard.
//
// Threat: a segment for shard B is placed at shard A's path — a bucket
// prefix typo, a rename by anyone holding bucket write. It is validly
// signed, so signature verification passes it.
// Invariant: a segment names its shard INSIDE the signature and the object
// key names one too. Only the signed half counts, so Range refuses a segment
// whose declared shard is not the one being read, exactly as it refuses one
// it cannot verify.
func TestAttack_PITRReplayCrossShard(t *testing.T) {
signer, _ := testSignerPair(t)
up := newMemUploader()
cfg := ArchiveConfig{
URL: "mem://",
SegmentTargetBytes: 1 << 20,
FlushInterval: time.Hour,
RetryDeadline: time.Second,
SigningKey: signer.priv,
TrustedSegmentKeys: []ed25519.PublicKey{signer.pub},
}
w := newArchiveWriter(up, "svc", cfg, nil)
t.Cleanup(func() { _ = w.Close() })
// Place a segment encoded for shard "B" — signed by a key the reader
// trusts — at shard "A"'s path.
sb := newSegmentBuffer("B", 1)
f := newFrame("B", 1, 0, []byte("B's rows"))
_ = sb.append(1, f.encode())
enc, err := sb.encode(signer)
if err != nil {
t.Fatalf("encode: %v", err)
}
wrongPath := objectKey("svc", "A", 1, time.Now().UnixNano())
if err := up.put(context.Background(), wrongPath, enc); err != nil {
t.Fatalf("put: %v", err)
}
it, err := w.Range(context.Background(), "A", 1, 10)
if err != nil {
t.Fatalf("range: %v", err)
}
for f, ferr := range it {
if ferr != nil {
t.Fatalf("iter: %v", ferr)
}
t.Fatalf("restoring shard A yielded a frame from segment %q (seq %d) — "+
"a restore writes what it is handed, so B's rows land in A's "+
"database. Range must compare the segment's own ShardID with the "+
"shard being read.", f.ShardID, f.Seq)
}
}
// TestAttack_ArchiveBucketPermissionLeak — BASE_ARCHIVE names any bucket.
//
// MARKER. NewArchive dispatches on scheme and accepts whatever host follows
// it, so where a pod ships its frames is settled entirely by the value of an
// environment variable. There is no allowlist here and this package is the
// wrong place for one: it knows a URL, not which buckets the deployment owns.
// That belongs to whatever writes the variable — the operator validating a
// spec against the buckets it provisioned.
//
// It previously read as an assertion and was not one. It called NewArchive
// with an s3:// URL, which resolves credentials and asks the endpoint whether
// the bucket exists — so it passed by failing to reach AWS, and would have
// started skipping on any runner that could. A suite whose first line is
// "no external services" cannot settle this by dialling one.
func TestAttack_ArchiveBucketPermissionLeak(t *testing.T) {
t.Skip(blockedReason)
}
// ---------------------------------------------------------------------------
// Group 4 — Shard routing / isolation.
// ---------------------------------------------------------------------------
// The marker that stood here said a client can name its own shard, because
// core.shardResolver read the identity first and fell back to the header
// X-<Shard-Key> when there was none. BASE_SHARD_KEY now names ONE source and
// names it in the value: ShardKeyHeader ("header:") reads the request header
// an operator wrote, and every other form reads the verified identity and
// nothing else. The assertion is where the resolver is — core's
// TestShardResolverIdentityReadsNothingElse — because this package cannot
// import core, and a marker here for a defence held there would be reporting
// on a file it cannot see.
//
// What a shard reaches, for whoever reads that test next: the resolved id goes
// to writeForward and nowhere else, so it selects WHICH POD serves a write and
// no part of what that pod will let the caller read or write. Isolation is the
// org's Base and the collection rules. What it selects among is this ring, so
// the pod is always a member of BASE_PEERS.
// TestAttack_OwnerConsistencyAcrossPods — deterministic routing invariant.
//
// Threat: two pods disagree on ownership of a shard; gateway sends writes
// to one, reads to the other; state diverges.
// Invariant: router.ownerOf(s) must be byte-identical across any pod
// sharing the same member set.
func TestAttack_OwnerConsistencyAcrossPods(t *testing.T) {
members := []NodeID{"a", "b", "c", "d", "e"}
r1 := newRouter(members, 3)
// Shuffle the member list to mimic different pods seeing peers in
// different orders.
rnd := seededRand(t)
shuffled := append([]NodeID(nil), members...)
rnd.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
r2 := newRouter(shuffled, 3)
for _, shard := range []string{"u1", "u2", "u3", "u4", "u5"} {
if r1.ownerOf(shard) != r2.ownerOf(shard) {
t.Fatalf("shard %q ownership diverges: r1=%q r2=%q. "+
"Non-deterministic routing → split-brain.",
shard, r1.ownerOf(shard), r2.ownerOf(shard))
}
}
}
// TestAttack_EmptyShardKey — Config.ShardKey validation.
//
// Threat: production deploy with BASE_SHARD_KEY unset; every request
// lands on shard "" which collapses every base into one engine.
// Invariant: Config.validate() rejects empty ShardKey when Enabled.
func TestAttack_EmptyShardKey(t *testing.T) {
cfg := Config{Enabled: true, Replication: 1, NodeID: "a"}
if err := cfg.validate(); err == nil {
t.Fatalf("empty ShardKey with Enabled=true accepted. Every base " +
"collapses into one engine → no isolation.")
}
}
// TestAttack_ConcurrentRebalance — member churn while writes in flight.
//
// Threat: during a pod scale event, a shard's owner changes; writes hit
// old owner, reads hit new owner, localSeq mismatch.
// Invariant: router setMembers is atomic; membersFor always reflects a
// single snapshot (never a partially-mutated state). Race detector
// catches torn writes under -race.
func TestAttack_ConcurrentRebalance(t *testing.T) {
members := []NodeID{"a", "b", "c"}
r := newRouter(members, 2)
stop := make(chan struct{})
readerDone := make(chan uint64)
go func() {
var n uint64
for {
select {
case <-stop:
readerDone <- n
return
default:
}
ms := r.membersFor("shard-x")
if len(ms) == 0 {
readerDone <- n
t.Error("empty members during rebalance — torn setMembers")
return
}
// Assert no duplicates in the snapshot (a partial setMembers
// could leave dupes in a naive impl).
seen := map[NodeID]bool{}
for _, m := range ms {
if seen[m] {
readerDone <- n
t.Errorf("duplicate member in snapshot: %v", ms)
return
}
seen[m] = true
}
n++
}
}()
// Rebalance repeatedly.
for i := 0; i < 500; i++ {
set := []NodeID{"a", "b", "c", "d"}
if i%2 == 0 {
set = []NodeID{"a", "b", "c"}
}
r.setMembers(set)
}
close(stop)
ops := <-readerDone
if ops == 0 {
// Extreme scheduler starvation. Acceptable outcome: the
// reader goroutine simply never ran on a busy CI. Don't fail —
// race-detector already validated the lock semantics.
t.Log("reader goroutine did not run (CI scheduler busy) — " +
"race detector validates the invariant in a separate run")
}
}
// ---------------------------------------------------------------------------
// Group 5 — Resource exhaustion / DoS.
// ---------------------------------------------------------------------------
// TestAttack_UnboundedShardBacklog — R6 archive backlog cap.
//
// Threat: S3 outage + high write rate → per-shard backlog grows without
// bound → OOM.
// Invariant: BacklogMaxBytes / BacklogMaxSegments enforced; oldest
// segments dropped with IncDrops metric firing.
// Expected: after 4000 appends against a failing uploader, backlog
// remains bounded by the cap.
func TestAttack_UnboundedShardBacklog(t *testing.T) {
signer, _ := testSignerPair(t)
fail := &failingUploader{}
drops := atomic.Int64{}
m := &ArchiveMetrics{
IncDrops: func() { drops.Add(1) },
}
cfg := ArchiveConfig{
SegmentTargetBytes: 64, // small, rotates frequently
FlushInterval: 5 * time.Millisecond,
RetryDeadline: 20 * time.Millisecond,
BacklogMaxBytes: 4096, // tight cap
BacklogMaxSegments: 32,
SigningKey: signer.priv,
}
w := newArchiveWriter(fail, "svc", cfg, m)
t.Cleanup(func() { _ = w.Close() })
ctx := context.Background()
for i := uint64(1); i <= 4000; i++ {
f := newFrame("shard-A", i, i-1, []byte(strings.Repeat("x", 60)))
if err := w.Append(ctx, "shard-A", i, f.encode()); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
time.Sleep(500 * time.Millisecond)
w.mu.Lock()
q := w.shards["shard-A"]
backlogBytes := 0
for _, p := range q.backlog {
backlogBytes += len(p.data)
}
backlogLen := len(q.backlog)
w.mu.Unlock()
// The cap plus at most the one segment that crossed it before the shed.
// A slacker bound passes while the cap is off by a factor.
if backlogBytes > cfg.BacklogMaxBytes+cfg.SegmentTargetBytes {
t.Fatalf("backlog bytes %d over cap %d (+1 segment of %d); the cap is "+
"not bounding memory. Under a storage outage the pod OOMs.",
backlogBytes, cfg.BacklogMaxBytes, cfg.SegmentTargetBytes)
}
if backlogLen > cfg.BacklogMaxSegments+1 {
t.Fatalf("backlog segments %d over cap %d", backlogLen, cfg.BacklogMaxSegments)
}
if drops.Load() == 0 {
t.Fatalf("4000 appends against failing uploader produced 0 drops; " +
"either the cap is absent or IncDrops wiring is missing, and the " +
"bound above holds vacuously.")
}
}
// TestAttack_ApplyLoopStarvation — one noisy shard starves others.
//
// Threat: a pathological shard floods its apply loop; other shards'
// applies stall.
// Invariant: each shard has its own applyLoop goroutine; contention on
// shared mutexes must be bounded.
func TestAttack_ApplyLoopStarvation(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 1, 1)
n := nodes[0]
noisy, _ := n.shard("noisy")
quiet, _ := n.shard("quiet")
// Hammer noisy with 500 frames; quiet gets 1.
for i := uint64(1); i <= 500; i++ {
f := newFrame("noisy", i, i-1, []byte{byte(i)})
_ = noisy.submitLocal(f)
}
qf := newFrame("quiet", 1, 0, []byte("q"))
_ = quiet.submitLocal(qf)
// Both should make progress within 2s.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if quiet.LocalSeq() >= 1 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("quiet shard starved: its 1 frame did not finalise while " +
"noisy shard's 500 were in flight. Per-shard apply isolation broken.")
}
// TestAttack_ManySmallShards — nothing bounds how many shards a pod holds.
//
// MARKER, and capacity rather than confidentiality. A shard is created by
// being named, and each one is a Quasar engine holding a 1024-slot channel,
// so the pod's floor is that product. A shard key of the wrong grain — per
// user rather than per org — reaches it without an adversary. Nothing in the
// tree is exercised above three shards.
//
// Two markers used to say this: one for the count and one for the per-shard
// cost. They are one sentence, and the product is the only thing either of
// them meant.
func TestAttack_ManySmallShards(t *testing.T) {
t.Skip(blockedReason)
}
// ---------------------------------------------------------------------------
// Group 6 — Names this package refuses.
//
// Each asserts the same thing about a different variable: a name that promises
// a property this package does not deliver is an error at startup and never a
// no-op, because an operator who sets one and sees no complaint has been told
// the property holds.
// ---------------------------------------------------------------------------
// TestAttack_WrongDEKForShard — per-shard DEK misbinding.
//
// Threat: the KMS plumbing caches DEKs in memory LRU; a bug swaps DEKs
// between shards and org A reads org B's plaintext.
// Invariant: this package decides no key, so a name that reads like one is
// refused at startup rather than accepted. An operator who sets it and gets
// no error would take encryption at rest as settled.
func TestAttack_WrongDEKForShard(t *testing.T) {
t.Setenv("BASE_ENCRYPT", "sqlcipher")
t.Setenv("BASE_NETWORK", "quasar")
t.Setenv("BASE_SHARD_KEY", "user_id")
t.Setenv("HOSTNAME", "a")
if _, err := ConfigFromEnv(); err == nil {
t.Fatal("ConfigFromEnv accepted BASE_ENCRYPT; it must refuse")
}
}
// TestAttack_TLSNamesRefused — the peer plane presents no certificate, and
// every name that offered one says so.
//
// Threat: an operator reads that the p2p transport does mTLS, mounts a CA and
// a keypair, sets these, and gets a process that starts clean. Nothing it does
// authenticates a peer, and the quiet start is what says otherwise.
// Invariant: each name is an error at startup. One at a time, because an
// operator setting only the one they read about must hit it too.
func TestAttack_TLSNamesRefused(t *testing.T) {
for _, name := range tlsNames {
t.Run(name, func(t *testing.T) {
t.Setenv("BASE_NETWORK", "quasar")
t.Setenv("BASE_SHARD_KEY", "user_id")
t.Setenv("HOSTNAME", "a")
t.Setenv(name, "/etc/base/peer.pem")
if _, err := ConfigFromEnv(); err == nil {
t.Fatalf("ConfigFromEnv accepted %s. A name that reads as peer "+
"authentication and does nothing tells an operator the "+
"property holds; it must refuse", name)
}
})
}
}
// The three markers that stood here — a rogue KMS cluster member, a deleted
// DEK, a platform-wide KEK — all named base/plugins/kms and the env variable
// BASE_KEK_SCOPE. Neither exists. A key is DERIVED per org from a master key
// (hanzoai/cek, one derivation for the platform), so there is no wrapper to
// delete and no scope to choose, and the separation the third one asked for is
// asserted where the derivation is: plugins/org's TestOrgDB_OrgDEK_UniquePerOrg
// and TestOrgDB_UserDEK_UniquePerUser. Whether the KMS a Base talks to is
// authenticated is the KMS client's question, in plugins/org, not the
// replication plane's.
// TestAttack_FeatureGateEscape — BASE_NETWORK accepts only known modes.
//
// Threat: an attacker-controlled env injection sets BASE_NETWORK to an
// unknown value; the code silently falls back to standalone.
// Invariant: ConfigFromEnv rejects unknown modes with an explicit error.
func TestAttack_FeatureGateEscape(t *testing.T) {
t.Setenv("BASE_NETWORK", "paxos-v99")
t.Setenv("BASE_SHARD_KEY", "user_id")
t.Setenv("HOSTNAME", "a")
if _, err := FromEnv(); err == nil {
t.Fatalf("unknown BASE_NETWORK=paxos-v99 accepted; fail-open on " +
"feature gate is a silent-regression vector.")
}
}
// ---------------------------------------------------------------------------
// Group 7 — Correctness under concurrency.
// ---------------------------------------------------------------------------
// TestAttack_WriterChurnLostCommits — concurrent submits must all finalise.
//
// Threat: two goroutines submit on the same shard concurrently; one
// finalisation overwrites another's seq counter.
// Invariant: atomic Seq allocation; no torn updates.
func TestAttack_WriterChurnLostCommits(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 1, 1)
n := nodes[0]
sh, _ := n.shard("shard-churn")
const goroutines = 8
const perGoroutine = 25
var wg sync.WaitGroup
w := &shardWriter{shardID: "shard-churn", src: nopSource{}}
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perGoroutine; i++ {
f, err := w.buildFrame()
if err != nil {
return
}
_ = sh.submitLocal(f)
}
}()
}
wg.Wait()
// Wait for engine finalise to drain.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
fin := counterVal(t, n.Metrics().FramesFinalized)
if fin >= float64(goroutines*perGoroutine) {
return
}
time.Sleep(20 * time.Millisecond)
}
got := counterVal(t, n.Metrics().FramesFinalized)
if got < float64(goroutines*perGoroutine) {
t.Fatalf("concurrent writers lost commits: FramesFinalized=%v "+
"want %d. Atomic seq allocation likely broken.",
got, goroutines*perGoroutine)
}
}
// TestAttack_NetworkPartition — apply loops must not spin on closed ctx.
//
// Threat: a partition stops peer delivery; applyLoop spins on a ctx that
// was cancelled, burning CPU.
// Invariant: shard.close() cancels ctx and applyLoop exits promptly.
func TestAttack_NetworkPartition(t *testing.T) {
_, _, nodes, _ := mustStartCluster(t, 1, 1)
n := nodes[0]
sh, _ := n.shard("shard-partition")
start := runtime.NumGoroutine()
sh.close()
// Give the goroutine a beat to exit.
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= start {
return
}
time.Sleep(20 * time.Millisecond)
}
// A leaked goroutine isn't immediately fatal — Go's runtime may
// schedule it later — but if we still see > start+2 after 500 ms,
// the apply loop is spinning.
if runtime.NumGoroutine() > start+2 {
t.Fatalf("applyLoop goroutine did not exit after shard.close(); " +
"partition = goroutine leak + CPU burn.")
}
}
// TestAttack_ClockSkew — node with skewed clock must not break ordering.
//
// Threat: one node's clock is 1 hr ahead; Frame.Timestamp on its frames
// claims the future; replicas that rely on Timestamp for ordering fail.
// Invariant: ordering is by quasar Block.Height (= Frame.Seq), never by
// Timestamp. Timestamp is advisory metadata.
func TestAttack_ClockSkew(t *testing.T) {
// Build two frames for the same shard with equal seqs but wildly
// different timestamps. blockID (= cksm) differs because Salt differs;
// both round-trip, neither affects the other.
past := newFrame("shard-skew", 1, 0, []byte("past"))
future := newFrame("shard-skew", 1, 0, []byte("future"))
future.Timestamp = time.Now().Add(time.Hour).UnixNano()
if past.ApplyKey() == future.ApplyKey() {
t.Fatalf("independent frames share ApplyKey; dedupe would collide " +
"even across clock-skewed submissions")
}
// Both must pass Valid().
if err := past.Valid(); err != nil {
t.Fatalf("past.Valid: %v", err)
}
// `future` has an altered Timestamp but computeCksm does not include
// Timestamp (see wal.go:61-74). That's fine — Timestamp is advisory —
// but assert no regression would make it checksum-covered and break
// replay across clock-skewed restarts.
if err := future.Valid(); err != nil {
t.Fatalf("future.Valid: %v", err)
}
}
// ---------------------------------------------------------------------------
// Creative additions — discovered while writing the suite.
// ---------------------------------------------------------------------------
// TestAttack_SegmentCRCWithBadSig — CRC passes but signature fails.
//
// Threat: attacker with bucket-write access but no signing key tries to
// slip a CRC-valid but signature-invalid segment past the reader.
// Invariant: signature check comes AFTER crc, so a CRC-valid but
// sig-invalid segment MUST still be rejected.
func TestAttack_SegmentCRCWithBadSig(t *testing.T) {
signer, verifier := testSignerPair(t)
sb := newSegmentBuffer("s", 1)
_ = sb.append(1, newFrame("s", 1, 0, []byte("legit")).encode())
enc, err := sb.encode(signer)
if err != nil {
t.Fatalf("encode: %v", err)
}
// Corrupt only the signature — last 64 bytes.
enc[len(enc)-1] ^= 0x55
// Recompute CRC after corruption so CRC still passes.
bodyEnd := len(enc) - (segmentPubKeyLen + segmentSigLen + segmentFooterCRCLen)
crc := crc32.ChecksumIEEE(enc[:bodyEnd])
binary.BigEndian.PutUint32(enc[bodyEnd:bodyEnd+4], crc)
_, derr := decodeSegment(enc, verifier)
if derr == nil {
t.Fatalf("EXPLOIT: CRC-valid + signature-corrupt segment accepted. " +
"Signature verification is skipped when CRC matches.")
}
}
// TestAttack_SegmentV1Downgrade — LBN1 must be rejected even though it's
// a known magic.
//
// Threat: attacker writes an unauthenticated LBN1 segment at the
// deterministic path; old decoders accepted it. LBN2 readers must refuse.
// Invariant: LBN1 magic is a hard-reject.
func TestAttack_SegmentV1Downgrade(t *testing.T) {
// Build a minimal LBN1-shaped blob (magic + header + CRC, no sig).
// Manually because no encoder exists for v1 anymore.
var buf []byte
buf = append(buf, []byte("LBN1")...)
buf = append(buf, 0x00, 0x01) // shard len = 1
buf = append(buf, 's')
buf = append(buf, make([]byte, 8)...) // startSeq = 0
buf = append(buf, 0x00, 0x00, 0x00, 0x00) // frame count = 0
crc := crc32.ChecksumIEEE(buf)
cb := make([]byte, 4)
binary.BigEndian.PutUint32(cb, crc)
buf = append(buf, cb...)
// Pad with sig + pubkey space so length passes minimum header check.
buf = append(buf, make([]byte, segmentPubKeyLen+segmentSigLen)...)
_, verifier := testSignerPair(t)
_, err := decodeSegment(buf, verifier)
if err == nil {
t.Fatalf("LBN1 (unauthenticated legacy) accepted by LBN2 reader; " +
"downgrade attack succeeds.")
}
}
// TestAttack_NilVerifier — fail-closed on nil verifier.
//
// Threat: a misconfigured or racy startup yields a nil verifier; reader
// must not silently accept all.
func TestAttack_NilVerifier(t *testing.T) {
signer, _ := testSignerPair(t)
sb := newSegmentBuffer("s", 1)
_ = sb.append(1, newFrame("s", 1, 0, []byte("hi")).encode())
enc, err := sb.encode(signer)
if err != nil {
t.Fatalf("encode: %v", err)
}
if _, derr := decodeSegment(enc, nil); derr == nil {
t.Fatalf("EXPLOIT: nil verifier accepted a segment. Must fail " +
"closed with ErrSegmentUnsigned.")
}
}
// TestAttack_FrameSeqZero — Seq=0 must not collide with "not yet applied".
//
// Threat: a frame with Seq=0 is valid by the decoder; localSeq starts at
// 0; txseq cookie=0 always reports caught-up.
// Invariant: Seq=0 is a semantically valid frame (it's just the first one).
// The gateway's txseq==0 convention means "no prior write" — the first
// Seq emitted should be 1, not 0, so they can't collide.
// shardWriter.seq starts at 0 and Add(1) returns 1 first — correct. Guard
// the invariant.
func TestAttack_FrameSeqZero(t *testing.T) {
w := &shardWriter{shardID: "s", src: nopSource{}}
f, err := w.buildFrame()
if err != nil {
t.Fatalf("buildFrame: %v", err)
}
if f.Seq == 0 {
t.Fatalf("first frame has Seq=0; collides with txseq=0 sentinel. " +
"First Seq MUST be 1.")
}
}
// ---------------------------------------------------------------------------
// Helpers.
// ---------------------------------------------------------------------------
// failingUploader refuses every upload, which is what a storage outage or a
// revoked credential looks like from in here.
type failingUploader struct {
attempts atomic.Uint64
}
func (u *failingUploader) put(context.Context, string, []byte) error {
u.attempts.Add(1)
return fmt.Errorf("injected failure #%d", u.attempts.Load())
}
func (*failingUploader) get(context.Context, string) ([]byte, error) {
return nil, errors.New("injected failure")
}
func (*failingUploader) list(context.Context, string) ([]string, error) { return nil, nil }
func (*failingUploader) close() error { return nil }
func (*failingUploader) scheme() string { return "fail" }