Commit 3b2889781b

3b2889781b80ee8c080d98999b99303d50d8ea91

parent: 0ed36f2a40

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 18:57 UTC

Harden exiftool lifecycle and startup checks

- CloseExiftool is final: later requests fail instead of starting a
  process that nothing stops. Fatal exits close it first.
- Each exiftool request times out after 30s and kills the process.
- Pass absolute paths so exiftool's argfile parsing cannot alter them.
- checkDirs rejects cache dirs such as photos/..cache.
- Publish the empty library before the watcher starts.

Ref #3
Ref #4

Layout: unified · split

cmd/gallery/main.go +15 −6
@@ -40,7 +40,11 @@ func checkDirs(photos, cache string) error {
4040 if err != nil {
4141 return err
4242 }
43 if rel, err := filepath.Rel(p, c); err == nil && (rel == "." || !strings.HasPrefix(rel, "..")) {
43 rel, err := filepath.Rel(p, c)
44 if err != nil {
45 return err
46 }
47 if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
4448 return fmt.Errorf("cache %s must not be inside photos %s", cache, photos)
4549 }
4650 return nil
@@ -97,15 +101,20 @@ func main() {
97101 log.Printf("rescan: %v", err)
98102 }
99103 }
104 // Serve an empty library while the first scan runs.
105 store.Set(&library.Library{})
106 // fatal stops exiftool first: it ignores EOF and would outlive the process.
107 fatal := func(err error) {
108 format.CloseExiftool()
109 log.Fatal(err)
110 }
100111 // Watch before the first scan so files added during it are not missed.
101112 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
102113 log.Fatal(err)
103114 }
104 // Serve an empty library while the first scan runs.
105 store.Set(&library.Library{})
106115 go func() {
107116 if err := rescan(); err != nil {
108 log.Fatal(err)
117 fatal(err)
109118 }
110119 }()
111120
@@ -115,7 +124,7 @@ func main() {
115124 }
116125 h, err := web.New(store, rend, opt)
117126 if err != nil {
118 log.Fatal(err)
127 fatal(err)
119128 }
120129
121130 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
@@ -129,7 +138,7 @@ func main() {
129138 }()
130139 log.Printf("listening on %s", *addr)
131140 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
132 log.Fatal(err)
141 fatal(err)
133142 }
134143 <-done
135144 format.CloseExiftool()
cmd/gallery/main_test.go +1
@@ -17,6 +17,7 @@ func TestCheckDirs(t *testing.T) {
1717 {photos, false},
1818 {filepath.Join(photos, "cache"), false},
1919 {filepath.Join(photos, ".cache"), false},
20 {filepath.Join(photos, "..cache"), false},
2021 {filepath.Join(photos, "a", "..", "cache"), false},
2122 } {
2223 err := checkDirs(photos, tc.cache)
internal/format/exiftool.go +38 −9
@@ -3,20 +3,26 @@ package format
33import (
44 "bufio"
55 "bytes"
6 "errors"
67 "fmt"
78 "io"
89 "os/exec"
910 "strings"
1011 "sync"
1112 "sync/atomic"
13 "time"
1214)
1315
16const exiftoolTimeout = 30 * time.Second
17
1418// exiftool keeps one `exiftool -stay_open` process and sends it one request at a time.
1519type exiftool struct {
16 mu sync.Mutex
17 cmd *exec.Cmd
18 stdin io.WriteCloser
19 stdout *bufio.Reader
20 mu sync.Mutex
21 cmd *exec.Cmd
22 stdin io.WriteCloser
23 stdout *bufio.Reader
24 closed bool
25 timeout time.Duration // per request; exiftoolTimeout when zero
2026}
2127
2228var (
@@ -58,19 +64,22 @@ func (e *exiftool) stop(graceful bool) {
5864 e.cmd = nil
5965}
6066
67// close stops the process for good; later requests fail instead of restarting it.
6168func (e *exiftool) close() {
6269 e.mu.Lock()
6370 defer e.mu.Unlock()
71 e.closed = true
6472 e.stop(true)
6573}
6674
67// CloseExiftool stops the shared exiftool process, if one is running.
75// CloseExiftool stops the shared exiftool process. Call it before exiting.
6876func CloseExiftool() {
6977 sharedExiftool.close()
7078}
7179
7280// run sends one request, one argument per line, and returns its stdout.
73// The process is restarted on the next call if the pipe breaks.
81// A broken pipe or a request exceeding the timeout kills the process; the
82// next request starts a new one.
7483func (e *exiftool) run(args ...string) ([]byte, error) {
7584 for _, a := range args {
7685 if strings.ContainsAny(a, "\r\n") {
@@ -79,21 +88,41 @@ func (e *exiftool) run(args ...string) ([]byte, error) {
7988 }
8089 e.mu.Lock()
8190 defer e.mu.Unlock()
91 if e.closed {
92 return nil, errors.New("exiftool closed")
93 }
8294 if e.cmd == nil {
8395 if err := e.start(); err != nil {
8496 return nil, err
8597 }
8698 }
87 if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil {
99 timeout := e.timeout
100 if timeout == 0 {
101 timeout = exiftoolTimeout
102 }
103 proc := e.cmd.Process
104 var timedOut atomic.Bool
105 timer := time.AfterFunc(timeout, func() {
106 timedOut.Store(true)
107 proc.Kill()
108 })
109 defer timer.Stop()
110
111 fail := func(err error) ([]byte, error) {
88112 e.stop(false)
113 if timedOut.Load() {
114 return nil, fmt.Errorf("timed out after %s", timeout)
115 }
89116 return nil, err
90117 }
118 if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil {
119 return fail(err)
120 }
91121 var out []byte
92122 for {
93123 line, err := e.stdout.ReadBytes('\n')
94124 if err != nil {
95 e.stop(false)
96 return nil, err
125 return fail(err)
97126 }
98127 if string(bytes.TrimRight(line, "\r\n")) == "{ready}" {
99128 return out, nil
internal/format/exiftool_test.go +26 −2
@@ -5,6 +5,7 @@ import (
55 "os"
66 "syscall"
77 "testing"
8 "time"
89)
910
1011func TestMain(m *testing.M) {
@@ -27,8 +28,31 @@ func TestExiftoolCloseEndsProcess(t *testing.T) {
2728 if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
2829 t.Fatalf("exiftool pid %d still running after close (kill 0: %v)", pid, err)
2930 }
30 if _, err := e.run("-ver"); err != nil {
31 t.Fatalf("run after close should restart: %v", err)
31}
32
33func TestExiftoolRunAfterCloseFails(t *testing.T) {
34 requireTools(t)
35 var e exiftool
36 e.close()
37 before := exiftoolStarts.Load()
38 if _, err := e.run("-ver"); err == nil {
39 t.Fatal("run after close: want error")
40 }
41 if exiftoolStarts.Load() != before {
42 t.Fatal("run after close started a process")
43 }
44}
45
46func TestExiftoolTimeoutKillsAndRecovers(t *testing.T) {
47 requireTools(t)
48 e := exiftool{timeout: time.Nanosecond}
49 if _, err := e.run("-ver"); err == nil {
50 t.Fatal("want timeout error")
51 }
52 e.timeout = 0
53 out, err := e.run("-ver")
54 if err != nil || len(out) == 0 {
55 t.Fatalf("run after timeout: %q, %v", out, err)
3256 }
3357 e.close()
3458}
internal/format/magick.go +6 −2
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "io"
77 "os/exec"
8 "path/filepath"
89 "strconv"
910 "strings"
1011)
@@ -24,8 +25,11 @@ func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
2425func (m *Magick) Kind() Kind { return KindImage }
2526
2627func (m *Magick) Metadata(path string) (Meta, error) {
27 if strings.HasPrefix(path, "-") {
28 path = "./" + path
28 // exiftool's argfile strips leading spaces and skips "#" lines, and a
29 // leading "-" would be read as an option; an absolute path avoids all three.
30 path, err := filepath.Abs(path)
31 if err != nil {
32 return Meta{}, err
2933 }
3034 out, err := sharedExiftool.run("-json", "-n",
3135 "-ImageWidth", "-ImageHeight", "-Orientation",
internal/format/magick_test.go +14
@@ -159,3 +159,17 @@ func TestResizeArgsLimitResources(t *testing.T) {
159159 t.Fatalf("got %s\nwant %s", got, want)
160160 }
161161}
162
163func TestMagickMetadataRelativePathArgfileSafe(t *testing.T) {
164 requireTools(t)
165 dir := t.TempDir()
166 run(t, "magick", "-size", "30x20", "xc:gray", filepath.Join(dir, "#a.jpg"))
167 t.Chdir(dir)
168 m, err := NewMagick("jpeg", ".jpg").Metadata("#a.jpg")
169 if err != nil {
170 t.Fatal(err)
171 }
172 if m.Width != 30 || m.Height != 20 {
173 t.Fatalf("got %dx%d", m.Width, m.Height)
174 }
175}
internal/render/render.go +3 −1
@@ -86,7 +86,9 @@ func (r *Renderer) generate(it *library.Item, w int, dst string) error {
8686}
8787
8888// pruneGrace keeps stale directories modified this recently, since a request
89// holding an older library snapshot may still be writing into them.
89// holding an older library snapshot may still be writing into them. This is
90// best-effort: a directory's mtime changes when entries are created or
91// renamed, not while a file is written, so a narrow window remains.
9092const pruneGrace = 5 * time.Minute
9193
9294// Prune removes derivative directories for items no longer in lib or whose source changed.