返回 DeepSeek-Reasonix
theme_import.go
根目录 / desktop / theme_import.go
1 package main
2
3 import (
4 "archive/zip"
5 "bytes"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/fileutil"
14 )
15
16 func themeReplaceFile(tmp, dest string) error {
17 return fileutil.ReplaceFile(tmp, dest)
18 }
19
20 // importThemePackZIP validates and extracts a .reasonix-theme ZIP into a staging dir.
21 // The caller must publish with publishThemeDir.
22 func importThemePackZIP(zipPath string) (manifest *ThemePackManifest, staging string, err error) {
23 info, err := os.Lstat(zipPath)
24 if err != nil {
25 return nil, "", err
26 }
27 if info.Mode()&os.ModeSymlink != 0 {
28 return nil, "", fmt.Errorf("theme package must not be a symlink")
29 }
30 if !info.Mode().IsRegular() {
31 return nil, "", fmt.Errorf("theme package must be a regular file")
32 }
33 if info.Size() > themePackMaxZipBytes {
34 return nil, "", fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
35 }
36
37 f, err := os.Open(zipPath)
38 if err != nil {
39 return nil, "", err
40 }
41 defer f.Close()
42
43 // Re-check size after open (TOCTOU).
44 fi, err := f.Stat()
45 if err != nil {
46 return nil, "", err
47 }
48 if fi.Size() > themePackMaxZipBytes {
49 return nil, "", fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
50 }
51
52 zr, err := zip.NewReader(f, fi.Size())
53 if err != nil {
54 return nil, "", fmt.Errorf("invalid theme ZIP: %w", err)
55 }
56 return extractThemeZip(zr)
57 }
58
59 func extractThemeZip(zr *zip.Reader) (*ThemePackManifest, string, error) {
60 manifestEntry, imageEntries, err := scanThemeZipEntries(zr)
61 if err != nil {
62 return nil, "", err
63 }
64
65 raw, err := readZipFileLimited(manifestEntry, themePackMaxManifest)
66 if err != nil {
67 return nil, "", err
68 }
69 m, err := parseThemePackManifest(raw)
70 if err != nil {
71 return nil, "", err
72 }
73 if isReservedThemeID(m.ID) {
74 return nil, "", fmt.Errorf("cannot import over built-in theme id %q", m.ID)
75 }
76 if err := checkThemeZipImages(m, imageEntries); err != nil {
77 return nil, "", err
78 }
79 expectedImages := themeZipExpectedImages(m)
80
81 staging, err := os.MkdirTemp("", "reasonix-theme-import-*")
82 if err != nil {
83 return nil, "", err
84 }
85 cleanup := true
86 defer func() {
87 if cleanup {
88 _ = os.RemoveAll(staging)
89 }
90 }()
91
92 // Write manifest as canonical JSON from validated struct.
93 canonical, err := json.MarshalIndent(m, "", " ")
94 if err != nil {
95 return nil, "", err
96 }
97 if err := os.WriteFile(filepath.Join(staging, themePackManifestName), canonical, 0o644); err != nil {
98 return nil, "", err
99 }
100
101 for key, manifestName := range expectedImages {
102 imgData, err := readZipFileLimited(imageEntries[key], themePackMaxImageBytes)
103 if err != nil {
104 return nil, "", err
105 }
106 imgPath := filepath.Join(staging, manifestName)
107 if err := os.WriteFile(imgPath, imgData, 0o644); err != nil {
108 return nil, "", err
109 }
110 if err := validateThemeImageFile(imgPath); err != nil {
111 return nil, "", err
112 }
113 }
114
115 cleanup = false
116 return m, staging, nil
117 }
118
119 // scanThemeZipEntries validates the container shape every .reasonix-theme ZIP
120 // must satisfy — root-level files only, no directories, no symlinks, no
121 // case-insensitive duplicate names, bounded entry sizes — and returns the
122 // manifest entry plus the scene images keyed by lowercase name. Import and
123 // plugin theme loading share it so the container contract cannot drift.
124 func scanThemeZipEntries(zr *zip.Reader) (manifestEntry *zip.File, imageEntries map[string]*zip.File, err error) {
125 seen := map[string]struct{}{}
126 imageEntries = map[string]*zip.File{}
127
128 for _, zf := range zr.File {
129 name := sanitizeZipEntryName(zf.Name)
130 if name == "" {
131 return nil, nil, fmt.Errorf("theme package contains an empty or unsafe path")
132 }
133 if strings.Contains(name, "/") || strings.Contains(name, `\`) {
134 return nil, nil, fmt.Errorf("theme package may only contain root-level files (got %q)", zf.Name)
135 }
136 if _, dup := seen[strings.ToLower(name)]; dup {
137 return nil, nil, fmt.Errorf("theme package contains duplicate entry %q", name)
138 }
139 seen[strings.ToLower(name)] = struct{}{}
140
141 if zf.FileInfo().IsDir() {
142 return nil, nil, fmt.Errorf("theme package must not contain directories")
143 }
144 // Detect symlink-like mode bits when present.
145 if zf.Mode()&os.ModeSymlink != 0 {
146 return nil, nil, fmt.Errorf("theme package must not contain symlinks")
147 }
148 if zf.UncompressedSize64 > themePackMaxImageBytes && !strings.EqualFold(name, themePackManifestName) {
149 return nil, nil, fmt.Errorf("theme package entry %q is too large", name)
150 }
151 if zf.UncompressedSize64 > themePackMaxManifest && strings.EqualFold(name, themePackManifestName) {
152 return nil, nil, fmt.Errorf("theme manifest is too large")
153 }
154
155 if strings.EqualFold(name, themePackManifestName) {
156 manifestEntry = zf
157 continue
158 }
159 if themePackImageRe.MatchString(name) {
160 if len(imageEntries) >= 2 {
161 return nil, nil, fmt.Errorf("theme package may contain at most two scene images")
162 }
163 imageEntries[strings.ToLower(name)] = zf
164 continue
165 }
166 return nil, nil, fmt.Errorf("theme package contains disallowed file %q", name)
167 }
168
169 if manifestEntry == nil {
170 return nil, nil, fmt.Errorf("theme package missing %s", themePackManifestName)
171 }
172 return manifestEntry, imageEntries, nil
173 }
174
175 // themeZipExpectedImages maps the manifest-declared scene images (lowercase
176 // name -> manifest name) that the ZIP must carry.
177 func themeZipExpectedImages(m *ThemePackManifest) map[string]string {
178 expectedImages := map[string]string{}
179 if m.Background != nil && m.Background.Image != "" {
180 expectedImages[strings.ToLower(m.Background.Image)] = m.Background.Image
181 }
182 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
183 expectedImages[strings.ToLower(m.TaskBackground.Image)] = m.TaskBackground.Image
184 }
185 return expectedImages
186 }
187
188 // checkThemeZipImages verifies the manifest references exactly the images the
189 // ZIP carries — no missing, no extras.
190 func checkThemeZipImages(m *ThemePackManifest, imageEntries map[string]*zip.File) error {
191 expectedImages := themeZipExpectedImages(m)
192 for key, manifestName := range expectedImages {
193 if imageEntries[key] == nil {
194 return fmt.Errorf("manifest references scene image %q but ZIP has none", manifestName)
195 }
196 }
197 for key := range imageEntries {
198 if _, ok := expectedImages[key]; !ok {
199 return fmt.Errorf("ZIP contains scene image not referenced by manifest")
200 }
201 }
202 return nil
203 }
204
205 func sanitizeZipEntryName(name string) string {
206 name = strings.TrimSpace(name)
207 name = strings.ReplaceAll(name, `\`, "/")
208 // Drop absolute and parent paths (ZIP slip).
209 name = strings.TrimPrefix(name, "/")
210 for strings.HasPrefix(name, "../") || name == ".." {
211 return ""
212 }
213 if strings.Contains(name, "/../") || strings.HasSuffix(name, "/..") {
214 return ""
215 }
216 // Only basename — reject nested paths elsewhere.
217 if i := strings.LastIndex(name, "/"); i >= 0 {
218 // Nested path — return as-is so caller rejects.
219 return name
220 }
221 if name == "." || name == ".." {
222 return ""
223 }
224 return name
225 }
226
227 func readZipFileLimited(zf *zip.File, max int64) ([]byte, error) {
228 rc, err := zf.Open()
229 if err != nil {
230 return nil, err
231 }
232 defer rc.Close()
233 data, err := io.ReadAll(io.LimitReader(rc, max+1))
234 if err != nil {
235 return nil, err
236 }
237 if int64(len(data)) > max {
238 return nil, fmt.Errorf("ZIP entry %q exceeds size limit", zf.Name)
239 }
240 return data, nil
241 }
242
243 // exportThemePackZIP writes a validated user theme to a ZIP path.
244 // Reserved ids (base styles + official themes) are refused: an exported pack
245 // could never be re-imported because the id is reserved. Duplicate first.
246 func exportThemePackZIP(id, destPath string) error {
247 id = strings.TrimSpace(id)
248 if isReservedThemeID(id) {
249 return fmt.Errorf("built-in themes cannot be exported; create a copy first")
250 }
251 m, err := loadUserThemeManifest(id)
252 if err != nil {
253 return err
254 }
255 var img []byte
256 var taskImg []byte
257 if m.Background != nil && m.Background.Image != "" {
258 imgPath, err := resolveThemeImageAbs(id, m.Background.Image)
259 if err != nil {
260 return err
261 }
262 img, err = os.ReadFile(imgPath)
263 if err != nil {
264 return err
265 }
266 }
267 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
268 imgPath, err := resolveThemeImageAbs(id, m.TaskBackground.Image)
269 if err != nil {
270 return err
271 }
272 taskImg, err = os.ReadFile(imgPath)
273 if err != nil {
274 return err
275 }
276 }
277 return writeThemeZip(destPath, m, img, taskImg)
278 }
279
280 func findBuiltinManifest(id string) *ThemePackManifest {
281 for _, m := range builtinThemePacks() {
282 if m.ID == id {
283 cp := m
284 return &cp
285 }
286 }
287 return nil
288 }
289
290 func writeThemeZip(destPath string, m *ThemePackManifest, imageBytes []byte, taskImageBytes ...[]byte) error {
291 if err := validateThemePackManifest(m); err != nil {
292 return err
293 }
294 destPath = filepath.Clean(destPath)
295 if !strings.HasSuffix(strings.ToLower(destPath), themePackExt) {
296 destPath += themePackExt
297 }
298 dir := filepath.Dir(destPath)
299 if err := os.MkdirAll(dir, 0o755); err != nil {
300 return err
301 }
302 tmp, err := os.CreateTemp(dir, ".export-theme-*.zip")
303 if err != nil {
304 return err
305 }
306 tmpName := tmp.Name()
307 cleanup := true
308 defer func() {
309 _ = tmp.Close()
310 if cleanup {
311 _ = os.Remove(tmpName)
312 }
313 }()
314
315 zw := zip.NewWriter(tmp)
316 manifestBytes, err := json.MarshalIndent(m, "", " ")
317 if err != nil {
318 return err
319 }
320 w, err := zw.Create(themePackManifestName)
321 if err != nil {
322 return err
323 }
324 if _, err := w.Write(manifestBytes); err != nil {
325 return err
326 }
327 if m.Background != nil && m.Background.Image != "" {
328 if len(imageBytes) == 0 {
329 return fmt.Errorf("missing background image bytes")
330 }
331 iw, err := zw.Create(m.Background.Image)
332 if err != nil {
333 return err
334 }
335 if _, err := iw.Write(imageBytes); err != nil {
336 return err
337 }
338 }
339 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
340 var data []byte
341 if len(taskImageBytes) > 0 {
342 data = taskImageBytes[0]
343 }
344 if len(data) == 0 {
345 return fmt.Errorf("missing task background image bytes")
346 }
347 iw, err := zw.Create(m.TaskBackground.Image)
348 if err != nil {
349 return err
350 }
351 if _, err := iw.Write(data); err != nil {
352 return err
353 }
354 }
355 if err := zw.Close(); err != nil {
356 return err
357 }
358 if err := tmp.Sync(); err != nil {
359 return err
360 }
361 if err := tmp.Close(); err != nil {
362 return err
363 }
364 // ReplaceFile retries Windows AV/indexer locks and falls back on EXDEV.
365 if err := themeReplaceFile(tmpName, destPath); err != nil {
366 return err
367 }
368 cleanup = false
369 return nil
370 }
371
372 // extractThemeZipBytes is a test helper for in-memory ZIP fixtures.
373 func extractThemeZipBytes(data []byte) (*ThemePackManifest, string, error) {
374 if int64(len(data)) > themePackMaxZipBytes {
375 return nil, "", fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
376 }
377 zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
378 if err != nil {
379 return nil, "", err
380 }
381 return extractThemeZip(zr)
382 }
383
383 lines GO