package clamd import ( "bufio" "bytes" "context" "encoding/binary" "io" "net" "strings" "testing" "time" ) // fakeDeadline bounds the fake clamd's side of every pipe, so a protocol bug // surfaces as a failing test rather than a hung one. const fakeDeadline = 5 * time.Second // frameReader decodes the INSTREAM framing off the wire: an optional // NUL-terminated command, then 4-byte big-endian length + payload chunks, // ending at the zero-length chunk. It exists so the tests assert the bytes we // actually put on the socket rather than trusting the writer to agree with // itself. type frameReader struct { r *bufio.Reader cmdRead bool remain uint32 done bool } func newFrameReader(r io.Reader) *frameReader { return &frameReader{r: bufio.NewReader(r)} } func (f *frameReader) Read(p []byte) (int, error) { if !f.cmdRead { if _, err := f.r.ReadString(0); err != nil { return 0, err } f.cmdRead = true } for f.remain == 0 { if f.done { return 0, io.EOF } var hdr [4]byte if _, err := io.ReadFull(f.r, hdr[:]); err != nil { return 0, err } f.remain = binary.BigEndian.Uint32(hdr[:]) if f.remain == 0 { f.done = true return 0, io.EOF } } if uint32(len(p)) > f.remain { p = p[:f.remain] } n, err := f.r.Read(p) f.remain -= uint32(n) return n, err } // fakeClamd stands a clamd on one end of a net.Pipe. It reads the command, // drains any INSTREAM frames, hands back the payload, then writes reply. // // NOTE: over net.Pipe (unbuffered, synchronous) the server MUST finish reading // before it writes, or both ends deadlock — the client is still blocked in a // write nobody is reading while the server blocks in a write nobody is // reading. Every helper here keeps that order. func fakeClamd(t *testing.T, reply string) (*Client, <-chan []byte) { t.Helper() client, server := net.Pipe() // A framing bug does not fail these tests, it HANGS them: a wrong length // header has the fake waiting on megabytes the client will never send // while the client waits on a reply. Verified by flipping the header to // little-endian, which without this deadline wedges until the package // test timeout ten minutes later. The deadline turns that into a clean // failure in seconds. _ = server.SetDeadline(time.Now().Add(fakeDeadline)) got := make(chan []byte, 1) go func() { defer server.Close() br := bufio.NewReader(server) cmd, err := br.ReadString(0) if err != nil { got <- nil return } var payload []byte if strings.Contains(cmd, "INSTREAM") { payload, _ = io.ReadAll(&frameReader{r: br, cmdRead: true}) } got <- payload _, _ = server.Write([]byte(reply)) }() t.Cleanup(func() { client.Close() }) return newWithConn(client), got } func replyFixture(t *testing.T, reply string) Result { t.Helper() c, _ := fakeClamd(t, reply) res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("payload"))) if err != nil { t.Fatalf("ScanStream: %v", err) } return res } func versionFixture(t *testing.T, reply string) string { t.Helper() c, _ := fakeClamd(t, reply) v, err := c.Version(context.Background()) if err != nil { t.Fatalf("Version: %v", err) } return v } // INSTREAM framing, asserted on the wire: zINSTREAM\0, then 4-byte // big-endian length + payload chunks, then a zero-length chunk. func TestScanStreamWireFormat(t *testing.T) { client, server := net.Pipe() _ = server.SetDeadline(time.Now().Add(fakeDeadline)) got := make(chan []byte, 1) go func() { defer server.Close() buf, _ := io.ReadAll(newFrameReader(server)) // reads until the zero chunk got <- buf server.Write([]byte("stream: OK\x00")) }() c := newWithConn(client) res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("hello"))) if err != nil { t.Fatal(err) } if res.Status != StatusClean { t.Fatalf("status = %v, want clean", res.Status) } if b := <-got; !bytes.Equal(b, []byte("hello")) { t.Fatalf("payload on the wire = %q, want %q", b, "hello") } } func TestScanStreamInfected(t *testing.T) { res := replyFixture(t, "stream: Eicar-Signature FOUND\x00") if res.Status != StatusInfected { t.Fatalf("status = %v, want infected", res.Status) } if res.Signature != "Eicar-Signature" { t.Fatalf("signature = %q", res.Signature) } } // A size refusal is SKIPPED, not FAILED. An over-large object was not judged; // calling that a failure would make the evidence lie about what happened. func TestScanStreamSizeExceededIsItsOwnStatus(t *testing.T) { res := replyFixture(t, "INSTREAM size limit exceeded. ERROR\x00") if res.Status != StatusSizeExceeded { t.Fatalf("status = %v, want size-exceeded", res.Status) } } // Anything else ending ERROR is the engine reading the object and failing on // it — a per-object fact, distinct from the engine being unreachable. func TestScanStreamErrorReply(t *testing.T) { res := replyFixture(t, "stream: Can't allocate memory ERROR\x00") if res.Status != StatusError { t.Fatalf("status = %v, want error", res.Status) } if !strings.Contains(res.Raw, "allocate memory") { t.Fatalf("the engine's own words did not survive: %q", res.Raw) } } func TestVersionParse(t *testing.T) { // zVERSION answers engine/sig-db-version/sig-db-date. v := versionFixture(t, "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026\x00") if v != "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026" { t.Fatalf("version = %q", v) } } // A chunked object arrives whole and in order. One Read per chunk means an // off-by-one in the framing shows up as a corrupted stream, not as a slightly // wrong length — this asserts we never send a frame header we then fail to // fill. func TestScanStreamChunksLargeObject(t *testing.T) { want := bytes.Repeat([]byte("carlos"), chunkSize) // several chunks' worth c, got := fakeClamd(t, "stream: OK\x00") if _, err := c.ScanStream(context.Background(), bytes.NewReader(want)); err != nil { t.Fatal(err) } if b := <-got; !bytes.Equal(b, want) { t.Fatalf("streamed %d bytes, want %d", len(b), len(want)) } } // Every blocking operation carries a deadline. A clamd that accepts the stream // and never answers must fail the object, not wedge the pass forever. func TestReadReplyHasADeadline(t *testing.T) { client, server := net.Pipe() defer server.Close() go func() { br := bufio.NewReader(server) br.ReadString(0) io.ReadAll(&frameReader{r: br, cmdRead: true}) // ... and then never reply. select {} }() c := newWithConn(client) c.timeouts.Read = 50 * time.Millisecond done := make(chan error, 1) go func() { _, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("x"))) done <- err }() select { case err := <-done: if err == nil { t.Fatal("a silent clamd returned success") } case <-time.After(5 * time.Second): t.Fatal("ScanStream blocked past its read deadline") } } // A zero Timeouts means the defaults, never "wait forever". func TestZeroTimeoutsTakeDefaults(t *testing.T) { c := New("/run/clamav/clamd.ctl", Timeouts{}) if c.timeouts.Dial <= 0 || c.timeouts.Write <= 0 || c.timeouts.Read <= 0 { t.Fatalf("zero timeouts left an infinite wait: %+v", c.timeouts) } } // The address decides the network, and a leading slash is the whole rule: a // unix socket is always an absolute path and a host never is. func TestNetworkForAddress(t *testing.T) { for addr, want := range map[string]string{ "/run/clamav/clamd.ctl": "unix", "/tmp/clamd.sock": "unix", "scanner.internal:3310": "tcp", "127.0.0.1:3310": "tcp", "[::1]:3310": "tcp", } { if got := networkFor(addr); got != want { t.Errorf("networkFor(%q) = %q, want %q", addr, got, want) } } } // A TCP client dials TCP and speaks the same protocol. The framing, deadlines // and reply parsing are transport-independent, so this asserts the dial and // then the whole INSTREAM exchange over a real socket. func TestScanStreamOverTCP(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() got := make(chan []byte, 1) go func() { conn, err := ln.Accept() if err != nil { got <- nil return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(fakeDeadline)) br := bufio.NewReader(conn) if _, err := br.ReadString(0); err != nil { got <- nil return } payload, _ := io.ReadAll(&frameReader{r: br, cmdRead: true}) got <- payload _, _ = conn.Write([]byte("stream: OK\x00")) }() c := New(ln.Addr().String(), Timeouts{}) if c.network != "tcp" { t.Fatalf("network = %q, want tcp", c.network) } res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("hello"))) if err != nil { t.Fatal(err) } if res.Status != StatusClean { t.Fatalf("status = %v, want clean", res.Status) } if b := <-got; !bytes.Equal(b, []byte("hello")) { t.Fatalf("payload over tcp = %q", b) } } // An empty address is refused rather than dialled, and the message says // address rather than socket now that either shape is legitimate. func TestDialRefusesEmptyAddress(t *testing.T) { c := New("", Timeouts{}) if _, err := c.Version(context.Background()); err == nil || !strings.Contains(err.Error(), "no address configured") { t.Fatalf("err = %v", err) } } // With AlertExceedsMax on, clamd reports an archive it could only partly scan // as a FOUND whose signature names the limit. That is not an infection and it // is not clean: nothing was judged, so it is the same status as an object too // large to stream. Recording it clean would sign a verdict over bytes nobody // read; recording it infected would make enforce block large honest archives. func TestLimitHeuristicIsSizeExceededNotInfected(t *testing.T) { for _, limit := range []string{"MaxScanSize", "MaxFileSize", "MaxFiles", "MaxRecursion"} { reply := "stream: Heuristics.Limits.Exceeded." + limit + " FOUND\x00" res := replyFixture(t, reply) if res.Status != StatusSizeExceeded { t.Errorf("%s: status = %v, want size-exceeded", limit, res.Status) } if res.Signature != "" { t.Errorf("%s: a limit is not a signature, got %q", limit, res.Signature) } if !strings.Contains(res.Raw, limit) { t.Errorf("%s: the engine's own words did not survive: %q", limit, res.Raw) } } } // Older engines name the limit without saying which one. func TestBareLimitHeuristicIsSizeExceeded(t *testing.T) { res := replyFixture(t, "stream: Heuristics.Limits.Exceeded FOUND\x00") if res.Status != StatusSizeExceeded { t.Fatalf("status = %v, want size-exceeded", res.Status) } } // A scan that ran out of TIME depends on load, not on the bytes, so it must be // retried. StatusError becomes a failed verdict, which every sweep re-issues; // size-exceeded becomes skipped, which is carried forward until the object // changes. Filing a timeout under skipped would exclude the object for good. func TestScanTimeLimitIsAnErrorSoItIsRetried(t *testing.T) { res := replyFixture(t, "stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND\x00") if res.Status != StatusError { t.Fatalf("status = %v, want error", res.Status) } if !strings.Contains(res.Raw, "MaxScanTime") { t.Fatalf("the engine's own words did not survive: %q", res.Raw) } } // The match is on the SIGNATURE, as a prefix. A real signature that happens to // carry the phrase elsewhere in its name is still an infection, and so is one // that only begins with something similar. func TestLookalikeSignaturesAreStillInfected(t *testing.T) { for _, sig := range []string{ "Win.Heuristics.Limits.Exceeded.Fake", "Heuristics.Limits.ExceededButNot", "Heuristics.Phishing.Email.SpoofedDomain", "Eicar-Signature", } { res := replyFixture(t, "stream: "+sig+" FOUND\x00") if res.Status != StatusInfected { t.Errorf("%s: status = %v, want infected", sig, res.Status) } if res.Signature != sig { t.Errorf("signature = %q, want %q", res.Signature, sig) } } }