Go · 7075 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package storage
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "regexp"
13 "strings"
14 )
15
16 // RepoFS owns the on-disk layout for bare git repositories. All callers
17 // that touch repo paths route through this type so the path-validation
18 // rules live in exactly one place.
19 type RepoFS struct {
20 root string
21 }
22
23 // NewRepoFS validates root (must be absolute, must exist, must be a
24 // directory) and returns the layer.
25 func NewRepoFS(root string) (*RepoFS, error) {
26 if root == "" {
27 return nil, errors.New("storage: repofs: root required")
28 }
29 if !filepath.IsAbs(root) {
30 return nil, fmt.Errorf("storage: repofs: root must be absolute, got %q", root)
31 }
32 abs, err := filepath.Abs(filepath.Clean(root))
33 if err != nil {
34 return nil, fmt.Errorf("storage: repofs: clean root: %w", err)
35 }
36 info, err := os.Stat(abs)
37 if err != nil {
38 return nil, fmt.Errorf("storage: repofs: stat root: %w", err)
39 }
40 if !info.IsDir() {
41 return nil, fmt.Errorf("storage: repofs: root %q is not a directory", abs)
42 }
43 return &RepoFS{root: abs}, nil
44 }
45
46 // Root returns the absolute root path. Useful for logging and `storage check`.
47 func (r *RepoFS) Root() string { return r.root }
48
49 // ownerNameRE is the whitelist for owner names: lowercase ASCII letters,
50 // digits, and hyphens; cannot start or end with a hyphen; length 1..39
51 // (matches GitHub's username constraint).
52 var ownerNameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`)
53
54 // repoNameRE is the whitelist for repository names: lowercase ASCII
55 // letters, digits, hyphens, dots, and underscores. Can't start or end
56 // with a separator. Length 1..100 (matches GitHub).
57 var repoNameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9_])?$`)
58
59 // validateName enforces the per-kind whitelist. Returns ErrInvalidPath
60 // wrapped with a precise reason on failure.
61 func validateName(kind, name string) error {
62 if name == "" {
63 return fmt.Errorf("%w: %s empty", ErrInvalidPath, kind)
64 }
65 maxLen, re, alphabet := 39, ownerNameRE, "[a-z0-9-]"
66 if kind == "repo" {
67 maxLen, re, alphabet = 100, repoNameRE, "[a-z0-9._-]"
68 }
69 if len(name) > maxLen {
70 return fmt.Errorf("%w: %s %q too long (max %d)", ErrInvalidPath, kind, name, maxLen)
71 }
72 if name != strings.ToLower(name) {
73 return fmt.Errorf("%w: %s %q must be lowercase", ErrInvalidPath, kind, name)
74 }
75 if strings.Contains(name, "..") {
76 return fmt.Errorf("%w: %s contains dot-dot", ErrInvalidPath, kind)
77 }
78 if strings.HasPrefix(name, ".") {
79 return fmt.Errorf("%w: %s starts with dot", ErrInvalidPath, kind)
80 }
81 if filepath.IsAbs(name) {
82 return fmt.Errorf("%w: %s is absolute", ErrInvalidPath, kind)
83 }
84 if !re.MatchString(name) {
85 return fmt.Errorf("%w: %s %q fails whitelist %s", ErrInvalidPath, kind, name, alphabet)
86 }
87 return nil
88 }
89
90 // shardOf returns the two-character shard prefix for owner. When owner is
91 // shorter than two characters, pads with `_` so the path remains stable.
92 func shardOf(owner string) string {
93 switch len(owner) {
94 case 0:
95 return "__"
96 case 1:
97 return owner + "_"
98 default:
99 return owner[:2]
100 }
101 }
102
103 // RepoPath returns the absolute disk path for the bare repository at
104 // (owner, name). Validates inputs and guarantees the result is rooted at
105 // r.root. Both inputs are lowercased before path construction.
106 func (r *RepoFS) RepoPath(owner, name string) (string, error) {
107 owner = strings.ToLower(owner)
108 name = strings.ToLower(name)
109 if err := validateName("owner", owner); err != nil {
110 return "", err
111 }
112 if err := validateName("repo", name); err != nil {
113 return "", err
114 }
115 p := filepath.Join(r.root, shardOf(owner), owner, name+".git")
116 if err := r.containedInRoot(p); err != nil {
117 return "", err
118 }
119 return p, nil
120 }
121
122 // containedInRoot returns ErrEscapesRoot when p does not resolve under r.root.
123 // Defense-in-depth: validateName already rejects ".." and absolute paths,
124 // but a future caller might compose paths differently.
125 func (r *RepoFS) containedInRoot(p string) error {
126 clean := filepath.Clean(p)
127 if !strings.HasPrefix(clean, r.root+string(filepath.Separator)) && clean != r.root {
128 return fmt.Errorf("%w: %s not under %s", ErrEscapesRoot, clean, r.root)
129 }
130 return nil
131 }
132
133 // Exists reports whether path exists. Validates that path is under root.
134 func (r *RepoFS) Exists(path string) (bool, error) {
135 if err := r.containedInRoot(path); err != nil {
136 return false, err
137 }
138 _, err := os.Stat(path)
139 if err == nil {
140 return true, nil
141 }
142 if errors.Is(err, os.ErrNotExist) {
143 return false, nil
144 }
145 return false, fmt.Errorf("storage: repofs: stat %s: %w", path, err)
146 }
147
148 // InitBare creates a bare git repository at path. Default branch is
149 // "trunk" — there is no path through this package that creates a bare
150 // repo with a different initial branch.
151 //
152 // The parent directory tree is created on demand. ErrAlreadyExists is
153 // returned if path is non-empty.
154 func (r *RepoFS) InitBare(ctx context.Context, path string) error {
155 if err := r.containedInRoot(path); err != nil {
156 return err
157 }
158 if entries, err := os.ReadDir(path); err == nil && len(entries) > 0 {
159 return fmt.Errorf("%w: %s", ErrAlreadyExists, path)
160 }
161 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
162 return fmt.Errorf("storage: repofs: mkdir parent: %w", err)
163 }
164 if err := os.MkdirAll(path, 0o750); err != nil {
165 return fmt.Errorf("storage: repofs: mkdir target: %w", err)
166 }
167 // G204: path is constructed via RepoPath (strict whitelist) and verified
168 // to live under r.root. Caller cannot inject arbitrary args.
169 cmd := exec.CommandContext(ctx, "git", "init", "--bare", "--initial-branch=trunk", path) //nolint:gosec
170 out, err := cmd.CombinedOutput()
171 if err != nil {
172 return fmt.Errorf("storage: repofs: git init --bare: %w (output: %s)", err, strings.TrimSpace(string(out)))
173 }
174 return nil
175 }
176
177 // Move atomically renames oldPath to newPath. Both must be under root.
178 // If newPath already exists, returns ErrAlreadyExists rather than
179 // overwriting (avoids silent corruption on concurrent moves).
180 func (r *RepoFS) Move(oldPath, newPath string) error {
181 if err := r.containedInRoot(oldPath); err != nil {
182 return err
183 }
184 if err := r.containedInRoot(newPath); err != nil {
185 return err
186 }
187 if _, err := os.Stat(newPath); err == nil {
188 return fmt.Errorf("%w: %s", ErrAlreadyExists, newPath)
189 } else if !errors.Is(err, os.ErrNotExist) {
190 return fmt.Errorf("storage: repofs: stat dest: %w", err)
191 }
192 if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err != nil {
193 return fmt.Errorf("storage: repofs: mkdir parent: %w", err)
194 }
195 if err := os.Rename(oldPath, newPath); err != nil {
196 return fmt.Errorf("storage: repofs: rename: %w", err)
197 }
198 return nil
199 }
200
201 // Delete removes the bare repo at path. Refuses paths outside root.
202 func (r *RepoFS) Delete(path string) error {
203 if err := r.containedInRoot(path); err != nil {
204 return err
205 }
206 if path == r.root {
207 return fmt.Errorf("%w: refusing to delete root", ErrEscapesRoot)
208 }
209 if err := os.RemoveAll(path); err != nil {
210 return fmt.Errorf("storage: repofs: remove: %w", err)
211 }
212 return nil
213 }
214