package zitcli
import (
"bytes"
"context"
"errors"
"io"
"strconv"
"strings"
"testing"
"time"
)
// childLife is how long the fake child keeps writing after the prefix
// is already in hand; cutOffBy is the deadline a bounded read must beat.
//
// The deadline sits just UNDER the child's lifetime rather than at some
// fraction of it. What these tests distinguish is milliseconds from
// thirty seconds, so any threshold between the two proves the property
// — but a low one ALSO fails when the machine is merely busy, and this
// pair flaked three times in one afternoon under full-suite load while
// the behaviour it guards was never in doubt. A guard that cries wolf
// gets re-run until it passes, which is the same as not having it.
//
// The cost of the wider margin is that a real regression takes 25s to
// report instead of 10s. A real regression is rare; a busy machine is
// not.
const (
childLife = 30 * time.Second
cutOffBy = 25 * time.Second
)
// childSeconds is childLife for a shell, which counts in whole seconds.
func childSeconds() string {
return strconv.Itoa(int(childLife / time.Second))
}
// The point of a bounded read is that it does NOT read the rest. A
// child that would go on writing for thirty seconds must be cut off
// once the prefix is in hand, not waited for — the old whole-blob read
// is exactly the cost this replaces.
func TestArtifactHeadStopsAtTheBound(t *testing.T) {
fakeZit(t, `printf '0123456789'; sleep `+childSleep())
c := &Client{repo: "tcl"}
done := make(chan struct{})
var got []byte
var err error
go func() {
got, err = c.ArtifactHead(context.Background(), "abcd", 4)
close(done)
}()
select {
case <-done:
case <-time.After(cutOffBy):
t.Fatal("FAIL: the bounded read waited for the child instead of cutting it off")
}
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if string(got) != "0123" {
t.Fatalf("FAIL: read %q, want the first four bytes", got)
}
t.Log("ok: the head read takes its prefix and leaves the child behind")
}
// A blob shorter than the bound arrives whole, and the child's own exit
// is waited for rather than killed out from under it.
func TestArtifactHeadReturnsShortContentWhole(t *testing.T) {
fakeZit(t, `printf 'hi\n'`)
c := &Client{repo: "tcl"}
got, err := c.ArtifactHead(context.Background(), "abcd", 4096)
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if string(got) != "hi\n" {
t.Fatalf("FAIL: read %q", got)
}
t.Log("ok: a blob smaller than the bound comes back entire")
}
// A hash the store does not hold is ErrNoEntry, the same answer the
// whole-blob read gives — the bound must not hide a real failure.
func TestArtifactHeadReportsAMissingArtifact(t *testing.T) {
fakeZit(t, `echo "no artifact matches" >&2; exit 1`)
c := &Client{repo: "tcl"}
if _, err := c.ArtifactHead(context.Background(), "abcd", 4096); !errors.Is(err, ErrNoEntry) {
t.Fatalf("FAIL: missing artifact answered %v, want ErrNoEntry", err)
}
t.Log("ok: a missing artifact is still ErrNoEntry under a bounded read")
}
// The size comes from the SUMMARY, which names it in one line, so
// learning how big a file is costs no bytes of the file.
func TestArtifactSizeReadsTheSummary(t *testing.T) {
fakeZit(t, `printf 'aaaa\ntype: file\nsize: 18349022 bytes\nbinary: yes\n'`)
c := &Client{repo: "tcl"}
n, err := c.ArtifactSize(context.Background(), "aaaa")
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if n != 18349022 {
t.Fatalf("FAIL: size %d", n)
}
t.Log("ok: the size is parsed from the summary without reading the blob")
}
// A manifest's summary runs to thousands of F-card lines. The size is
// on the third; the reader must not sit through the rest.
//
// The fake child KEEPS WRITING rather than printing the prefix and
// sleeping, because a long summary is what this models and a pause is
// not. The difference is not cosmetic: readUntil consults its stop
// function on the bytes it already holds and then blocks on the next
// read, so a child that goes quiet mid-summary is waited for. With
// `printf` then `sleep`, whether that happened depended on whether the
// pipe delivered three lines in one chunk or two — usually one, so the
// test usually passed, and it failed under full-suite load looking
// exactly like a flake. Confirmed by feeding readUntil a reader that
// splits the prefix and then blocks: it waits for the whole child.
//
// Recorded rather than hardened: real zit writes a summary
// continuously, so the next chunk always arrives and the stop fires.
// A read deadline would close the latent case, and belongs with the
// context the caller passes rather than in a test fixture.
func TestArtifactSizeStopsAtTheSizeLine(t *testing.T) {
fakeZit(t, `printf 'aaaa\ntype: manifest\nsize: 50800 bytes\n'; `+
`end=$((SECONDS+`+childSeconds()+`)); while [ $SECONDS -lt $end ]; do `+
`printf 'F some/path/in/the/manifest %064d\n' $SECONDS; done`)
c := &Client{repo: "tcl"}
done := make(chan struct{})
var n int
var err error
go func() {
n, err = c.ArtifactSize(context.Background(), "aaaa")
close(done)
}()
select {
case <-done:
case <-time.After(cutOffBy):
t.Fatal("FAIL: the size read waited for the whole summary")
}
if err != nil || n != 50800 {
t.Fatalf("FAIL: size %d, err %v", n, err)
}
t.Log("ok: the size read stops at the line it came for")
}
func TestArtifactSizeReportsAMissingArtifact(t *testing.T) {
fakeZit(t, `echo "no artifact matches" >&2; exit 1`)
c := &Client{repo: "tcl"}
_, err := c.ArtifactSize(context.Background(), "abcd")
if !errors.Is(err, ErrNoEntry) {
t.Fatalf("FAIL: %v", err)
}
// A summary with no size line is a contract change, not a zero.
fakeZit(t, `printf 'aaaa\ntype: file\n'`)
if _, err := c.ArtifactSize(context.Background(), "aaaa"); err == nil ||
!strings.Contains(err.Error(), "size") {
t.Fatalf("FAIL: a summary with no size answered %v", err)
}
t.Log("ok: a missing artifact and a sizeless summary are both errors")
}
// childSleep renders childLife as the whole number of seconds `sleep`
// takes, so the shell script and the deadline cannot drift apart.
func childSleep() string {
return strconv.Itoa(int(childLife / time.Second))
}
// slowPipe hands out one chunk per call, so a reader's progress is
// countable: it models a child producing a long summary steadily.
type slowPipe struct {
chunks [][]byte
reads int
}
func (s *slowPipe) Read(p []byte) (int, error) {
if s.reads >= len(s.chunks) {
return 0, io.EOF
}
n := copy(p, s.chunks[s.reads])
s.reads++
return n, nil
}
// The property, asserted on BYTES rather than on a clock: a read that
// came for the size line stops at it and does not go on to the byte
// bound.
//
// The process-level test above can only observe how long the read took,
// and once its fake child writes continuously — which it must, or it
// blocks — a read to the 4KB bound finishes quickly too. So that test
// proves "did not wait for the whole child" and this one proves "did
// not read more than it needed". Both were wanted; only the first
// existed, and it was the one sensitive to load.
func TestReadUntilStopsAtTheLineNotTheBound(t *testing.T) {
const bound = 4 << 10
filler := bytes.Repeat([]byte("F some/path/in/the/manifest 0000\n"), 40)
p := &slowPipe{chunks: [][]byte{
[]byte("aaaa\ntype: manifest\n"), // no size line yet
[]byte("size: 50800 bytes\n"), // it arrives here
filler, filler, filler, filler, // and the summary goes on
}}
buf, err := readUntil(p, bound, sizeLineSeen)
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if !sizeLineSeen(buf) {
t.Fatal("FAIL: the read returned without the line it came for")
}
// Two chunks: the prefix, then the one carrying the size line. A
// third means it kept reading after it had the answer.
if p.reads != 2 {
t.Errorf("FAIL: the read consumed %d chunks, want 2 — it went past the line it came for", p.reads)
}
if len(buf) >= bound {
t.Errorf("FAIL: the read ran to its %d-byte bound (%d bytes) instead of stopping at the line", bound, len(buf))
}
// And a SPLIT prefix must not defeat it: the line arriving in a
// later chunk than the first is the ordinary case, not an edge.
t.Logf("ok: stopped after %d bytes in %d chunks", len(buf), p.reads)
}