返回 last30days-skill
extract.go
根目录 / mcp / internal / engine / extract.go
1 package engine
2
3 import (
4 "errors"
5 "fmt"
6 "io"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "sync"
11 )
12
13 // SentinelFilename names the file Ensure writes inside the cache directory
14 // after a successful extraction. Its contents are compared to the requested
15 // version; a match short-circuits re-extraction on subsequent calls.
16 const SentinelFilename = ".version"
17
18 // cacheSubdir namespaces our cache under the OS user cache directory so
19 // multiple printing-press-style bundles can coexist.
20 const cacheSubdir = "last30days-pp-mcp"
21
22 // CacheEnvOverride lets users redirect the cache directory when the default
23 // OS cache location is read-only (locked-down corp images, ephemeral CI
24 // containers). Pointed at by extract errors via the documented escape hatch.
25 const CacheEnvOverride = "LAST30DAYS_CACHE_DIR"
26
27 // Ensure extracts src into baseDir/last30days-pp-mcp/<version> and returns
28 // the cache path. If the sentinel file already records the same version the
29 // directory is reused without rewriting. version must be non-empty so the
30 // cache layout always namespaces by version.
31 //
32 // Extraction writes to a sibling .tmp directory and renames it on success
33 // so a partial extraction can never be mistaken for a complete one. Concurrent
34 // callers within the same process serialize behind a per-cache-dir sync.Once
35 // so the rename happens exactly once.
36 func Ensure(src fs.FS, baseDir, version string) (string, error) {
37 if version == "" {
38 return "", errors.New("engine: version is required")
39 }
40 cacheDir := filepath.Join(baseDir, cacheSubdir, version)
41
42 once := getOnce(cacheDir)
43 var extractErr error
44 once.Do(func() {
45 extractErr = ensureLocked(src, cacheDir, version)
46 })
47 if extractErr != nil {
48 // Reset the sync.Once so a follow-up call can retry rather than
49 // permanently caching the error. Retry is the right default when
50 // the failure is transient (e.g., disk full, parent dir restored).
51 resetOnce(cacheDir)
52 return "", extractErr
53 }
54 return cacheDir, nil
55 }
56
57 // EnsureUserCache wraps Ensure with the OS user cache dir (or the
58 // LAST30DAYS_CACHE_DIR override) as base. Production callers use this; tests
59 // use Ensure with an explicit temp dir.
60 func EnsureUserCache(src fs.FS, version string) (string, error) {
61 if override := os.Getenv(CacheEnvOverride); override != "" {
62 return Ensure(src, override, version)
63 }
64 base, err := os.UserCacheDir()
65 if err != nil {
66 return "", fmt.Errorf("engine: resolve user cache dir (set %s to override): %w", CacheEnvOverride, err)
67 }
68 return Ensure(src, base, version)
69 }
70
71 func ensureLocked(src fs.FS, cacheDir, version string) error {
72 if sentinelMatches(cacheDir, version) {
73 return nil
74 }
75 tmpDir := cacheDir + ".tmp"
76 if err := os.RemoveAll(tmpDir); err != nil {
77 return fmt.Errorf("engine: clean tmp cache: %w", err)
78 }
79 if err := os.MkdirAll(tmpDir, 0o755); err != nil {
80 return fmt.Errorf("engine: create tmp cache (%s, set %s to override): %w", tmpDir, CacheEnvOverride, err)
81 }
82 if err := extractAll(src, tmpDir); err != nil {
83 _ = os.RemoveAll(tmpDir)
84 return err
85 }
86 sentinel := filepath.Join(tmpDir, SentinelFilename)
87 if err := os.WriteFile(sentinel, []byte(version), 0o644); err != nil {
88 _ = os.RemoveAll(tmpDir)
89 return fmt.Errorf("engine: write sentinel: %w", err)
90 }
91 if err := os.RemoveAll(cacheDir); err != nil {
92 _ = os.RemoveAll(tmpDir)
93 return fmt.Errorf("engine: clean old cache: %w", err)
94 }
95 if err := os.Rename(tmpDir, cacheDir); err != nil {
96 _ = os.RemoveAll(tmpDir)
97 return fmt.Errorf("engine: promote tmp cache: %w", err)
98 }
99 return nil
100 }
101
102 func sentinelMatches(cacheDir, version string) bool {
103 data, err := os.ReadFile(filepath.Join(cacheDir, SentinelFilename))
104 if err != nil {
105 return false
106 }
107 return string(data) == version
108 }
109
110 func extractAll(src fs.FS, dst string) error {
111 return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
112 if err != nil {
113 return err
114 }
115 if path == "." {
116 return nil
117 }
118 target := filepath.Join(dst, path)
119 if d.IsDir() {
120 return os.MkdirAll(target, 0o755)
121 }
122 return copyEmbeddedFile(src, path, target)
123 })
124 }
125
126 func copyEmbeddedFile(src fs.FS, srcPath, dst string) error {
127 in, err := src.Open(srcPath)
128 if err != nil {
129 return fmt.Errorf("engine: open %s: %w", srcPath, err)
130 }
131 defer func() { _ = in.Close() }()
132 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
133 return fmt.Errorf("engine: ensure parent of %s: %w", dst, err)
134 }
135 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
136 if err != nil {
137 return fmt.Errorf("engine: create %s: %w", dst, err)
138 }
139 defer func() { _ = out.Close() }()
140 if _, err := io.Copy(out, in); err != nil {
141 return fmt.Errorf("engine: write %s: %w", dst, err)
142 }
143 return nil
144 }
145
146 // onceRegistry serializes first-call extraction per cache directory so the
147 // rename in ensureLocked happens exactly once across goroutines.
148 var (
149 onceMu sync.Mutex
150 onceRegistry = map[string]*sync.Once{}
151 )
152
153 func getOnce(cacheDir string) *sync.Once {
154 onceMu.Lock()
155 defer onceMu.Unlock()
156 if o, ok := onceRegistry[cacheDir]; ok {
157 return o
158 }
159 o := &sync.Once{}
160 onceRegistry[cacheDir] = o
161 return o
162 }
163
164 func resetOnce(cacheDir string) {
165 onceMu.Lock()
166 defer onceMu.Unlock()
167 delete(onceRegistry, cacheDir)
168 }
169
169 lines GO