From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 14:35:12 +0000 (-0300) Subject: Store shared private images by content hash X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/36229ef68a30c5a117599037d79fa1fc708fbfef?ds=sidebyside Store shared private images by content hash Keep one immutable JPEG, PNG, or WebP file per image in the shared data store. Validate size and format before atomic publication, and preserve image files and modes in backups. The WebP config decoder needs x/image because the standard library has no WebP decoder. --- diff --git a/agent.go b/agent.go index dbcabd2..97d6685 100644 --- a/agent.go +++ b/agent.go @@ -80,6 +80,16 @@ type Agent struct { config AgentConfig } +// Images returns the shared data-directory image store, also for a child. +func (a *Agent) Images() *ImageStore { + root := a.dir + if a.config.Parent != "" { + root = filepath.Dir(filepath.Dir(root)) + } + + return &ImageStore{dir: filepath.Join(filepath.Dir(root), imageDirName)} +} + // CacheKey returns the prompt cache key of every model call of the agent. func (a *Agent) CacheKey() string { return "onechat-" + a.config.ID diff --git a/backup_archive.go b/backup_archive.go index 9560cd1..2ec9352 100644 --- a/backup_archive.go +++ b/backup_archive.go @@ -270,7 +270,7 @@ func backupExcluded(name string) bool { return true } - if filepath.Base(name) == "lock" { + if filepath.Base(name) == "lock" || (first == imageDirName && strings.HasPrefix(filepath.Base(name), ".image-")) { return true } diff --git a/go.mod b/go.mod index fcf4681..4643a53 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.5 require ( github.com/stretchr/testify v1.12.0 go.mau.fi/whatsmeow v0.0.0-20261107160454-79dc2a8c33df + golang.org/x/image v0.47.0 golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 modernc.org/sqlite v1.62.0 diff --git a/go.sum b/go.sum index 13d902b..16298eb 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= diff --git a/image.go b/image.go new file mode 100644 index 0000000..d6a0b20 --- /dev/null +++ b/image.go @@ -0,0 +1,174 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "os" + "path/filepath" + "strings" + + _ "golang.org/x/image/webp" +) + +const ( + imageDirName = "images" + imageMaxBytes = 20 << 20 +) + +// ImageStore holds immutable images shared by the root, children, and +// subagents. Names are the SHA-256 of the bytes with the decoded format as +// extension. Publication is atomic and does not replace an existing image. +type ImageStore struct { + dir string +} + +// Save validates and stores data, and returns its filename. Only JPEG, PNG, and +// WebP images up to 20 MB are accepted. +func (s *ImageStore) Save(data []byte) (name string, err error) { + format, err := imageFormat(data) + if err != nil { + return "", fmt.Errorf("check image: %w", err) + } + + sum := sha256.Sum256(data) + name = hex.EncodeToString(sum[:]) + "." + format + err = os.MkdirAll(s.dir, 0o700) + if err != nil { + return "", fmt.Errorf("create image directory: %w", err) + } + + file, err := os.CreateTemp(s.dir, ".image-*") + if err != nil { + return "", fmt.Errorf("create image file: %w", err) + } + + defer func() { + err = errors.Join(err, os.Remove(file.Name())) + }() + + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + + err = errors.Join(err, file.Close()) + if err != nil { + return "", fmt.Errorf("write image: %w", err) + } + + err = os.Link(file.Name(), filepath.Join(s.dir, name)) + if err != nil && !errors.Is(err, os.ErrExist) { + return "", fmt.Errorf("publish image: %w", err) + } + + return name, nil +} + +// Read returns the bytes of a stored image after checking its name and content. +func (s *ImageStore) Read(name string) ([]byte, error) { + if !imageName(name) { + return nil, errors.New("invalid image name") + } + + data, err := imageReadFile(filepath.Join(s.dir, name)) + if err != nil { + return nil, fmt.Errorf("read stored image: %w", err) + } + + format, err := imageFormat(data) + if err != nil { + return nil, fmt.Errorf("check stored image: %w", err) + } + + sum := sha256.Sum256(data) + if name != hex.EncodeToString(sum[:])+"."+format { + return nil, errors.New("image content does not match its name") + } + + return data, nil +} + +func imageFormat(data []byte) (string, error) { + if len(data) > imageMaxBytes { + return "", errors.New("image exceeds 20 MB") + } + + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || config.Width <= 0 || config.Height <= 0 { + return "", errors.New("use a JPEG, PNG, or WebP image") + } + + switch format { + case "jpeg": + return "jpg", nil + case "png", "webp": + return format, nil + default: + return "", errors.New("use a JPEG, PNG, or WebP image") + } +} + +func imageName(name string) bool { + hash, ext, found := strings.Cut(name, ".") + return found && len(hash) == 64 && strings.Trim(hash, "0123456789abcdef") == "" && (ext == "jpg" || ext == "png" || ext == "webp") +} + +func imageReadFile(path string) (data []byte, err error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open image: %w", err) + } + + defer func() { + err = errors.Join(err, file.Close()) + }() + + data, err = io.ReadAll(io.LimitReader(file, imageMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read image: %w", err) + } + + if len(data) > imageMaxBytes { + return nil, errors.New("image exceeds 20 MB") + } + + return data, nil +} + +// imageFiles reads only complete marker lines with valid store filenames. +func imageFiles(text string) []string { + var names []string + + for line := range strings.SplitSeq(text, "\n") { + name, found := strings.CutPrefix(line, "[image ") + if !found || !strings.HasSuffix(name, "]") { + continue + } + + name = strings.TrimSuffix(name, "]") + if imageName(name) { + names = append(names, name) + } + } + + return names +} + +func imageText(names []string, caption string) string { + var text strings.Builder + + for _, name := range names { + text.WriteString("[image " + name + "]\n") + } + + text.WriteString(caption) + + return strings.TrimSuffix(text.String(), "\n") +} diff --git a/image_helper_test.go b/image_helper_test.go new file mode 100644 index 0000000..94788a2 --- /dev/null +++ b/image_helper_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" + + "github.com/stretchr/testify/require" +) + +func imageTestPNG(t *testing.T, width, height int) []byte { + t.Helper() + + picture := image.NewRGBA(image.Rect(0, 0, width, height)) + picture.Set(0, 0, color.RGBA{ + R: 255, + A: 255, + }) + + var data bytes.Buffer + + require.NoError(t, png.Encode(&data, picture)) + + return data.Bytes() +} diff --git a/image_test.go b/image_test.go new file mode 100644 index 0000000..24007d7 --- /dev/null +++ b/image_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestImagesArePrivateSharedAndWrittenOnce(t *testing.T) { + root, err := openAgent(filepath.Join(t.TempDir(), "agent")) + require.NoError(t, err) + + child := &Agent{ + dir: filepath.Join(root.dir, "children", "House"), + config: AgentConfig{Parent: root.config.Name}, + } + require.Equal(t, root.Images().dir, child.Images().dir) + + data := imageTestPNG(t, 3, 2) + sum := sha256.Sum256(data) + expected := hex.EncodeToString(sum[:]) + ".png" + store := root.Images() + name, err := store.Save(data) + require.NoError(t, err) + require.Equal(t, expected, name) + + info, err := os.Stat(filepath.Join(store.dir, name)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + var workers sync.WaitGroup + + for range 8 { + workers.Go(func() { + saved, saveErr := child.Images().Save(data) + require.NoError(t, saveErr) + require.Equal(t, name, saved) + }) + } + + workers.Wait() + + again, err := os.Stat(filepath.Join(store.dir, name)) + require.NoError(t, err) + require.True(t, os.SameFile(info, again)) + + files, err := os.ReadDir(store.dir) + require.NoError(t, err) + require.Len(t, files, 1) + + actual, err := store.Read(name) + require.NoError(t, err) + require.Equal(t, data, actual) +} + +func TestImageStoreRejectsLargeUnsupportedAndUnsafeImages(t *testing.T) { + store := &ImageStore{dir: filepath.Join(t.TempDir(), imageDirName)} + _, err := store.Save(bytes.Repeat([]byte{0}, imageMaxBytes+1)) + require.ErrorContains(t, err, "exceeds 20 MB") + + _, err = store.Save([]byte("not an image")) + require.ErrorContains(t, err, "JPEG, PNG, or WebP") + + _, err = store.Read("../secret.png") + require.ErrorContains(t, err, "invalid image name") + + _, err = os.Stat(store.dir) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestBackupKeepsImagesAndTheirModes(t *testing.T) { + dir := t.TempDir() + store := &ImageStore{dir: filepath.Join(dir, imageDirName)} + data := imageTestPNG(t, 2, 2) + name, err := store.Save(data) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(store.dir, ".image-incomplete"), []byte("partial"), 0o600)) + + stage := t.TempDir() + archive := filepath.Join(t.TempDir(), "images.tar.gz") + require.NoError(t, backupCopy(t.Context(), dir, stage, archive)) + + _, err = backupArchive(t.Context(), stage, archive) + require.NoError(t, err) + + restored := t.TempDir() + require.NoError(t, backupRestore(t.Context(), archive, restored)) + + actual, err := (&ImageStore{dir: filepath.Join(restored, imageDirName)}).Read(name) + require.NoError(t, err) + require.Equal(t, data, actual) + + info, err := os.Stat(filepath.Join(restored, imageDirName, name)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + _, err = os.Stat(filepath.Join(restored, imageDirName, ".image-incomplete")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestImageMarkersKeepCaptionAndOnlySafeNames(t *testing.T) { + name := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png" + text := imageText([]string{name}, "Read the label.") + require.Equal(t, "[image "+name+"]\nRead the label.", text) + require.Equal(t, []string{name}, imageFiles(text+"\n[image ../secret.png]\ntext [image "+name+"]")) +} diff --git a/nix/package.nix b/nix/package.nix index bbd5fb6..41cd24f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -10,7 +10,7 @@ buildGoModule { subPackages = [ "." ]; ldflags = [ "-X main.version=${version}" ]; - vendorHash = "sha256-HEYbx27w3s+x29/gDLUOCrkBJu9Ke1ue+Ms++LdHpyQ="; + vendorHash = "sha256-nRDUAGSXAK5/L2xyfDh3NbMOZmrGGEIm4vtgfWsxHYU="; # The flake check runs the full replay. checkFlags = [ "-short" ];