package zitcli
import (
"bytes"
"context"
"fmt"
"io"
"strconv"
"strings"
)
// ArtifactHead returns the FIRST n bytes of one stored artifact.
//
// ArtifactRaw exists for the case where a reader is being handed the
// whole file. This exists for the case where something only needs to
// know what the file IS — which magic number it starts with, whether it
// is text, what its first lines say — and paying a whole 32 MiB read
// through a subprocess for that answer is the difference between a page
// that renders and a page that does not.
//
// The child is cut off rather than drained: once n bytes are in hand
// the pipe is closed and the process killed, so a large blob costs the
// prefix and not the file. A blob shorter than n arrives whole and its
// own exit is waited for, which is what keeps a missing artifact
// distinguishable from a truncated one.
func (c *Client) ArtifactHead(ctx context.Context, hash string, n int) ([]byte, error) {
return c.headOutput(ctx, n, nil, "artifact", hash, "--raw", "-R", c.repo)
}
// ArtifactSize reports one artifact's byte count WITHOUT reading it.
// zit's summary names the size on its third line; a manifest's summary
// then runs to one line per file, so this stops at the line it came
// for the same way ArtifactHead stops at its bound.
func (c *Client) ArtifactSize(ctx context.Context, hash string) (int, error) {
// Enough for the hash, the type and the size. A summary whose size
// line is past this is a zit contract change, and the error below
// says so rather than answering zero.
const summaryPrefix = 4 << 10
// Stop at the line this came for rather than at the byte bound: the
// size is on the third line of every summary, and a file blob's
// summary is four lines that the bound alone would never cut short.
out, err := c.headOutput(ctx, summaryPrefix, sizeLineSeen,
"artifact", hash, "-R", c.repo)
if err != nil {
return 0, err
}
for _, line := range strings.Split(string(out), "\n") {
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "size:")
if !ok {
continue
}
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "bytes")))
if err != nil {
return 0, fmt.Errorf("zitcli: artifact %s: unreadable size line %q", hash, line)
}
return n, nil
}
return 0, fmt.Errorf("zitcli: artifact %s: summary carried no size line", hash)
}
// headOutput runs a zit command and keeps at most n bytes of what it
// writes, killing it once that much has arrived.
//
// The kill is conditional on the bound actually being reached. Killing
// unconditionally would make every exit status "signal: killed", and
// the exit status is how a missing artifact is told from an empty one.
func (c *Client) headOutput(ctx context.Context, n int, stop func([]byte) bool, args ...string) ([]byte, error) {
cmd, done := c.command(ctx, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
pipe, err := cmd.StdoutPipe()
if err != nil {
return nil, c.storeFault(err, args[0]+" "+args[1], "")
}
if err := cmd.Start(); err != nil {
return nil, c.storeFault(err, args[0]+" "+args[1], stderr.String())
}
buf, rerr := readUntil(pipe, n, stop)
// "Enough" is either the bound or the caller's own stopping point.
full := len(buf) >= n || (stop != nil && stop(buf))
if full {
pipe.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
werr := cmd.Wait()
if full {
// The child was killed on purpose, so its status says nothing.
// The bytes are what was asked for.
return buf, nil
}
if exitCode(werr) == 1 {
return nil, fmt.Errorf("zitcli: artifact %s: %w: %s",
args[1], ErrNoEntry, firstLine(stderr.String()))
}
if werr != nil {
return nil, c.storeFault(werr, args[0]+" "+args[1], stderr.String())
}
if rerr != nil {
return nil, c.storeFault(rerr, args[0]+" "+args[1], stderr.String())
}
return buf, nil
}
// readUntil reads at most n bytes, checking stop after each chunk so a
// caller that only wants one line does not wait for a child that has
// more to say.
func readUntil(r io.Reader, n int, stop func([]byte) bool) ([]byte, error) {
buf := make([]byte, 0, min(n, 32<<10))
chunk := make([]byte, 4<<10)
for len(buf) < n {
if stop != nil && stop(buf) {
return buf, nil
}
want := min(len(chunk), n-len(buf))
got, err := r.Read(chunk[:want])
buf = append(buf, chunk[:got]...)
if err != nil {
if err == io.EOF {
return buf, nil
}
return buf, err
}
}
return buf, nil
}
// sizeLineSeen reports that a summary's size line has arrived whole —
// the line, plus the newline that proves it is not still being written.
func sizeLineSeen(buf []byte) bool {
i := bytes.Index(buf, []byte("size:"))
return i >= 0 && bytes.IndexByte(buf[i:], '\n') >= 0
}