Store and describe images through a shared media intake (#252)
Vision needs a second model this box does not have, so the shipped half is the part that works without one: an image arrives, is sniffed, is stored content-addressed, and is prepared for inference. The describing half is written and tested against a fake server, and refuses any endpoint that is not on this box. internal/media is the intake all three senses share — hearing and speaker recognition store their audio in the same place under the same retention. Blobs stay out of the sqlite store; only the derived text becomes a note, and only when the caller asks. Retention is enforced by an hourly prune loop rather than by a comment. The plan's RemoteProvider step is refused: no cloud model, inference stays on the box, and vision.NewLocal validates that at construction.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultMaxDim — the longest edge an image is scaled down to before it goes to
|
||||
// a vision model. 896 is the tile size the current crop of small
|
||||
// vision-language models (Qwen2.5-VL, SmolVLM, moondream) work in; sending a
|
||||
// 12-megapixel phone photo instead just costs the box minutes of prefill for
|
||||
// tiles that get pooled away anyway.
|
||||
const DefaultMaxDim = 896
|
||||
|
||||
// JPEGQuality for the re-encode. 85 is the usual "no visible artefacts" point,
|
||||
// and the re-encode exists to shrink the payload, not to archive it — the
|
||||
// original bytes stay in the blob store untouched.
|
||||
const JPEGQuality = 85
|
||||
|
||||
// ErrUnsupportedImage — the bytes are not an image format this build can
|
||||
// decode. Notably webp: the stdlib has no webp decoder and this repo takes no
|
||||
// new dependencies, so a webp arriving from Telegram is refused here with a
|
||||
// clear error rather than handed to a model as garbage.
|
||||
var ErrUnsupportedImage = errors.New("media: unsupported image format")
|
||||
|
||||
// SniffImage identifies image bytes by magic number and returns the mime. It
|
||||
// exists because a caller-declared content type is a claim, and the store's file
|
||||
// extension (and the vision provider's data URI) should follow the bytes.
|
||||
//
|
||||
// Returns ErrUnsupportedImage for anything unrecognised, including webp — which
|
||||
// is recognised well enough to name in the error, so the log says "webp is not
|
||||
// supported" instead of "not an image".
|
||||
func SniffImage(data []byte) (string, error) {
|
||||
switch {
|
||||
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "image/jpeg", nil
|
||||
case len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n":
|
||||
return "image/png", nil
|
||||
case len(data) >= 6 && (string(data[:6]) == "GIF87a" || string(data[:6]) == "GIF89a"):
|
||||
return "image/gif", nil
|
||||
case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||
return "", fmt.Errorf("%w: webp (no decoder in this build)", ErrUnsupportedImage)
|
||||
}
|
||||
return "", ErrUnsupportedImage
|
||||
}
|
||||
|
||||
// Image — an image prepared for a vision model: JPEG bytes, downscaled, with
|
||||
// the dimensions it ended up at. It is deliberately a separate type from Blob:
|
||||
// a Blob is what he sent, an Image is what the model sees, and the two are not
|
||||
// the same bytes.
|
||||
type Image struct {
|
||||
JPEG []byte
|
||||
Width int
|
||||
Height int
|
||||
// Source names where the original came from ("telegram", "web:upload"),
|
||||
// carried through only so a log line can say what was looked at.
|
||||
Source string
|
||||
}
|
||||
|
||||
// DataURI renders the image as a `data:image/jpeg;base64,...` URI, which is how
|
||||
// every OpenAI-compatible multimodal endpoint takes an image. The string is
|
||||
// large (roughly 4/3 of the JPEG); nothing caches it.
|
||||
func (im Image) DataURI() string {
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(im.JPEG)
|
||||
}
|
||||
|
||||
// PrepareImage decodes data, scales it so its longest edge is at most maxDim
|
||||
// (never up — a small image is left alone), and re-encodes it as JPEG.
|
||||
// maxDim ≤ 0 ⇒ DefaultMaxDim.
|
||||
//
|
||||
// An image with an alpha channel is composited onto white rather than having
|
||||
// alpha dropped to black, because the common case is a screenshot or a
|
||||
// transparent-background diagram, and text on black-on-black is unreadable to
|
||||
// the model for no reason.
|
||||
func PrepareImage(data []byte, source string, maxDim int) (Image, error) {
|
||||
if len(data) == 0 {
|
||||
return Image{}, ErrEmpty
|
||||
}
|
||||
if maxDim <= 0 {
|
||||
maxDim = DefaultMaxDim
|
||||
}
|
||||
mime, err := SniffImage(data)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
src, err := decode(data, mime)
|
||||
if err != nil {
|
||||
return Image{}, fmt.Errorf("media: decode %s: %w", mime, err)
|
||||
}
|
||||
|
||||
dst := flattenAndScale(src, maxDim)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: JPEGQuality}); err != nil {
|
||||
return Image{}, fmt.Errorf("media: encode jpeg: %w", err)
|
||||
}
|
||||
b := dst.Bounds()
|
||||
return Image{JPEG: buf.Bytes(), Width: b.Dx(), Height: b.Dy(), Source: source}, nil
|
||||
}
|
||||
|
||||
func decode(data []byte, mime string) (image.Image, error) {
|
||||
r := bytes.NewReader(data)
|
||||
switch strings.ToLower(mime) {
|
||||
case "image/jpeg":
|
||||
return jpeg.Decode(r)
|
||||
case "image/png":
|
||||
return png.Decode(r)
|
||||
case "image/gif":
|
||||
return gif.Decode(r)
|
||||
}
|
||||
return nil, ErrUnsupportedImage
|
||||
}
|
||||
|
||||
// flattenAndScale composites onto white and box-scales down to maxDim. The
|
||||
// scaler is a plain area average over the source pixels mapping to each
|
||||
// destination pixel — nearest-neighbour would alias small text into noise,
|
||||
// which defeats the point of reading a screenshot, and an area average is a
|
||||
// dozen lines against pulling in golang.org/x/image on an offline box.
|
||||
func flattenAndScale(src image.Image, maxDim int) *image.RGBA {
|
||||
sb := src.Bounds()
|
||||
sw, sh := sb.Dx(), sb.Dy()
|
||||
dw, dh := fit(sw, sh, maxDim)
|
||||
|
||||
flat := image.NewRGBA(image.Rect(0, 0, sw, sh))
|
||||
draw.Draw(flat, flat.Bounds(), image.NewUniform(image.White), image.Point{}, draw.Src)
|
||||
draw.Draw(flat, flat.Bounds(), src, sb.Min, draw.Over)
|
||||
if dw == sw && dh == sh {
|
||||
return flat
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||
for y := 0; y < dh; y++ {
|
||||
y0, y1 := y*sh/dh, (y+1)*sh/dh
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
for x := 0; x < dw; x++ {
|
||||
x0, x1 := x*sw/dw, (x+1)*sw/dw
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
var r, g, b, n uint32
|
||||
for sy := y0; sy < y1; sy++ {
|
||||
for sx := x0; sx < x1; sx++ {
|
||||
i := flat.PixOffset(sx, sy)
|
||||
r += uint32(flat.Pix[i])
|
||||
g += uint32(flat.Pix[i+1])
|
||||
b += uint32(flat.Pix[i+2])
|
||||
n++
|
||||
}
|
||||
}
|
||||
o := dst.PixOffset(x, y)
|
||||
dst.Pix[o] = uint8(r / n)
|
||||
dst.Pix[o+1] = uint8(g / n)
|
||||
dst.Pix[o+2] = uint8(b / n)
|
||||
dst.Pix[o+3] = 0xFF
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// fit returns the largest w×h with the same aspect ratio whose longest edge is
|
||||
// at most maxDim, never enlarging. Both edges are clamped to at least 1 so a
|
||||
// 2000×1 strip does not scale to zero height.
|
||||
func fit(w, h, maxDim int) (int, int) {
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return w, h
|
||||
}
|
||||
if w >= h {
|
||||
nh := h * maxDim / w
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
return maxDim, nh
|
||||
}
|
||||
nw := w * maxDim / h
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
return nw, maxDim
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes builds a w×h test image: left half red, right half a light grey, so
|
||||
// a downscale that averages produces a predictable mid value and a scaler that
|
||||
// silently returns the wrong region is visible.
|
||||
func pngBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if x < w/2 {
|
||||
img.Set(x, y, color.RGBA{255, 0, 0, 255})
|
||||
} else {
|
||||
img.Set(x, y, color.RGBA{200, 200, 200, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestSniffImage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{"png", pngBytes(t, 4, 4), "image/png"},
|
||||
{"jpeg", jpegBytes(t, 4, 4), "image/jpeg"},
|
||||
{"gif", gifBytes(t, 4, 4), "image/gif"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := SniffImage(c.data)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// webp is common from Telegram and there is no stdlib decoder, so it must be
|
||||
// refused by name rather than mis-sniffed or fed to a model as noise.
|
||||
func TestSniffRefusesWebpByName(t *testing.T) {
|
||||
webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...)
|
||||
_, err := SniffImage(webp)
|
||||
if !errors.Is(err, ErrUnsupportedImage) {
|
||||
t.Fatalf("got %v, want ErrUnsupportedImage", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "webp") {
|
||||
t.Errorf("error does not name the format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffRefusesGarbage(t *testing.T) {
|
||||
for _, data := range [][]byte{nil, []byte("hello"), []byte("\x00\x01\x02\x03")} {
|
||||
if _, err := SniffImage(data); !errors.Is(err, ErrUnsupportedImage) {
|
||||
t.Errorf("SniffImage(%q) = %v", data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageDownscalesLongestEdge(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 2000, 1000), "web:upload", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Width != 500 || im.Height != 250 {
|
||||
t.Errorf("got %dx%d, want 500x250", im.Width, im.Height)
|
||||
}
|
||||
if _, err := jpeg.Decode(bytes.NewReader(im.JPEG)); err != nil {
|
||||
t.Errorf("output is not decodable jpeg: %v", err)
|
||||
}
|
||||
if im.Source != "web:upload" {
|
||||
t.Errorf("source lost: %q", im.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Tall images scale on the other axis; a scaler that only handles landscape is
|
||||
// the classic version of this bug.
|
||||
func TestPrepareImageHandlesPortrait(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 400, 1600), "telegram", 800)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Height != 800 || im.Width != 200 {
|
||||
t.Errorf("got %dx%d, want 200x800", im.Width, im.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageNeverEnlarges(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 64, 32), "telegram", 896)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Width != 64 || im.Height != 32 {
|
||||
t.Errorf("got %dx%d, want the original 64x32", im.Width, im.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// A degenerate strip must not scale to zero on the short axis — jpeg.Encode
|
||||
// fails on a zero-height image, which would turn a weird screenshot into a
|
||||
// hard error.
|
||||
func TestPrepareImageClampsDegenerateAspect(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 2000, 2), "web:upload", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Height < 1 || im.Width != 100 {
|
||||
t.Errorf("got %dx%d", im.Width, im.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// Transparent pixels composite onto white, not black: the common case is a
|
||||
// screenshot or a diagram, and dark-on-black is unreadable to the model.
|
||||
func TestPrepareImageFlattensAlphaOntoWhite(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 8, 8)) // fully transparent
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := PrepareImage(buf.Bytes(), "web:upload", 8)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
decoded, err := jpeg.Decode(bytes.NewReader(im.JPEG))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, g, b, _ := decoded.At(4, 4).RGBA()
|
||||
if r>>8 < 240 || g>>8 < 240 || b>>8 < 240 {
|
||||
t.Errorf("transparent pixel became rgb(%d,%d,%d), want near-white", r>>8, g>>8, b>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageRejectsEmpty(t *testing.T) {
|
||||
if _, err := PrepareImage(nil, "x", 0); !errors.Is(err, ErrEmpty) {
|
||||
t.Errorf("got %v, want ErrEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataURIIsAJPEGDataURI(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 16, 16), "x", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uri := im.DataURI()
|
||||
if !strings.HasPrefix(uri, "data:image/jpeg;base64,") {
|
||||
t.Fatalf("bad prefix: %.40s", uri)
|
||||
}
|
||||
if len(uri) <= len("data:image/jpeg;base64,") {
|
||||
t.Error("data uri carries no payload")
|
||||
}
|
||||
}
|
||||
|
||||
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func gifBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{color.Black, color.White})
|
||||
var buf bytes.Buffer
|
||||
if err := gif.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package media is the intake for everything Maven sees or hears that is not
|
||||
// text: a photo he sends her, a meeting she was asked to record, a voice sample
|
||||
// used to enrol a speaker. All three senses (vision, hearing, speaker
|
||||
// recognition) share one problem — a blob arrives, it has to be stored, and
|
||||
// something has to describe it — so the storing half lives here once instead of
|
||||
// three times.
|
||||
//
|
||||
// # What this package is
|
||||
//
|
||||
// A content-addressed blob store on the local filesystem. Put returns a Blob
|
||||
// keyed by the sha256 of its bytes, so the same photo sent twice is one file.
|
||||
// Each blob gets a sidecar `.json` with its kind, mime, size, source and
|
||||
// creation time; the sidecar is the whole index, because at personal scale a
|
||||
// directory walk is cheaper than another sqlite table and the store has to be
|
||||
// readable with `ls` when something goes wrong.
|
||||
//
|
||||
// Blobs are NOT in the sqlite database. The database is small, encrypted, and
|
||||
// read on every tick; a 40 MB meeting recording has no business in it. What
|
||||
// goes in the database is the *text* a blob produced — a transcript, a
|
||||
// description — written as an ordinary note, which is the durable artefact and
|
||||
// the only part worth recalling later.
|
||||
//
|
||||
// # Invariants (these are the point of the package, not decoration)
|
||||
//
|
||||
// - Nothing is captured that was not asked for. This package never records;
|
||||
// it stores what a caller hands it, and every caller is an explicit act
|
||||
// with a start and a stop. There is no ambient path in, and none may be
|
||||
// added: see the refusal recorded in docs/plans/08-hearing.md.
|
||||
// - A blob never leaves the box. No provider in this repo may upload one, and
|
||||
// the vision provider refuses a non-private endpoint for exactly that
|
||||
// reason (internal/vision).
|
||||
// - A blob is never search input and never embedded. His photos and the audio
|
||||
// of his meetings are not corpus. Only text derived from them, once he can
|
||||
// see it as a note, participates in recall.
|
||||
// - Storage is bounded. Retention is a config knob with a default, Prune
|
||||
// enforces it, and an unpruned store is a bug: audio of people accumulating
|
||||
// forever on disk is the failure mode this capability has to avoid.
|
||||
//
|
||||
// # Layout
|
||||
//
|
||||
// <dir>/<kind>/<aa>/<sha256>.<ext> the bytes
|
||||
// <dir>/<kind>/<aa>/<sha256>.json the sidecar metadata
|
||||
//
|
||||
// `aa` is the first two hex chars of the digest — one fan-out level, enough to
|
||||
// keep a directory listing usable after a few thousand blobs.
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind — what a blob is. Two values today; the kind is a directory name and a
|
||||
// retention bucket, so adding a third is additive.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindImage — a still image (png / jpeg / gif / webp bytes as received).
|
||||
KindImage Kind = "image"
|
||||
// KindAudio — raw PCM in the canonical internal/audio format, or a WAV
|
||||
// container. Meeting captures and enrolment samples both land here.
|
||||
KindAudio Kind = "audio"
|
||||
)
|
||||
|
||||
// Valid reports whether k is a kind this package will store. An unknown kind is
|
||||
// refused at Put rather than creating a stray directory.
|
||||
func (k Kind) Valid() bool { return k == KindImage || k == KindAudio }
|
||||
|
||||
// Errors callers distinguish. ErrNotFound is the only one a caller usually
|
||||
// handles; the rest mean the call was wrong.
|
||||
var (
|
||||
// ErrNotFound — no blob with that id in this store.
|
||||
ErrNotFound = errors.New("media: not found")
|
||||
// ErrEmpty — Put was handed zero bytes. Storing an empty capture would
|
||||
// leave a sidecar claiming a recording exists when it does not.
|
||||
ErrEmpty = errors.New("media: empty payload")
|
||||
// ErrTooLarge — the payload is over the store's cap. The cap exists so a
|
||||
// runaway capture cannot fill the disk that mavend's database lives on.
|
||||
ErrTooLarge = errors.New("media: payload too large")
|
||||
// ErrBadKind — unknown Kind.
|
||||
ErrBadKind = errors.New("media: unknown kind")
|
||||
// ErrBadID — the id is not a 64-char lowercase hex digest, so it cannot
|
||||
// have come from this store and must not be turned into a path.
|
||||
ErrBadID = errors.New("media: malformed id")
|
||||
)
|
||||
|
||||
// Blob — one stored item. ID is the sha256 of the bytes in lowercase hex, which
|
||||
// makes it both the primary key and the dedupe mechanism. Path is absolute and
|
||||
// local; it is a debugging affordance and the argument a subprocess (whisper,
|
||||
// llama-server) is pointed at, never something handed to a network client.
|
||||
type Blob struct {
|
||||
ID string `json:"id"`
|
||||
Kind Kind `json:"kind"`
|
||||
MIME string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
Source string `json:"source"` // provenance: "telegram", "web:upload", "capture:meeting", "enroll"
|
||||
Created time.Time `json:"created"` // UTC
|
||||
Path string `json:"-"` // filled by the store; not part of the sidecar
|
||||
}
|
||||
|
||||
// Age is how long ago the blob was stored, measured against now. Prune uses it;
|
||||
// it is exported because the /media surface will want to show it.
|
||||
func (b Blob) Age(now time.Time) time.Duration { return now.Sub(b.Created) }
|
||||
|
||||
// String is a one-line summary for logs. Deliberately does not include Path:
|
||||
// a log line is not the place to spell out where his meeting audio lives.
|
||||
func (b Blob) String() string {
|
||||
return fmt.Sprintf("%s %s %dB from %s", b.Kind, shortID(b.ID), b.Size, b.Source)
|
||||
}
|
||||
|
||||
// shortID trims a digest to something readable in a log line. Twelve hex chars
|
||||
// is unambiguous at personal scale and short enough to fit next to the rest.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultMaxBytes — the per-blob cap when a store is built without one. 64 MiB
|
||||
// is about an hour of 16 kHz mono PCM, which is also the hearing capture's own
|
||||
// ceiling; a single item bigger than that is a mistake, not a meeting.
|
||||
const DefaultMaxBytes int64 = 64 << 20
|
||||
|
||||
// DefaultRetention — how long a blob is kept when no retention is configured.
|
||||
// Seven days is long enough to re-run a transcription that came out wrong and
|
||||
// short enough that "she has a month of my meetings on disk" is never true.
|
||||
const DefaultRetention = 7 * 24 * time.Hour
|
||||
|
||||
// Store — a content-addressed blob directory. Zero value is not usable; build
|
||||
// one with Open, which creates the directory 0700. The store holds no lock and
|
||||
// no cache: every operation is a filesystem call, and two writers of the same
|
||||
// bytes produce the same file, so concurrent Puts do not need coordinating.
|
||||
type Store struct {
|
||||
dir string
|
||||
maxBytes int64
|
||||
retention time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Open prepares a blob store rooted at dir. maxBytes ≤ 0 ⇒ DefaultMaxBytes;
|
||||
// retention ≤ 0 ⇒ DefaultRetention. The directory (and every kind subdirectory
|
||||
// created later) is 0700: these are recordings of people, and the daemon's user
|
||||
// is the only reader.
|
||||
func Open(dir string, maxBytes int64, retention time.Duration) (*Store, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, errors.New("media: empty dir")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: resolve dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("media: create dir: %w", err)
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultMaxBytes
|
||||
}
|
||||
if retention <= 0 {
|
||||
retention = DefaultRetention
|
||||
}
|
||||
return &Store{dir: abs, maxBytes: maxBytes, retention: retention, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Dir is the store root. Exported for logs and for pointing a subprocess at a
|
||||
// path under it.
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
// Retention is the configured age limit Prune enforces.
|
||||
func (s *Store) Retention() time.Duration { return s.retention }
|
||||
|
||||
// Put stores data and returns its Blob. The id is the sha256 of data, so
|
||||
// storing the same bytes twice is idempotent: the second call rewrites the
|
||||
// sidecar (keeping the ORIGINAL creation time, so a re-send cannot extend
|
||||
// retention indefinitely) and returns the same id.
|
||||
//
|
||||
// mime is recorded as given and used only to pick a file extension; nothing
|
||||
// dispatches on it. Callers that need the mime to be trustworthy sniff it
|
||||
// first — see SniffImage.
|
||||
func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
|
||||
if !kind.Valid() {
|
||||
return Blob{}, ErrBadKind
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Blob{}, ErrEmpty
|
||||
}
|
||||
if int64(len(data)) > s.maxBytes {
|
||||
return Blob{}, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(data), s.maxBytes)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
id := hex.EncodeToString(sum[:])
|
||||
|
||||
blobPath, metaPath, err := s.paths(kind, id, mime)
|
||||
if err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(blobPath), 0o700); err != nil {
|
||||
return Blob{}, fmt.Errorf("media: create bucket: %w", err)
|
||||
}
|
||||
|
||||
b := Blob{ID: id, Kind: kind, MIME: mime, Size: int64(len(data)), Source: source,
|
||||
Created: s.now().UTC(), Path: blobPath}
|
||||
|
||||
// A blob already here keeps its first-seen time. Re-sending the same photo
|
||||
// every hour must not keep it alive past retention.
|
||||
if prev, err := readMeta(metaPath); err == nil && !prev.Created.IsZero() {
|
||||
b.Created = prev.Created
|
||||
}
|
||||
|
||||
if err := writeFile(blobPath, data); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if err := writeMeta(metaPath, b); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Get returns the blob's metadata without reading its bytes.
|
||||
func (s *Store) Get(id string) (Blob, error) {
|
||||
if !validID(id) {
|
||||
return Blob{}, ErrBadID
|
||||
}
|
||||
for _, kind := range []Kind{KindImage, KindAudio} {
|
||||
metaPath := filepath.Join(s.dir, string(kind), id[:2], id+".json")
|
||||
b, err := readMeta(metaPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := s.locate(kind, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Path = p
|
||||
return b, nil
|
||||
}
|
||||
return Blob{}, ErrNotFound
|
||||
}
|
||||
|
||||
// Read returns the blob's bytes together with its metadata. This is the only
|
||||
// way out of the store, and it is a local read: nothing in this package can
|
||||
// send bytes anywhere.
|
||||
func (s *Store) Read(id string) (Blob, []byte, error) {
|
||||
b, err := s.Get(id)
|
||||
if err != nil {
|
||||
return Blob{}, nil, err
|
||||
}
|
||||
data, err := os.ReadFile(b.Path)
|
||||
if err != nil {
|
||||
return Blob{}, nil, fmt.Errorf("media: read %s: %w", shortID(id), err)
|
||||
}
|
||||
return b, data, nil
|
||||
}
|
||||
|
||||
// List returns every blob of the given kind, newest first. An empty kind lists
|
||||
// both. It walks the directory; at personal volumes (tens to hundreds of items
|
||||
// inside the retention window) that is cheap, and it means the sidecars are the
|
||||
// single source of truth with no index to fall out of sync.
|
||||
func (s *Store) List(kind Kind) ([]Blob, error) {
|
||||
kinds := []Kind{KindImage, KindAudio}
|
||||
if kind != "" {
|
||||
if !kind.Valid() {
|
||||
return nil, ErrBadKind
|
||||
}
|
||||
kinds = []Kind{kind}
|
||||
}
|
||||
var out []Blob
|
||||
for _, k := range kinds {
|
||||
root := filepath.Join(s.dir, string(k))
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil // kind never used; not an error
|
||||
}
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".json") {
|
||||
return nil
|
||||
}
|
||||
b, err := readMeta(path)
|
||||
if err != nil {
|
||||
return nil // a corrupt sidecar is skipped, not fatal
|
||||
}
|
||||
if p, err := s.locate(b.Kind, b.ID); err == nil {
|
||||
b.Path = p
|
||||
}
|
||||
out = append(out, b)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: list %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Created.Equal(out[j].Created) {
|
||||
return out[i].ID < out[j].ID
|
||||
}
|
||||
return out[i].Created.After(out[j].Created)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes a blob and its sidecar. Missing is not an error: the caller
|
||||
// asked for it gone and it is gone.
|
||||
func (s *Store) Delete(id string) error {
|
||||
if !validID(id) {
|
||||
return ErrBadID
|
||||
}
|
||||
for _, kind := range []Kind{KindImage, KindAudio} {
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
entries, err := os.ReadDir(bucket)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), id) {
|
||||
if err := os.Remove(filepath.Join(bucket, e.Name())); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("media: delete %s: %w", shortID(id), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prune deletes every blob older than the store's retention and reports how
|
||||
// many went. It is the enforcement half of the retention promise; a caller that
|
||||
// never runs it has a store that grows without bound, which is why the daemon
|
||||
// runs it on the digestion tick rather than leaving it to a cron the operator
|
||||
// might not add.
|
||||
func (s *Store) Prune() (int, error) {
|
||||
blobs, err := s.List("")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := s.now()
|
||||
deleted := 0
|
||||
for _, b := range blobs {
|
||||
if b.Age(now) <= s.retention {
|
||||
continue
|
||||
}
|
||||
if err := s.Delete(b.ID); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// paths returns the blob and sidecar paths for an id.
|
||||
func (s *Store) paths(kind Kind, id, mime string) (blobPath, metaPath string, err error) {
|
||||
if !validID(id) {
|
||||
return "", "", ErrBadID
|
||||
}
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
return filepath.Join(bucket, id+extFor(mime, kind)), filepath.Join(bucket, id+".json"), nil
|
||||
}
|
||||
|
||||
// locate finds the stored bytes for an id whose extension we do not know,
|
||||
// because the extension came from the mime at Put time.
|
||||
func (s *Store) locate(kind Kind, id string) (string, error) {
|
||||
if !validID(id) {
|
||||
return "", ErrBadID
|
||||
}
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
entries, err := os.ReadDir(bucket)
|
||||
if err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, id) && !strings.HasSuffix(name, ".json") {
|
||||
return filepath.Join(bucket, name), nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// validID guards every path built from an id. Without it a caller-supplied id
|
||||
// is a path traversal: Get("../../etc/passwd") would read outside the store.
|
||||
func validID(id string) bool {
|
||||
if len(id) != 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extFor maps a mime to a file extension, defaulting per kind. The extension is
|
||||
// cosmetic — the id is the key — but it is what makes the store browsable and
|
||||
// lets a subprocess that sniffs by name (piper, some image tools) cope.
|
||||
func extFor(mime string, kind Kind) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mime)) {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "audio/wav", "audio/x-wav", "audio/wave":
|
||||
return ".wav"
|
||||
case "audio/l16", "audio/pcm":
|
||||
return ".pcm"
|
||||
}
|
||||
if kind == KindImage {
|
||||
return ".bin"
|
||||
}
|
||||
return ".pcm"
|
||||
}
|
||||
|
||||
// writeFile writes data 0600 via a temp file in the same directory, so a
|
||||
// crash mid-write cannot leave a truncated blob under a digest that claims
|
||||
// to describe the whole thing.
|
||||
func writeFile(path string, data []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: temp: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("media: chmod: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("media: write: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("media: close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("media: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeMeta(path string, b Blob) error {
|
||||
data, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: marshal meta: %w", err)
|
||||
}
|
||||
return writeFile(path, data)
|
||||
}
|
||||
|
||||
func readMeta(path string) (Blob, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
var b Blob
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if !validID(b.ID) || !b.Kind.Valid() {
|
||||
return Blob{}, errors.New("media: corrupt sidecar")
|
||||
}
|
||||
b.Created = b.Created.UTC()
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPutAndRead(t *testing.T) {
|
||||
s := testStore(t)
|
||||
b, err := s.Put(KindImage, "image/png", "web:upload", []byte("pretend png"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if len(b.ID) != 64 {
|
||||
t.Fatalf("id is not a sha256 hex digest: %q", b.ID)
|
||||
}
|
||||
if b.Size != int64(len("pretend png")) {
|
||||
t.Errorf("size = %d", b.Size)
|
||||
}
|
||||
if !strings.HasSuffix(b.Path, ".png") {
|
||||
t.Errorf("extension not taken from mime: %s", b.Path)
|
||||
}
|
||||
got, data, err := s.Read(b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(data) != "pretend png" {
|
||||
t.Errorf("data = %q", data)
|
||||
}
|
||||
if got.Source != "web:upload" || got.Kind != KindImage {
|
||||
t.Errorf("metadata not round-tripped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same bytes twice must be one file, and must NOT get a fresh creation
|
||||
// time — otherwise re-sending a photo keeps it alive past retention forever.
|
||||
func TestPutIsIdempotentAndKeepsFirstSeenTime(t *testing.T) {
|
||||
s := testStore(t)
|
||||
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
s.now = func() time.Time { return base }
|
||||
|
||||
first, err := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("pcm"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
s.now = func() time.Time { return base.Add(72 * time.Hour) }
|
||||
second, err := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("pcm"))
|
||||
if err != nil {
|
||||
t.Fatalf("re-put: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("same bytes produced two ids")
|
||||
}
|
||||
if !second.Created.Equal(base) {
|
||||
t.Errorf("re-put moved created time to %v, want %v", second.Created, base)
|
||||
}
|
||||
list, err := s.List(KindAudio)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("got %d blobs, want 1", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejects(t *testing.T) {
|
||||
s, err := Open(t.TempDir(), 8, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "x", nil); !errors.Is(err, ErrEmpty) {
|
||||
t.Errorf("empty payload: %v", err)
|
||||
}
|
||||
if _, err := s.Put("video", "video/mp4", "x", []byte("ab")); !errors.Is(err, ErrBadKind) {
|
||||
t.Errorf("bad kind: %v", err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "x", []byte("way too many bytes")); !errors.Is(err, ErrTooLarge) {
|
||||
t.Errorf("over cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller-supplied id becomes a path, so a traversal attempt must be refused
|
||||
// before it touches the filesystem rather than escaping the store root.
|
||||
func TestMalformedIDIsRefused(t *testing.T) {
|
||||
s := testStore(t)
|
||||
for _, id := range []string{"", "../../etc/passwd", strings.Repeat("z", 64), strings.Repeat("a", 63)} {
|
||||
if _, err := s.Get(id); !errors.Is(err, ErrBadID) && !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Get(%q) = %v, want a refusal", id, err)
|
||||
}
|
||||
if _, _, err := s.Read(id); err == nil {
|
||||
t.Errorf("Read(%q) succeeded", id)
|
||||
}
|
||||
if err := s.Delete(id); err == nil && id != "" {
|
||||
// Delete of a well-formed but absent id is fine; these are not
|
||||
// well-formed.
|
||||
t.Errorf("Delete(%q) succeeded", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMissingIsNotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if _, err := s.Get(strings.Repeat("a", 64)); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEnforcesRetention(t *testing.T) {
|
||||
s, err := Open(t.TempDir(), 0, 48*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
s.now = func() time.Time { return now.Add(-96 * time.Hour) }
|
||||
old, _ := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("old meeting"))
|
||||
s.now = func() time.Time { return now.Add(-1 * time.Hour) }
|
||||
fresh, _ := s.Put(KindImage, "image/png", "telegram", []byte("recent photo"))
|
||||
|
||||
s.now = func() time.Time { return now }
|
||||
n, err := s.Prune()
|
||||
if err != nil {
|
||||
t.Fatalf("prune: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("pruned %d, want 1", n)
|
||||
}
|
||||
if _, err := s.Get(old.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("stale blob survived prune: %v", err)
|
||||
}
|
||||
if _, err := s.Get(fresh.ID); err != nil {
|
||||
t.Errorf("fresh blob was pruned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIsNewestFirstAcrossKinds(t *testing.T) {
|
||||
s := testStore(t)
|
||||
base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
s.now = func() time.Time { return base }
|
||||
_, _ = s.Put(KindImage, "image/png", "telegram", []byte("one"))
|
||||
s.now = func() time.Time { return base.Add(time.Hour) }
|
||||
newest, _ := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("two"))
|
||||
|
||||
all, err := s.List("")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(all))
|
||||
}
|
||||
if all[0].ID != newest.ID {
|
||||
t.Errorf("list is not newest-first")
|
||||
}
|
||||
}
|
||||
|
||||
// Recordings of people are 0700/0600 and nothing else.
|
||||
func TestPermissionsAreOwnerOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(filepath.Join(dir, "blobs"), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("pcm"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
di, err := os.Stat(s.Dir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if di.Mode().Perm() != 0o700 {
|
||||
t.Errorf("store dir mode = %o, want 700", di.Mode().Perm())
|
||||
}
|
||||
fi, err := os.Stat(b.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("blob mode = %o, want 600", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesBytesAndSidecar(t *testing.T) {
|
||||
s := testStore(t)
|
||||
b, _ := s.Put(KindImage, "image/png", "telegram", []byte("bytes"))
|
||||
if err := s.Delete(b.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(b.Path); !os.IsNotExist(err) {
|
||||
t.Errorf("bytes survived delete")
|
||||
}
|
||||
if _, err := s.Get(b.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("sidecar survived delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsEmptyDir(t *testing.T) {
|
||||
if _, err := Open(" ", 0, 0); err == nil {
|
||||
t.Error("empty dir accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user