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 {
40 if err != nil { 40 if err != nil {
41 return err 41 return err
42 } 42 }
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)) {
44 return fmt.Errorf("cache %s must not be inside photos %s", cache, photos) 48 return fmt.Errorf("cache %s must not be inside photos %s", cache, photos)
45 } 49 }
46 return nil 50 return nil
@@ -97,15 +101,20 @@ func main() {
97 log.Printf("rescan: %v", err) 101 log.Printf("rescan: %v", err)
98 } 102 }
99 } 103 }
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 }
100 // Watch before the first scan so files added during it are not missed. 111 // Watch before the first scan so files added during it are not missed.
101 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil { 112 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
102 log.Fatal(err) 113 log.Fatal(err)
103 } 114 }
104 // Serve an empty library while the first scan runs.
105 store.Set(&library.Library{})
106 go func() { 115 go func() {
107 if err := rescan(); err != nil { 116 if err := rescan(); err != nil {
108 log.Fatal(err) 117 fatal(err)
109 } 118 }
110 }() 119 }()
111 120
@@ -115,7 +124,7 @@ func main() {
115 } 124 }
116 h, err := web.New(store, rend, opt) 125 h, err := web.New(store, rend, opt)
117 if err != nil { 126 if err != nil {
118 log.Fatal(err) 127 fatal(err)
119 } 128 }
120 129
121 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} 130 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
@@ -129,7 +138,7 @@ func main() {
129 }() 138 }()
130 log.Printf("listening on %s", *addr) 139 log.Printf("listening on %s", *addr)
131 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 140 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
132 log.Fatal(err) 141 fatal(err)
133 } 142 }
134 <-done 143 <-done
135 format.CloseExiftool() 144 format.CloseExiftool()
cmd/gallery/main_test.go +1
@@ -17,6 +17,7 @@ func TestCheckDirs(t *testing.T) {
17 {photos, false}, 17 {photos, false},
18 {filepath.Join(photos, "cache"), false}, 18 {filepath.Join(photos, "cache"), false},
19 {filepath.Join(photos, ".cache"), false}, 19 {filepath.Join(photos, ".cache"), false},
20 {filepath.Join(photos, "..cache"), false},
20 {filepath.Join(photos, "a", "..", "cache"), false}, 21 {filepath.Join(photos, "a", "..", "cache"), false},
21 } { 22 } {
22 err := checkDirs(photos, tc.cache) 23 err := checkDirs(photos, tc.cache)
internal/format/exiftool.go +38 −9
@@ -3,20 +3,26 @@ package format
3import ( 3import (
4 "bufio" 4 "bufio"
5 "bytes" 5 "bytes"
6 "errors"
6 "fmt" 7 "fmt"
7 "io" 8 "io"
8 "os/exec" 9 "os/exec"
9 "strings" 10 "strings"
10 "sync" 11 "sync"
11 "sync/atomic" 12 "sync/atomic"
13 "time"
12) 14)
13 15
16const exiftoolTimeout = 30 * time.Second
17
14// exiftool keeps one `exiftool -stay_open` process and sends it one request at a time. 18// exiftool keeps one `exiftool -stay_open` process and sends it one request at a time.
15type exiftool struct { 19type exiftool struct {
16 mu sync.Mutex 20 mu sync.Mutex
17 cmd *exec.Cmd 21 cmd *exec.Cmd
18 stdin io.WriteCloser 22 stdin io.WriteCloser
19 stdout *bufio.Reader 23 stdout *bufio.Reader
24 closed bool
25 timeout time.Duration // per request; exiftoolTimeout when zero
20} 26}
21 27
22var ( 28var (
@@ -58,19 +64,22 @@ func (e *exiftool) stop(graceful bool) {
58 e.cmd = nil 64 e.cmd = nil
59} 65}
60 66
67// close stops the process for good; later requests fail instead of restarting it.
61func (e *exiftool) close() { 68func (e *exiftool) close() {
62 e.mu.Lock() 69 e.mu.Lock()
63 defer e.mu.Unlock() 70 defer e.mu.Unlock()
71 e.closed = true
64 e.stop(true) 72 e.stop(true)
65} 73}
66 74
67// CloseExiftool stops the shared exiftool process, if one is running. 75// CloseExiftool stops the shared exiftool process. Call it before exiting.
68func CloseExiftool() { 76func CloseExiftool() {
69 sharedExiftool.close() 77 sharedExiftool.close()
70} 78}
71 79
72// run sends one request, one argument per line, and returns its stdout. 80// 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.
74func (e *exiftool) run(args ...string) ([]byte, error) { 83func (e *exiftool) run(args ...string) ([]byte, error) {
75 for _, a := range args { 84 for _, a := range args {
76 if strings.ContainsAny(a, "\r\n") { 85 if strings.ContainsAny(a, "\r\n") {
@@ -79,21 +88,41 @@ func (e *exiftool) run(args ...string) ([]byte, error) {
79 } 88 }
80 e.mu.Lock() 89 e.mu.Lock()
81 defer e.mu.Unlock() 90 defer e.mu.Unlock()
91 if e.closed {
92 return nil, errors.New("exiftool closed")
93 }
82 if e.cmd == nil { 94 if e.cmd == nil {
83 if err := e.start(); err != nil { 95 if err := e.start(); err != nil {
84 return nil, err 96 return nil, err
85 } 97 }
86 } 98 }
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) {
88 e.stop(false) 112 e.stop(false)
113 if timedOut.Load() {
114 return nil, fmt.Errorf("timed out after %s", timeout)
115 }
89 return nil, err 116 return nil, err
90 } 117 }
118 if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil {
119 return fail(err)
120 }
91 var out []byte 121 var out []byte
92 for { 122 for {
93 line, err := e.stdout.ReadBytes('\n') 123 line, err := e.stdout.ReadBytes('\n')
94 if err != nil { 124 if err != nil {
95 e.stop(false) 125 return fail(err)
96 return nil, err
97 } 126 }
98 if string(bytes.TrimRight(line, "\r\n")) == "{ready}" { 127 if string(bytes.TrimRight(line, "\r\n")) == "{ready}" {
99 return out, nil 128 return out, nil
internal/format/exiftool_test.go +26 −2
@@ -5,6 +5,7 @@ import (
5 "os" 5 "os"
6 "syscall" 6 "syscall"
7 "testing" 7 "testing"
8 "time"
8) 9)
9 10
10func TestMain(m *testing.M) { 11func TestMain(m *testing.M) {
@@ -27,8 +28,31 @@ func TestExiftoolCloseEndsProcess(t *testing.T) {
27 if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) { 28 if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
28 t.Fatalf("exiftool pid %d still running after close (kill 0: %v)", pid, err) 29 t.Fatalf("exiftool pid %d still running after close (kill 0: %v)", pid, err)
29 } 30 }
30 if _, err := e.run("-ver"); err != nil { 31}
31 t.Fatalf("run after close should restart: %v", err) 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)
32 } 56 }
33 e.close() 57 e.close()
34} 58}
internal/format/magick.go +6 −2
@@ -5,6 +5,7 @@ import (
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "os/exec" 7 "os/exec"
8 "path/filepath"
8 "strconv" 9 "strconv"
9 "strings" 10 "strings"
10) 11)
@@ -24,8 +25,11 @@ func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
24func (m *Magick) Kind() Kind { return KindImage } 25func (m *Magick) Kind() Kind { return KindImage }
25 26
26func (m *Magick) Metadata(path string) (Meta, error) { 27func (m *Magick) Metadata(path string) (Meta, error) {
27 if strings.HasPrefix(path, "-") { 28 // exiftool's argfile strips leading spaces and skips "#" lines, and a
28 path = "./" + path 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
29 } 33 }
30 out, err := sharedExiftool.run("-json", "-n", 34 out, err := sharedExiftool.run("-json", "-n",
31 "-ImageWidth", "-ImageHeight", "-Orientation", 35 "-ImageWidth", "-ImageHeight", "-Orientation",
internal/format/magick_test.go +14
@@ -159,3 +159,17 @@ func TestResizeArgsLimitResources(t *testing.T) {
159 t.Fatalf("got %s\nwant %s", got, want) 159 t.Fatalf("got %s\nwant %s", got, want)
160 } 160 }
161} 161}
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 {
86} 86}
87 87
88// pruneGrace keeps stale directories modified this recently, since a request 88// 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.
90const pruneGrace = 5 * time.Minute 92const pruneGrace = 5 * time.Minute
91 93
92// Prune removes derivative directories for items no longer in lib or whose source changed. 94// Prune removes derivative directories for items no longer in lib or whose source changed.