1package modcache
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "log"
10 "math/rand/v2"
11 "os"
12 "path/filepath"
13 "slices"
14 "strconv"
15 "strings"
16
17 "cuelang.org/go/internal/par"
18 "cuelang.org/go/internal/robustio"
19 "cuelang.org/go/mod/modfile"
20 "cuelang.org/go/mod/modregistry"
21 "cuelang.org/go/mod/module"
22 "cuelang.org/go/mod/modzip"
23)
24
25const logging = false // TODO hook this up to CUE_DEBUG
26
27// New returns r wrapped inside a caching layer that
28// stores persistent cached content inside the given
29// OS directory, typically ${CUE_CACHE_DIR}.
30//
31// The `module.SourceLoc.FS` fields in the locations
32// returned by the registry implement the `OSRootFS` interface,
33// allowing a caller to find the native OS filepath where modules
34// are stored.
35//
36// The returned type implements [modconfig.Registry]
37// and [modconfig.CachedRegistry].
38func New(registry *modregistry.Client, dir string) (*Cache, error) {
39 info, err := os.Stat(dir)
40 if err == nil && !info.IsDir() {
41 return nil, fmt.Errorf("%q is not a directory", dir)
42 }
43 return &Cache{
44 dir: filepath.Join(dir, "mod"),
45 reg: registry,
46 }, nil
47}
48
49type Cache struct {
50 dir string // typically ${CUE_CACHE_DIR}/mod
51 reg *modregistry.Client
52 downloadZipCache par.ErrCache[module.Version, string]
53 modFileCache par.ErrCache[string, []byte]
54}
55
56func (c *Cache) Requirements(ctx context.Context, mv module.Version) ([]module.Version, error) {
57 data, err := c.downloadModFile(ctx, mv)
58 if err != nil {
59 return nil, err
60 }
61 mf, err := modfile.Parse(data, mv.String())
62 if err != nil {
63 return nil, fmt.Errorf("cannot parse module file from %v: %v", mv, err)
64 }
65 return mf.DepVersions(), nil
66}
67
68// FetchFromCache implements [cuelang.org/go/mod/modconfig.CachedRegistry].
69func (c *Cache) FetchFromCache(mv module.Version) (module.SourceLoc, error) {
70 dir, err := c.downloadDir(mv)
71 if err != nil {
72 if errors.Is(err, fs.ErrNotExist) {
73 return module.SourceLoc{}, modregistry.ErrNotFound
74 }
75 return module.SourceLoc{}, err
76 }
77 return c.dirToLocation(dir), nil
78}
79
80// Fetch returns the location of the contents for the given module
81// version, downloading it if necessary.
82func (c *Cache) Fetch(ctx context.Context, mv module.Version) (module.SourceLoc, error) {
83 dir, err := c.downloadDir(mv)
84 if err == nil {
85 // The directory has already been completely extracted (no .partial file exists).
86 return c.dirToLocation(dir), nil
87 }
88 if dir == "" || !errors.Is(err, fs.ErrNotExist) {
89 return module.SourceLoc{}, err
90 }
91
92 // To avoid cluttering the cache with extraneous files,
93 // DownloadZip uses the same lockfile as Download.
94 // Invoke DownloadZip before locking the file.
95 zipfile, err := c.downloadZip(ctx, mv)
96 if err != nil {
97 return module.SourceLoc{}, err
98 }
99
100 unlock, err := c.lockVersion(mv)
101 if err != nil {
102 return module.SourceLoc{}, err
103 }
104 defer unlock()
105
106 // Check whether the directory was populated while we were waiting on the lock.
107 _, dirErr := c.downloadDir(mv)
108 if dirErr == nil {
109 return c.dirToLocation(dir), nil
110 }
111 _, dirExists := dirErr.(*downloadDirPartialError)
112
113 // Clean up any partially extracted directories (indicated by
114 // DownloadDirPartialError, usually because of a .partial file). This is only
115 // safe to do because the lock file ensures that their writers are no longer
116 // active.
117 parentDir := filepath.Dir(dir)
118 tmpPrefix := filepath.Base(dir) + ".tmp-"
119
120 entries, _ := os.ReadDir(parentDir)
121 for _, entry := range entries {
122 if strings.HasPrefix(entry.Name(), tmpPrefix) {
123 RemoveAll(filepath.Join(parentDir, entry.Name())) // best effort
124 }
125 }
126 if dirExists {
127 if err := RemoveAll(dir); err != nil {
128 return module.SourceLoc{}, err
129 }
130 }
131
132 partialPath, err := c.cachePath(mv, "partial")
133 if err != nil {
134 return module.SourceLoc{}, err
135 }
136
137 // Extract the module zip directory at its final location.
138 //
139 // To prevent other processes from reading the directory if we crash,
140 // create a .partial file before extracting the directory, and delete
141 // the .partial file afterward (all while holding the lock).
142 //
143 // A technique used previously was to extract to a temporary directory with a random name
144 // then rename it into place with os.Rename. On Windows, this can fail with
145 // ERROR_ACCESS_DENIED when another process (usually an anti-virus scanner)
146 // opened files in the temporary directory.
147 if err := os.MkdirAll(parentDir, 0777); err != nil {
148 return module.SourceLoc{}, err
149 }
150 if err := os.WriteFile(partialPath, nil, 0666); err != nil {
151 return module.SourceLoc{}, err
152 }
153 if err := modzip.Unzip(dir, mv, zipfile); err != nil {
154 if rmErr := RemoveAll(dir); rmErr == nil {
155 os.Remove(partialPath)
156 }
157 return module.SourceLoc{}, err
158 }
159 if err := os.Remove(partialPath); err != nil {
160 return module.SourceLoc{}, err
161 }
162 makeDirsReadOnly(dir)
163 return c.dirToLocation(dir), nil
164}
165
166// ModuleVersions implements [modload.Registry.ModuleVersions].
167func (c *Cache) ModuleVersions(ctx context.Context, mpath string) ([]string, error) {
168 // TODO should this do any kind of short-term caching?
169 return c.reg.ModuleVersions(ctx, mpath)
170}
171
172func (c *Cache) downloadZip(ctx context.Context, mv module.Version) (zipfile string, err error) {
173 return c.downloadZipCache.Do(mv, func() (string, error) {
174 zipfile, err := c.cachePath(mv, "zip")
175 if err != nil {
176 return "", err
177 }
178
179 // Return without locking if the zip file exists.
180 if _, err := os.Stat(zipfile); err == nil {
181 return zipfile, nil
182 }
183 logf("cue: downloading %s", mv)
184 unlock, err := c.lockVersion(mv)
185 if err != nil {
186 return "", err
187 }
188 defer unlock()
189
190 if err := c.downloadZip1(ctx, mv, zipfile); err != nil {
191 return "", err
192 }
193 return zipfile, nil
194 })
195}
196
197func (c *Cache) downloadZip1(ctx context.Context, mod module.Version, zipfile string) (err error) {
198 // Double-check that the zipfile was not created while we were waiting for
199 // the lock in downloadZip.
200 if _, err := os.Stat(zipfile); err == nil {
201 return nil
202 }
203
204 // Create parent directories.
205 if err := os.MkdirAll(filepath.Dir(zipfile), 0777); err != nil {
206 return err
207 }
208
209 // Clean up any remaining tempfiles from previous runs.
210 // This is only safe to do because the lock file ensures that their
211 // writers are no longer active.
212 tmpPattern := filepath.Base(zipfile) + "*.tmp"
213 if old, err := filepath.Glob(filepath.Join(quoteGlob(filepath.Dir(zipfile)), tmpPattern)); err == nil {
214 for _, path := range old {
215 os.Remove(path) // best effort
216 }
217 }
218
219 // From here to the os.Rename call below is functionally almost equivalent to
220 // renameio.WriteToFile. We avoid using that so that we have control over the
221 // names of the temporary files (see the cleanup above) and to avoid adding
222 // renameio as an extra dependency.
223 f, err := tempFile(ctx, filepath.Dir(zipfile), filepath.Base(zipfile), 0666)
224 if err != nil {
225 return err
226 }
227 defer func() {
228 if err != nil {
229 f.Close()
230 os.Remove(f.Name())
231 }
232 }()
233
234 // TODO cache the result of GetModule so we don't have to do
235 // an extra round trip when we've already fetched the module file.
236 m, err := c.reg.GetModule(ctx, mod)
237 if err != nil {
238 return err
239 }
240 r, err := m.GetZip(ctx)
241 if err != nil {
242 return err
243 }
244 defer r.Close()
245 if _, err := io.Copy(f, r); err != nil {
246 return fmt.Errorf("failed to get module zip contents: %v", err)
247 }
248 if err := f.Close(); err != nil {
249 return err
250 }
251 if err := os.Rename(f.Name(), zipfile); err != nil {
252 return err
253 }
254 // TODO should we check the zip file for well-formedness?
255 // TODO: Should we make the .zip file read-only to discourage tampering?
256 return nil
257}
258
259func (c *Cache) downloadModFile(ctx context.Context, mod module.Version) ([]byte, error) {
260 return c.modFileCache.Do(mod.String(), func() ([]byte, error) {
261 modfile, data, err := c.readDiskModFile(mod)
262 if err == nil {
263 return data, nil
264 }
265 logf("cue: downloading %s", mod)
266 unlock, err := c.lockVersion(mod)
267 if err != nil {
268 return nil, err
269 }
270 defer unlock()
271 // Double-check that the file hasn't been created while we were
272 // acquiring the lock.
273 _, data, err = c.readDiskModFile(mod)
274 if err == nil {
275 return data, nil
276 }
277 return c.downloadModFile1(ctx, mod, modfile)
278 })
279}
280
281func (c *Cache) downloadModFile1(ctx context.Context, mod module.Version, modfile string) ([]byte, error) {
282 m, err := c.reg.GetModule(ctx, mod)
283 if err != nil {
284 return nil, err
285 }
286 data, err := m.ModuleFile(ctx)
287 if err != nil {
288 return nil, err
289 }
290 if err := c.writeDiskModFile(ctx, modfile, data); err != nil {
291 return nil, err
292 }
293 return data, nil
294}
295
296func (c *Cache) dirToLocation(fpath string) module.SourceLoc {
297 return module.SourceLoc{
298 FS: module.OSDirFS(fpath),
299 Dir: ".",
300 }
301}
302
303// makeDirsReadOnly makes a best-effort attempt to remove write permissions for dir
304// and its transitive contents.
305func makeDirsReadOnly(dir string) {
306 type pathMode struct {
307 path string
308 mode fs.FileMode
309 }
310 var dirs []pathMode // in lexical order
311 filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
312 if err == nil && d.IsDir() {
313 info, err := d.Info()
314 if err == nil && info.Mode()&0222 != 0 {
315 dirs = append(dirs, pathMode{path, info.Mode()})
316 }
317 }
318 return nil
319 })
320
321 // Run over list backward to chmod children before parents.
322 for _, dir := range slices.Backward(dirs) {
323 os.Chmod(dir.path, dir.mode&^0222)
324 }
325}
326
327// RemoveAll removes a directory written by the cache, first applying
328// any permission changes needed to do so.
329func RemoveAll(dir string) error {
330 // Module cache has 0555 directories; make them writable in order to remove content.
331 filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
332 if err != nil {
333 return nil // ignore errors walking in file system
334 }
335 if info.IsDir() {
336 os.Chmod(path, 0777)
337 }
338 return nil
339 })
340 return robustio.RemoveAll(dir)
341}
342
343// quoteGlob returns s with all Glob metacharacters quoted.
344// We don't try to handle backslash here, as that can appear in a
345// file path on Windows.
346func quoteGlob(s string) string {
347 if !strings.ContainsAny(s, `*?[]`) {
348 return s
349 }
350 var sb strings.Builder
351 for _, c := range s {
352 switch c {
353 case '*', '?', '[', ']':
354 sb.WriteByte('\\')
355 }
356 sb.WriteRune(c)
357 }
358 return sb.String()
359}
360
361// tempFile creates a new temporary file with given permission bits.
362func tempFile(ctx context.Context, dir, prefix string, perm fs.FileMode) (f *os.File, err error) {
363 for range 10000 {
364 name := filepath.Join(dir, prefix+strconv.Itoa(rand.IntN(1000000000))+".tmp")
365 f, err = os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm)
366 if os.IsExist(err) {
367 if ctx.Err() != nil {
368 return nil, ctx.Err()
369 }
370 continue
371 }
372 break
373 }
374 return
375}
376
377func logf(f string, a ...any) {
378 if logging {
379 log.Printf(f, a...)
380 }
381}