// Package artifact keeps the files attached to a release, outside git. Chapter 22.2. package artifact import ( "fmt" "io" "io/fs" "os" "path/filepath" "sort" "strings" "time" "github.com/barerepo/server/internal/gitx" ) // File is one attached file, named and measured, which is what the releases page draws. type File struct { Name string Size int64 } // ValidName reports a file name safe to join to a path, which is the only check that matters here. func ValidName(name string) bool { if name == "" || len(name) > 128 || name == "." || name == ".." { return false } if strings.ContainsAny(name, "/\\\x00") || strings.HasPrefix(name, ".") { return false } for _, c := range name { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': case c == '.' || c == '-' || c == '_' || c == '+': default: return false } } return true } // dir resolves where one tag's files live, refusing anything that would leave the root. func dir(root, owner, name, tag string) (string, error) { if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) || !gitx.ValidRef("refs/tags/"+tag) { return "", fmt.Errorf("no such release") } absRoot, err := filepath.Abs(root) if err != nil { return "", err } // A tag may hold a slash, so it is one path element with the slash spelled out. safe := strings.ReplaceAll(tag, "/", "%2F") if safe == "." || safe == ".." || strings.HasPrefix(safe, ".") { return "", fmt.Errorf("no such release") } out := filepath.Join(absRoot, owner, name, safe) if !strings.HasPrefix(out, absRoot+string(os.PathSeparator)) { return "", fmt.Errorf("no such release") } return out, nil } // Put writes one attached file, replacing what was there, so a rerun does not double the list. func Put(root, owner, name, tag, file string, body io.Reader) (int64, error) { if !ValidName(file) { return 0, fmt.Errorf("%q is not a usable file name", file) } d, err := dir(root, owner, name, tag) if err != nil { return 0, err } if err := os.MkdirAll(d, 0o750); err != nil { return 0, err } // A unique part file, so two uploads of one name cannot write into each other. f, err := os.CreateTemp(d, "."+file+".*.part") if err != nil { return 0, err } tmp := f.Name() n, err := io.Copy(f, body) if cerr := f.Close(); err == nil { err = cerr } if err != nil { os.Remove(tmp) return 0, err } // The rename is what makes a half written upload invisible to a reader. if err := os.Rename(tmp, filepath.Join(d, file)); err != nil { os.Remove(tmp) return 0, err } return n, nil } // ListAll reads one repository's releases in a single pass, so a page with no files costs one stat. func ListAll(root, owner, name string) (map[string][]File, error) { d, err := repoDir(root, owner, name) if err != nil { return nil, err } tags, err := os.ReadDir(d) if err != nil { return nil, nil } out := make(map[string][]File, len(tags)) for _, t := range tags { if !t.IsDir() { continue } files, err := readFiles(filepath.Join(d, t.Name())) if err != nil || len(files) == 0 { continue } out[unescapeTag(t.Name())] = files } return out, nil } // readFiles names what is in one release's directory, skipping the part files an upload leaves. func readFiles(d string) ([]File, error) { entries, err := os.ReadDir(d) if err != nil { return nil, err } out := make([]File, 0, len(entries)) for _, e := range entries { if e.IsDir() || !ValidName(e.Name()) { continue } info, err := e.Info() if err != nil { continue } out = append(out, File{Name: e.Name(), Size: info.Size()}) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } func unescapeTag(dirName string) string { return strings.ReplaceAll(dirName, "%2F", "/") } // Open reads one attached file back, and the caller closes it. func Open(root, owner, name, tag, file string) (*os.File, os.FileInfo, error) { if !ValidName(file) { return nil, nil, fmt.Errorf("no such file") } d, err := dir(root, owner, name, tag) if err != nil { return nil, nil, err } f, err := os.Open(filepath.Join(d, file)) if err != nil { return nil, nil, err } info, err := f.Stat() if err != nil || info.IsDir() { f.Close() return nil, nil, fmt.Errorf("no such file") } return f, info, nil } // Move carries a repository's files to its new name, since nothing in git holds them. 22.2. func Move(root, oldOwner, oldName, newOwner, newName string) error { from, err := repoDir(root, oldOwner, oldName) if err != nil { return err } to, err := repoDir(root, newOwner, newName) if err != nil { return err } if _, err := os.Stat(from); err != nil { // A repository with nothing attached has nothing to move, which is most of them. return nil } if err := os.MkdirAll(filepath.Dir(to), 0o750); err != nil { return err } return os.Rename(from, to) } // repoDir is where one repository's releases live, refusing anything that would leave the root. func repoDir(root, owner, name string) (string, error) { if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) { return "", fmt.Errorf("no such repository") } absRoot, err := filepath.Abs(root) if err != nil { return "", err } out := filepath.Join(absRoot, owner, name) if !strings.HasPrefix(out, absRoot+string(os.PathSeparator)) { return "", fmt.Errorf("no such repository") } return out, nil } // Forget drops every file a repository has, for a delete, since the blob store is not in git. func Forget(root, owner, name string) error { out, err := repoDir(root, owner, name) if err != nil { return err } return os.RemoveAll(out) } // PartWindow is how long one upload may be in flight before its temporary counts as abandoned. const PartWindow = 24 * time.Hour // SweepParts removes what a killed process left mid-copy, which no page lists and nothing else frees. func SweepParts(root string, window time.Duration, now time.Time) (int, error) { if window <= 0 { return 0, nil } gone := 0 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } // Put writes "...part", and ValidName refuses a leading dot, so nothing else is one. name := d.Name() if !strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".part") { return nil } // Old enough that no upload is still writing it, since a live one is a file in use. info, err := d.Info() if err != nil || now.Sub(info.ModTime()) <= window { return nil } if os.Remove(p) == nil { gone++ } return nil }) if err != nil && !os.IsNotExist(err) { return gone, err } return gone, nil }